aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-02-11 11:52:34 +0100
committerChocobozzz <me@florianbigard.com>2019-02-11 11:52:34 +0100
commit88108880bbdba473cfe36ecbebc1c3c4f972e102 (patch)
treeb242efb3b4f0d7e49d88f2d1f2063b5b3b0489c0 /server
parent53a94c7cfa8368da4cd248d65df8346905938f0c (diff)
parent9b712a2017e4ab3cf12cd6bd58278905520159d0 (diff)
downloadPeerTube-88108880bbdba473cfe36ecbebc1c3c4f972e102.tar.gz
PeerTube-88108880bbdba473cfe36ecbebc1c3c4f972e102.tar.zst
PeerTube-88108880bbdba473cfe36ecbebc1c3c4f972e102.zip
Merge branch 'develop' into pr/1217
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts86
-rw-r--r--server/controllers/activitypub/inbox.ts6
-rw-r--r--server/controllers/api/accounts.ts18
-rw-r--r--server/controllers/api/config.ts77
-rw-r--r--server/controllers/api/search.ts3
-rw-r--r--server/controllers/api/server/contact.ts28
-rw-r--r--server/controllers/api/server/follows.ts16
-rw-r--r--server/controllers/api/server/index.ts4
-rw-r--r--server/controllers/api/server/server-blocklist.ts132
-rw-r--r--server/controllers/api/server/stats.ts7
-rw-r--r--server/controllers/api/users/index.ts33
-rw-r--r--server/controllers/api/users/me.ts165
-rw-r--r--server/controllers/api/users/my-blocklist.ts125
-rw-r--r--server/controllers/api/users/my-history.ts57
-rw-r--r--server/controllers/api/users/my-notifications.ts108
-rw-r--r--server/controllers/api/users/my-subscriptions.ts170
-rw-r--r--server/controllers/api/video-channel.ts18
-rw-r--r--server/controllers/api/videos/abuse.ts5
-rw-r--r--server/controllers/api/videos/blacklist.ts31
-rw-r--r--server/controllers/api/videos/captions.ts4
-rw-r--r--server/controllers/api/videos/comment.ts15
-rw-r--r--server/controllers/api/videos/import.ts21
-rw-r--r--server/controllers/api/videos/index.ts72
-rw-r--r--server/controllers/api/videos/rate.ts17
-rw-r--r--server/controllers/bots.ts101
-rw-r--r--server/controllers/client.ts21
-rw-r--r--server/controllers/feeds.ts12
-rw-r--r--server/controllers/index.ts1
-rw-r--r--server/controllers/static.ts24
-rw-r--r--server/controllers/tracker.ts46
-rw-r--r--server/helpers/activitypub.ts72
-rw-r--r--server/helpers/captions-utils.ts4
-rw-r--r--server/helpers/core-utils.ts80
-rw-r--r--server/helpers/custom-jsonld-signature.ts4
-rw-r--r--server/helpers/custom-validators/activitypub/activity.ts99
-rw-r--r--server/helpers/custom-validators/activitypub/actor.ts29
-rw-r--r--server/helpers/custom-validators/activitypub/announce.ts13
-rw-r--r--server/helpers/custom-validators/activitypub/cache-file.ts28
-rw-r--r--server/helpers/custom-validators/activitypub/flag.ts14
-rw-r--r--server/helpers/custom-validators/activitypub/misc.ts24
-rw-r--r--server/helpers/custom-validators/activitypub/rate.ts15
-rw-r--r--server/helpers/custom-validators/activitypub/undo.ts20
-rw-r--r--server/helpers/custom-validators/activitypub/video-comments.ts11
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts34
-rw-r--r--server/helpers/custom-validators/activitypub/view.ts10
-rw-r--r--server/helpers/custom-validators/misc.ts10
-rw-r--r--server/helpers/custom-validators/servers.ts11
-rw-r--r--server/helpers/custom-validators/user-notifications.ts23
-rw-r--r--server/helpers/custom-validators/users.ts10
-rw-r--r--server/helpers/custom-validators/video-captions.ts4
-rw-r--r--server/helpers/custom-validators/video-imports.ts4
-rw-r--r--server/helpers/custom-validators/videos.ts22
-rw-r--r--server/helpers/express-utils.ts7
-rw-r--r--server/helpers/ffmpeg-utils.ts209
-rw-r--r--server/helpers/image-utils.ts17
-rw-r--r--server/helpers/peertube-crypto.ts128
-rw-r--r--server/helpers/regexp.ts23
-rw-r--r--server/helpers/requests.ts21
-rw-r--r--server/helpers/utils.ts37
-rw-r--r--server/helpers/video.ts4
-rw-r--r--server/helpers/webtorrent.ts6
-rw-r--r--server/helpers/youtube-dl.ts8
-rw-r--r--server/initializers/checker-after-init.ts14
-rw-r--r--server/initializers/checker-before-init.ts13
-rw-r--r--server/initializers/constants.ts196
-rw-r--r--server/initializers/database.ts44
-rw-r--r--server/initializers/installer.ts26
-rw-r--r--server/initializers/migrations/0120-video-null.ts3
-rw-r--r--server/initializers/migrations/0195-support.ts9
-rw-r--r--server/initializers/migrations/0245-user-blocked.ts3
-rw-r--r--server/initializers/migrations/0250-video-abuse-state.ts3
-rw-r--r--server/initializers/migrations/0255-video-blacklist-reason.ts3
-rw-r--r--server/initializers/migrations/0260-upload-quota-daily.ts1
-rw-r--r--server/initializers/migrations/0275-video-file-unique.ts6
-rw-r--r--server/initializers/migrations/0280-webtorrent-policy-user.ts28
-rw-r--r--server/initializers/migrations/0285-description-support.ts53
-rw-r--r--server/initializers/migrations/0290-account-video-rate-url.ts46
-rw-r--r--server/initializers/migrations/0295-video-file-extname.ts49
-rw-r--r--server/initializers/migrations/0300-user-videos-history-enabled.ts27
-rw-r--r--server/initializers/migrations/0305-fix-unfederated-videos.ts52
-rw-r--r--server/initializers/migrations/0310-drop-unused-video-indexes.ts32
-rw-r--r--server/initializers/migrations/0315-user-notifications.ts47
-rw-r--r--server/initializers/migrations/0320-blacklist-unfederate.ts27
-rw-r--r--server/initializers/migrations/0325-video-abuse-fields.ts37
-rw-r--r--server/initializers/migrations/0330-video-streaming-playlist.ts51
-rw-r--r--server/lib/activitypub/actor.ts157
-rw-r--r--server/lib/activitypub/cache-file.ts23
-rw-r--r--server/lib/activitypub/crawl.ts7
-rw-r--r--server/lib/activitypub/process/index.ts8
-rw-r--r--server/lib/activitypub/process/process-accept.ts1
-rw-r--r--server/lib/activitypub/process/process-announce.ts8
-rw-r--r--server/lib/activitypub/process/process-create.ts122
-rw-r--r--server/lib/activitypub/process/process-dislike.ts52
-rw-r--r--server/lib/activitypub/process/process-flag.ts49
-rw-r--r--server/lib/activitypub/process/process-follow.ts14
-rw-r--r--server/lib/activitypub/process/process-like.ts6
-rw-r--r--server/lib/activitypub/process/process-undo.ts14
-rw-r--r--server/lib/activitypub/process/process-update.ts3
-rw-r--r--server/lib/activitypub/process/process-view.ts35
-rw-r--r--server/lib/activitypub/process/process.ts35
-rw-r--r--server/lib/activitypub/send/send-create.ts74
-rw-r--r--server/lib/activitypub/send/send-dislike.ts41
-rw-r--r--server/lib/activitypub/send/send-flag.ts39
-rw-r--r--server/lib/activitypub/send/send-like.ts2
-rw-r--r--server/lib/activitypub/send/send-undo.ts17
-rw-r--r--server/lib/activitypub/send/send-update.ts2
-rw-r--r--server/lib/activitypub/send/send-view.ts40
-rw-r--r--server/lib/activitypub/share.ts31
-rw-r--r--server/lib/activitypub/url.ts19
-rw-r--r--server/lib/activitypub/video-comments.ts21
-rw-r--r--server/lib/activitypub/video-rates.ts41
-rw-r--r--server/lib/activitypub/videos.ts280
-rw-r--r--server/lib/avatar.ts3
-rw-r--r--server/lib/blocklist.ts40
-rw-r--r--server/lib/cache/actor-follow-score-cache.ts46
-rw-r--r--server/lib/cache/index.ts1
-rw-r--r--server/lib/client-html.ts61
-rw-r--r--server/lib/emailer.ts261
-rw-r--r--server/lib/hls.ts164
-rw-r--r--server/lib/job-queue/handlers/activitypub-follow.ts9
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-broadcast.ts8
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-fetcher.ts2
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-unicast.ts11
-rw-r--r--server/lib/job-queue/handlers/activitypub-refresher.ts54
-rw-r--r--server/lib/job-queue/handlers/email.ts3
-rw-r--r--server/lib/job-queue/handlers/utils/activitypub-http-utils.ts29
-rw-r--r--server/lib/job-queue/handlers/video-file.ts106
-rw-r--r--server/lib/job-queue/handlers/video-import.ts27
-rw-r--r--server/lib/job-queue/handlers/video-views.ts21
-rw-r--r--server/lib/job-queue/job-queue.ts15
-rw-r--r--server/lib/notifier.ts455
-rw-r--r--server/lib/oauth-model.ts3
-rw-r--r--server/lib/peertube-socket.ts52
-rw-r--r--server/lib/redis.ts33
-rw-r--r--server/lib/schedulers/abstract-scheduler.ts18
-rw-r--r--server/lib/schedulers/actor-follow-scheduler.ts (renamed from server/lib/schedulers/bad-actor-follow-scheduler.ts)23
-rw-r--r--server/lib/schedulers/remove-old-jobs-scheduler.ts6
-rw-r--r--server/lib/schedulers/update-videos-scheduler.ts32
-rw-r--r--server/lib/schedulers/videos-redundancy-scheduler.ts201
-rw-r--r--server/lib/schedulers/youtube-dl-update-scheduler.ts2
-rw-r--r--server/lib/user.ts34
-rw-r--r--server/lib/video-comment.ts6
-rw-r--r--server/lib/video-transcoding.ts63
-rw-r--r--server/middlewares/activitypub.ts97
-rw-r--r--server/middlewares/cache.ts7
-rw-r--r--server/middlewares/csp.ts44
-rw-r--r--server/middlewares/dnt.ts2
-rw-r--r--server/middlewares/index.ts2
-rw-r--r--server/middlewares/oauth.ts38
-rw-r--r--server/middlewares/validators/activitypub/signature.ts16
-rw-r--r--server/middlewares/validators/blocklist.ts172
-rw-r--r--server/middlewares/validators/config.ts19
-rw-r--r--server/middlewares/validators/index.ts3
-rw-r--r--server/middlewares/validators/redundancy.ts33
-rw-r--r--server/middlewares/validators/search.ts38
-rw-r--r--server/middlewares/validators/server.ts78
-rw-r--r--server/middlewares/validators/sort.ts11
-rw-r--r--server/middlewares/validators/user-history.ts26
-rw-r--r--server/middlewares/validators/user-notifications.ts63
-rw-r--r--server/middlewares/validators/users.ts14
-rw-r--r--server/middlewares/validators/videos/index.ts2
-rw-r--r--server/middlewares/validators/videos/video-blacklist.ts15
-rw-r--r--server/middlewares/validators/videos/video-rates.ts55
-rw-r--r--server/middlewares/validators/videos/video-shares.ts38
-rw-r--r--server/middlewares/validators/videos/video-watch.ts7
-rw-r--r--server/middlewares/validators/videos/videos.ts165
-rw-r--r--server/models/account/account-blocklist.ts142
-rw-r--r--server/models/account/account-video-rate.ts60
-rw-r--r--server/models/account/account.ts25
-rw-r--r--server/models/account/user-notification-setting.ts150
-rw-r--r--server/models/account/user-notification.ts472
-rw-r--r--server/models/account/user-video-history.ts33
-rw-r--r--server/models/account/user.ts217
-rw-r--r--server/models/activitypub/actor-follow.ts148
-rw-r--r--server/models/activitypub/actor.ts1
-rw-r--r--server/models/avatar/avatar.ts5
-rw-r--r--server/models/oauth/oauth-token.ts6
-rw-r--r--server/models/redundancy/video-redundancy.ts158
-rw-r--r--server/models/server/server-blocklist.ts121
-rw-r--r--server/models/server/server.ts6
-rw-r--r--server/models/utils.ts22
-rw-r--r--server/models/video/video-abuse.ts24
-rw-r--r--server/models/video/video-blacklist.ts31
-rw-r--r--server/models/video/video-channel.ts25
-rw-r--r--server/models/video/video-comment.ts140
-rw-r--r--server/models/video/video-file.ts32
-rw-r--r--server/models/video/video-format-utils.ts69
-rw-r--r--server/models/video/video-import.ts4
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts158
-rw-r--r--server/models/video/video.ts372
-rw-r--r--server/tests/activitypub.ts35
-rw-r--r--server/tests/api/activitypub/client.ts67
-rw-r--r--server/tests/api/activitypub/fetch.ts87
-rw-r--r--server/tests/api/activitypub/helpers.ts182
-rw-r--r--server/tests/api/activitypub/index.ts5
-rw-r--r--server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json93
-rw-r--r--server/tests/api/activitypub/json/mastodon/bad-http-signature.json93
-rw-r--r--server/tests/api/activitypub/json/mastodon/bad-public-key.json3
-rw-r--r--server/tests/api/activitypub/json/mastodon/create-bad-signature.json81
-rw-r--r--server/tests/api/activitypub/json/mastodon/create.json81
-rw-r--r--server/tests/api/activitypub/json/mastodon/http-signature.json93
-rw-r--r--server/tests/api/activitypub/json/mastodon/public-key.json3
-rw-r--r--server/tests/api/activitypub/json/peertube/announce-without-context.json13
-rw-r--r--server/tests/api/activitypub/json/peertube/invalid-keys.json6
-rw-r--r--server/tests/api/activitypub/json/peertube/keys.json4
-rw-r--r--server/tests/api/activitypub/refresher.ts93
-rw-r--r--server/tests/api/activitypub/security.ts187
-rw-r--r--server/tests/api/check-params/accounts.ts14
-rw-r--r--server/tests/api/check-params/blocklist.ts498
-rw-r--r--server/tests/api/check-params/config.ts9
-rw-r--r--server/tests/api/check-params/contact-form.ts96
-rw-r--r--server/tests/api/check-params/follows.ts8
-rw-r--r--server/tests/api/check-params/index.ts5
-rw-r--r--server/tests/api/check-params/jobs.ts18
-rw-r--r--server/tests/api/check-params/redundancy.ts2
-rw-r--r--server/tests/api/check-params/search.ts8
-rw-r--r--server/tests/api/check-params/services.ts10
-rw-r--r--server/tests/api/check-params/user-notifications.ts297
-rw-r--r--server/tests/api/check-params/user-subscriptions.ts14
-rw-r--r--server/tests/api/check-params/users.ts60
-rw-r--r--server/tests/api/check-params/video-abuses.ts14
-rw-r--r--server/tests/api/check-params/video-blacklist.ts126
-rw-r--r--server/tests/api/check-params/video-captions.ts4
-rw-r--r--server/tests/api/check-params/video-channels.ts16
-rw-r--r--server/tests/api/check-params/video-comments.ts10
-rw-r--r--server/tests/api/check-params/video-imports.ts12
-rw-r--r--server/tests/api/check-params/videos-filter.ts127
-rw-r--r--server/tests/api/check-params/videos-history.ts72
-rw-r--r--server/tests/api/check-params/videos.ts21
-rw-r--r--server/tests/api/index-4.ts2
-rw-r--r--server/tests/api/index.ts1
-rw-r--r--server/tests/api/redundancy/index.ts1
-rw-r--r--server/tests/api/redundancy/redundancy.ts (renamed from server/tests/api/server/redundancy.ts)279
-rw-r--r--server/tests/api/search/search-activitypub-video-channels.ts6
-rw-r--r--server/tests/api/search/search-activitypub-videos.ts4
-rw-r--r--server/tests/api/search/search-videos.ts2
-rw-r--r--server/tests/api/server/config.ts62
-rw-r--r--server/tests/api/server/contact-form.ts86
-rw-r--r--server/tests/api/server/email.ts16
-rw-r--r--server/tests/api/server/follow-constraints.ts225
-rw-r--r--server/tests/api/server/follows.ts67
-rw-r--r--server/tests/api/server/handle-down.ts22
-rw-r--r--server/tests/api/server/index.ts4
-rw-r--r--server/tests/api/server/jobs.ts12
-rw-r--r--server/tests/api/server/no-client.ts36
-rw-r--r--server/tests/api/server/reverse-proxy.ts6
-rw-r--r--server/tests/api/server/stats.ts14
-rw-r--r--server/tests/api/server/tracker.ts4
-rw-r--r--server/tests/api/users/blocklist.ts511
-rw-r--r--server/tests/api/users/index.ts4
-rw-r--r--server/tests/api/users/user-notifications.ts1053
-rw-r--r--server/tests/api/users/user-subscriptions.ts19
-rw-r--r--server/tests/api/users/users-multiple-servers.ts10
-rw-r--r--server/tests/api/users/users-verification.ts13
-rw-r--r--server/tests/api/users/users.ts62
-rw-r--r--server/tests/api/videos/index.ts3
-rw-r--r--server/tests/api/videos/multiple-servers.ts23
-rw-r--r--server/tests/api/videos/services.ts12
-rw-r--r--server/tests/api/videos/single-server.ts4
-rw-r--r--server/tests/api/videos/video-abuse.ts6
-rw-r--r--server/tests/api/videos/video-blacklist-management.ts193
-rw-r--r--server/tests/api/videos/video-blacklist.ts305
-rw-r--r--server/tests/api/videos/video-captions.ts15
-rw-r--r--server/tests/api/videos/video-change-ownership.ts4
-rw-r--r--server/tests/api/videos/video-channels.ts12
-rw-r--r--server/tests/api/videos/video-comments.ts6
-rw-r--r--server/tests/api/videos/video-description.ts6
-rw-r--r--server/tests/api/videos/video-hls.ts139
-rw-r--r--server/tests/api/videos/video-imports.ts8
-rw-r--r--server/tests/api/videos/video-nsfw.ts17
-rw-r--r--server/tests/api/videos/video-privacy.ts12
-rw-r--r--server/tests/api/videos/video-schedule-update.ts5
-rw-r--r--server/tests/api/videos/video-transcoder.ts90
-rw-r--r--server/tests/api/videos/videos-filter.ts130
-rw-r--r--server/tests/api/videos/videos-history.ts87
-rw-r--r--server/tests/api/videos/videos-overview.ts4
-rw-r--r--server/tests/cli/create-import-video-file-job.ts4
-rw-r--r--server/tests/cli/create-transcoding-job.ts4
-rw-r--r--server/tests/cli/index.ts1
-rw-r--r--server/tests/cli/optimize-old-videos.ts120
-rw-r--r--server/tests/cli/peertube.ts4
-rw-r--r--server/tests/cli/reset-password.ts2
-rw-r--r--server/tests/cli/update-host.ts19
-rw-r--r--server/tests/client.ts2
-rw-r--r--server/tests/feeds/feeds.ts6
-rw-r--r--server/tests/fixtures/video_short.avibin0 -> 584656 bytes
-rw-r--r--server/tests/fixtures/video_short.mkvbin0 -> 40642 bytes
-rw-r--r--server/tests/fixtures/video_short_240p.mp4bin0 -> 14082 bytes
-rw-r--r--server/tests/helpers/comment-model.ts25
-rw-r--r--server/tests/helpers/core-utils.ts98
-rw-r--r--server/tests/helpers/index.ts2
-rw-r--r--server/tests/index.ts1
-rw-r--r--server/tests/misc-endpoints.ts82
-rw-r--r--server/tests/real-world/populate-database.ts2
-rw-r--r--server/tests/real-world/real-world.ts4
-rw-r--r--server/tests/utils/cli/cli.ts24
-rw-r--r--server/tests/utils/feeds/feeds.ts32
-rw-r--r--server/tests/utils/index.ts18
-rw-r--r--server/tests/utils/miscs/email.ts25
-rw-r--r--server/tests/utils/miscs/miscs.ts72
-rw-r--r--server/tests/utils/overviews/overviews.ts18
-rw-r--r--server/tests/utils/requests/check-api-params.ts40
-rw-r--r--server/tests/utils/requests/requests.ts170
-rw-r--r--server/tests/utils/search/video-channels.ts22
-rw-r--r--server/tests/utils/search/videos.ts77
-rw-r--r--server/tests/utils/server/activitypub.ts15
-rw-r--r--server/tests/utils/server/clients.ts19
-rw-r--r--server/tests/utils/server/config.ts135
-rw-r--r--server/tests/utils/server/follows.ts77
-rw-r--r--server/tests/utils/server/jobs.ts77
-rw-r--r--server/tests/utils/server/redundancy.ts17
-rw-r--r--server/tests/utils/server/servers.ts185
-rw-r--r--server/tests/utils/server/stats.ts22
-rw-r--r--server/tests/utils/users/accounts.ts63
-rw-r--r--server/tests/utils/users/login.ts62
-rw-r--r--server/tests/utils/users/user-subscriptions.ts82
-rw-r--r--server/tests/utils/users/users.ts295
-rw-r--r--server/tests/utils/videos/services.ts23
-rw-r--r--server/tests/utils/videos/video-abuses.ts65
-rw-r--r--server/tests/utils/videos/video-blacklist.ts67
-rw-r--r--server/tests/utils/videos/video-captions.ts71
-rw-r--r--server/tests/utils/videos/video-change-ownership.ts54
-rw-r--r--server/tests/utils/videos/video-channels.ts118
-rw-r--r--server/tests/utils/videos/video-comments.ts83
-rw-r--r--server/tests/utils/videos/video-history.ts14
-rw-r--r--server/tests/utils/videos/video-imports.ts51
-rw-r--r--server/tests/utils/videos/videos.ts582
-rw-r--r--server/tools/README.md82
-rw-r--r--server/tools/cli.ts2
-rw-r--r--server/tools/peertube-auth.ts113
-rw-r--r--server/tools/peertube-get-access-token.ts2
-rw-r--r--server/tools/peertube-import-videos.ts34
-rw-r--r--server/tools/peertube-repl.ts76
-rw-r--r--server/tools/peertube-upload.ts4
-rwxr-xr-xserver/tools/peertube.ts3
336 files changed, 13823 insertions, 5205 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 433186179..31c0a5fbd 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -3,7 +3,7 @@ import * as express from 'express'
3import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos' 3import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos'
4import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub' 4import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub'
5import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers' 5import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers'
6import { buildAnnounceWithVideoAudience } from '../../lib/activitypub/send' 6import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/activitypub/send'
7import { audiencify, getAudience } from '../../lib/activitypub/audience' 7import { audiencify, getAudience } from '../../lib/activitypub/audience'
8import { buildCreateActivity } from '../../lib/activitypub/send/send-create' 8import { buildCreateActivity } from '../../lib/activitypub/send/send-create'
9import { 9import {
@@ -11,9 +11,10 @@ import {
11 executeIfActivityPub, 11 executeIfActivityPub,
12 localAccountValidator, 12 localAccountValidator,
13 localVideoChannelValidator, 13 localVideoChannelValidator,
14 videosCustomGetValidator 14 videosCustomGetValidator,
15 videosShareValidator
15} from '../../middlewares' 16} from '../../middlewares'
16import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators' 17import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators'
17import { AccountModel } from '../../models/account/account' 18import { AccountModel } from '../../models/account/account'
18import { ActorModel } from '../../models/activitypub/actor' 19import { ActorModel } from '../../models/activitypub/actor'
19import { ActorFollowModel } from '../../models/activitypub/actor-follow' 20import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@@ -25,14 +26,17 @@ import { cacheRoute } from '../../middlewares/cache'
25import { activityPubResponse } from './utils' 26import { activityPubResponse } from './utils'
26import { AccountVideoRateModel } from '../../models/account/account-video-rate' 27import { AccountVideoRateModel } from '../../models/account/account-video-rate'
27import { 28import {
29 getRateUrl,
28 getVideoCommentsActivityPubUrl, 30 getVideoCommentsActivityPubUrl,
29 getVideoDislikesActivityPubUrl, 31 getVideoDislikesActivityPubUrl,
30 getVideoLikesActivityPubUrl, 32 getVideoLikesActivityPubUrl,
31 getVideoSharesActivityPubUrl 33 getVideoSharesActivityPubUrl
32} from '../../lib/activitypub' 34} from '../../lib/activitypub'
33import { VideoCaptionModel } from '../../models/video/video-caption' 35import { VideoCaptionModel } from '../../models/video/video-caption'
34import { videoRedundancyGetValidator } from '../../middlewares/validators/redundancy' 36import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy'
35import { getServerActor } from '../../helpers/utils' 37import { getServerActor } from '../../helpers/utils'
38import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
39import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
36 40
37const activityPubClientRouter = express.Router() 41const activityPubClientRouter = express.Router()
38 42
@@ -48,21 +52,29 @@ activityPubClientRouter.get('/accounts?/:name/following',
48 executeIfActivityPub(asyncMiddleware(localAccountValidator)), 52 executeIfActivityPub(asyncMiddleware(localAccountValidator)),
49 executeIfActivityPub(asyncMiddleware(accountFollowingController)) 53 executeIfActivityPub(asyncMiddleware(accountFollowingController))
50) 54)
55activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
56 executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))),
57 executeIfActivityPub(getAccountVideoRate('like'))
58)
59activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId',
60 executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('dislike'))),
61 executeIfActivityPub(getAccountVideoRate('dislike'))
62)
51 63
52activityPubClientRouter.get('/videos/watch/:id', 64activityPubClientRouter.get('/videos/watch/:id',
53 executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))), 65 executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))),
54 executeIfActivityPub(asyncMiddleware(videosGetValidator)), 66 executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video-with-rights'))),
55 executeIfActivityPub(asyncMiddleware(videoController)) 67 executeIfActivityPub(asyncMiddleware(videoController))
56) 68)
57activityPubClientRouter.get('/videos/watch/:id/activity', 69activityPubClientRouter.get('/videos/watch/:id/activity',
58 executeIfActivityPub(asyncMiddleware(videosGetValidator)), 70 executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video-with-rights'))),
59 executeIfActivityPub(asyncMiddleware(videoController)) 71 executeIfActivityPub(asyncMiddleware(videoController))
60) 72)
61activityPubClientRouter.get('/videos/watch/:id/announces', 73activityPubClientRouter.get('/videos/watch/:id/announces',
62 executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))), 74 executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
63 executeIfActivityPub(asyncMiddleware(videoAnnouncesController)) 75 executeIfActivityPub(asyncMiddleware(videoAnnouncesController))
64) 76)
65activityPubClientRouter.get('/videos/watch/:id/announces/:accountId', 77activityPubClientRouter.get('/videos/watch/:id/announces/:actorId',
66 executeIfActivityPub(asyncMiddleware(videosShareValidator)), 78 executeIfActivityPub(asyncMiddleware(videosShareValidator)),
67 executeIfActivityPub(asyncMiddleware(videoAnnounceController)) 79 executeIfActivityPub(asyncMiddleware(videoAnnounceController))
68) 80)
@@ -101,7 +113,11 @@ activityPubClientRouter.get('/video-channels/:name/following',
101) 113)
102 114
103activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?', 115activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?',
104 executeIfActivityPub(asyncMiddleware(videoRedundancyGetValidator)), 116 executeIfActivityPub(asyncMiddleware(videoFileRedundancyGetValidator)),
117 executeIfActivityPub(asyncMiddleware(videoRedundancyController))
118)
119activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/:videoId',
120 executeIfActivityPub(asyncMiddleware(videoPlaylistRedundancyGetValidator)),
105 executeIfActivityPub(asyncMiddleware(videoRedundancyController)) 121 executeIfActivityPub(asyncMiddleware(videoRedundancyController))
106) 122)
107 123
@@ -133,8 +149,25 @@ async function accountFollowingController (req: express.Request, res: express.Re
133 return activityPubResponse(activityPubContextify(activityPubResult), res) 149 return activityPubResponse(activityPubContextify(activityPubResult), res)
134} 150}
135 151
136async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) { 152function getAccountVideoRate (rateType: VideoRateType) {
137 const video: VideoModel = res.locals.video 153 return (req: express.Request, res: express.Response) => {
154 const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate
155
156 const byActor = accountVideoRate.Account.Actor
157 const url = getRateUrl(rateType, byActor, accountVideoRate.Video)
158 const APObject = rateType === 'like'
159 ? buildLikeActivity(url, byActor, accountVideoRate.Video)
160 : buildDislikeActivity(url, byActor, accountVideoRate.Video)
161
162 return activityPubResponse(activityPubContextify(APObject), res)
163 }
164}
165
166async function videoController (req: express.Request, res: express.Response) {
167 // We need more attributes
168 const video: VideoModel = await VideoModel.loadForGetAPI(res.locals.video.id)
169
170 if (video.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(video.url)
138 171
139 // We need captions to render AP object 172 // We need captions to render AP object
140 video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id) 173 video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id)
@@ -150,14 +183,17 @@ async function videoController (req: express.Request, res: express.Response, nex
150 return activityPubResponse(activityPubContextify(videoObject), res) 183 return activityPubResponse(activityPubContextify(videoObject), res)
151} 184}
152 185
153async function videoAnnounceController (req: express.Request, res: express.Response, next: express.NextFunction) { 186async function videoAnnounceController (req: express.Request, res: express.Response) {
154 const share = res.locals.videoShare as VideoShareModel 187 const share = res.locals.videoShare as VideoShareModel
188
189 if (share.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(share.url)
190
155 const { activity } = await buildAnnounceWithVideoAudience(share.Actor, share, res.locals.video, undefined) 191 const { activity } = await buildAnnounceWithVideoAudience(share.Actor, share, res.locals.video, undefined)
156 192
157 return activityPubResponse(activityPubContextify(activity), res) 193 return activityPubResponse(activityPubContextify(activity), res)
158} 194}
159 195
160async function videoAnnouncesController (req: express.Request, res: express.Response, next: express.NextFunction) { 196async function videoAnnouncesController (req: express.Request, res: express.Response) {
161 const video: VideoModel = res.locals.video 197 const video: VideoModel = res.locals.video
162 198
163 const handler = async (start: number, count: number) => { 199 const handler = async (start: number, count: number) => {
@@ -172,21 +208,21 @@ async function videoAnnouncesController (req: express.Request, res: express.Resp
172 return activityPubResponse(activityPubContextify(json), res) 208 return activityPubResponse(activityPubContextify(json), res)
173} 209}
174 210
175async function videoLikesController (req: express.Request, res: express.Response, next: express.NextFunction) { 211async function videoLikesController (req: express.Request, res: express.Response) {
176 const video: VideoModel = res.locals.video 212 const video: VideoModel = res.locals.video
177 const json = await videoRates(req, 'like', video, getVideoLikesActivityPubUrl(video)) 213 const json = await videoRates(req, 'like', video, getVideoLikesActivityPubUrl(video))
178 214
179 return activityPubResponse(activityPubContextify(json), res) 215 return activityPubResponse(activityPubContextify(json), res)
180} 216}
181 217
182async function videoDislikesController (req: express.Request, res: express.Response, next: express.NextFunction) { 218async function videoDislikesController (req: express.Request, res: express.Response) {
183 const video: VideoModel = res.locals.video 219 const video: VideoModel = res.locals.video
184 const json = await videoRates(req, 'dislike', video, getVideoDislikesActivityPubUrl(video)) 220 const json = await videoRates(req, 'dislike', video, getVideoDislikesActivityPubUrl(video))
185 221
186 return activityPubResponse(activityPubContextify(json), res) 222 return activityPubResponse(activityPubContextify(json), res)
187} 223}
188 224
189async function videoCommentsController (req: express.Request, res: express.Response, next: express.NextFunction) { 225async function videoCommentsController (req: express.Request, res: express.Response) {
190 const video: VideoModel = res.locals.video 226 const video: VideoModel = res.locals.video
191 227
192 const handler = async (start: number, count: number) => { 228 const handler = async (start: number, count: number) => {
@@ -201,29 +237,31 @@ async function videoCommentsController (req: express.Request, res: express.Respo
201 return activityPubResponse(activityPubContextify(json), res) 237 return activityPubResponse(activityPubContextify(json), res)
202} 238}
203 239
204async function videoChannelController (req: express.Request, res: express.Response, next: express.NextFunction) { 240async function videoChannelController (req: express.Request, res: express.Response) {
205 const videoChannel: VideoChannelModel = res.locals.videoChannel 241 const videoChannel: VideoChannelModel = res.locals.videoChannel
206 242
207 return activityPubResponse(activityPubContextify(videoChannel.toActivityPubObject()), res) 243 return activityPubResponse(activityPubContextify(videoChannel.toActivityPubObject()), res)
208} 244}
209 245
210async function videoChannelFollowersController (req: express.Request, res: express.Response, next: express.NextFunction) { 246async function videoChannelFollowersController (req: express.Request, res: express.Response) {
211 const videoChannel: VideoChannelModel = res.locals.videoChannel 247 const videoChannel: VideoChannelModel = res.locals.videoChannel
212 const activityPubResult = await actorFollowers(req, videoChannel.Actor) 248 const activityPubResult = await actorFollowers(req, videoChannel.Actor)
213 249
214 return activityPubResponse(activityPubContextify(activityPubResult), res) 250 return activityPubResponse(activityPubContextify(activityPubResult), res)
215} 251}
216 252
217async function videoChannelFollowingController (req: express.Request, res: express.Response, next: express.NextFunction) { 253async function videoChannelFollowingController (req: express.Request, res: express.Response) {
218 const videoChannel: VideoChannelModel = res.locals.videoChannel 254 const videoChannel: VideoChannelModel = res.locals.videoChannel
219 const activityPubResult = await actorFollowing(req, videoChannel.Actor) 255 const activityPubResult = await actorFollowing(req, videoChannel.Actor)
220 256
221 return activityPubResponse(activityPubContextify(activityPubResult), res) 257 return activityPubResponse(activityPubContextify(activityPubResult), res)
222} 258}
223 259
224async function videoCommentController (req: express.Request, res: express.Response, next: express.NextFunction) { 260async function videoCommentController (req: express.Request, res: express.Response) {
225 const videoComment: VideoCommentModel = res.locals.videoComment 261 const videoComment: VideoCommentModel = res.locals.videoComment
226 262
263 if (videoComment.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(videoComment.url)
264
227 const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined) 265 const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined)
228 const isPublic = true // Comments are always public 266 const isPublic = true // Comments are always public
229 const audience = getAudience(videoComment.Account.Actor, isPublic) 267 const audience = getAudience(videoComment.Account.Actor, isPublic)
@@ -239,7 +277,9 @@ async function videoCommentController (req: express.Request, res: express.Respon
239} 277}
240 278
241async function videoRedundancyController (req: express.Request, res: express.Response) { 279async function videoRedundancyController (req: express.Request, res: express.Response) {
242 const videoRedundancy = res.locals.videoRedundancy 280 const videoRedundancy: VideoRedundancyModel = res.locals.videoRedundancy
281 if (videoRedundancy.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(videoRedundancy.url)
282
243 const serverActor = await getServerActor() 283 const serverActor = await getServerActor()
244 284
245 const audience = getAudience(serverActor) 285 const audience = getAudience(serverActor)
@@ -260,7 +300,7 @@ async function actorFollowing (req: express.Request, actor: ActorModel) {
260 return ActorFollowModel.listAcceptedFollowingUrlsForApi([ actor.id ], undefined, start, count) 300 return ActorFollowModel.listAcceptedFollowingUrlsForApi([ actor.id ], undefined, start, count)
261 } 301 }
262 302
263 return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.url, handler, req.query.page) 303 return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
264} 304}
265 305
266async function actorFollowers (req: express.Request, actor: ActorModel) { 306async function actorFollowers (req: express.Request, actor: ActorModel) {
@@ -268,7 +308,7 @@ async function actorFollowers (req: express.Request, actor: ActorModel) {
268 return ActorFollowModel.listAcceptedFollowerUrlsForApi([ actor.id ], undefined, start, count) 308 return ActorFollowModel.listAcceptedFollowerUrlsForApi([ actor.id ], undefined, start, count)
269 } 309 }
270 310
271 return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.url, handler, req.query.page) 311 return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
272} 312}
273 313
274function videoRates (req: express.Request, rateType: VideoRateType, video: VideoModel, url: string) { 314function videoRates (req: express.Request, rateType: VideoRateType, video: VideoModel, url: string) {
@@ -276,7 +316,7 @@ function videoRates (req: express.Request, rateType: VideoRateType, video: Video
276 const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count) 316 const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
277 return { 317 return {
278 total: result.count, 318 total: result.count,
279 data: result.rows.map(r => r.Account.Actor.url) 319 data: result.rows.map(r => r.url)
280 } 320 }
281 } 321 }
282 return activityPubCollectionPagination(url, handler, req.query.page) 322 return activityPubCollectionPagination(url, handler, req.query.page)
diff --git a/server/controllers/activitypub/inbox.ts b/server/controllers/activitypub/inbox.ts
index 738d155eb..f0e65015b 100644
--- a/server/controllers/activitypub/inbox.ts
+++ b/server/controllers/activitypub/inbox.ts
@@ -43,11 +43,13 @@ export {
43// --------------------------------------------------------------------------- 43// ---------------------------------------------------------------------------
44 44
45const inboxQueue = queue<{ activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel }, Error>((task, cb) => { 45const inboxQueue = queue<{ activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel }, Error>((task, cb) => {
46 processActivities(task.activities, task.signatureActor, task.inboxActor) 46 const options = { signatureActor: task.signatureActor, inboxActor: task.inboxActor }
47
48 processActivities(task.activities, options)
47 .then(() => cb()) 49 .then(() => cb())
48}) 50})
49 51
50function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) { 52function inboxController (req: express.Request, res: express.Response) {
51 const rootActivity: RootActivity = req.body 53 const rootActivity: RootActivity = req.body
52 let activities: Activity[] = [] 54 let activities: Activity[] = []
53 55
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
index b7691ccba..8c0237203 100644
--- a/server/controllers/api/accounts.ts
+++ b/server/controllers/api/accounts.ts
@@ -1,7 +1,8 @@
1import * as express from 'express' 1import * as express from 'express'
2import { getFormattedObjects } from '../../helpers/utils' 2import { getFormattedObjects } from '../../helpers/utils'
3import { 3import {
4 asyncMiddleware, commonVideosFiltersValidator, 4 asyncMiddleware,
5 commonVideosFiltersValidator,
5 listVideoAccountChannelsValidator, 6 listVideoAccountChannelsValidator,
6 optionalAuthenticate, 7 optionalAuthenticate,
7 paginationValidator, 8 paginationValidator,
@@ -13,6 +14,8 @@ import { AccountModel } from '../../models/account/account'
13import { VideoModel } from '../../models/video/video' 14import { VideoModel } from '../../models/video/video'
14import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 15import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
15import { VideoChannelModel } from '../../models/video/video-channel' 16import { VideoChannelModel } from '../../models/video/video-channel'
17import { JobQueue } from '../../lib/job-queue'
18import { logger } from '../../helpers/logger'
16 19
17const accountsRouter = express.Router() 20const accountsRouter = express.Router()
18 21
@@ -56,6 +59,11 @@ export {
56function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) { 59function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) {
57 const account: AccountModel = res.locals.account 60 const account: AccountModel = res.locals.account
58 61
62 if (account.isOutdated()) {
63 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: account.Actor.url } })
64 .catch(err => logger.error('Cannot create AP refresher job for actor %s.', account.Actor.url, { err }))
65 }
66
59 return res.json(account.toFormattedJSON()) 67 return res.json(account.toFormattedJSON())
60} 68}
61 69
@@ -73,10 +81,10 @@ async function listVideoAccountChannels (req: express.Request, res: express.Resp
73 81
74async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 82async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
75 const account: AccountModel = res.locals.account 83 const account: AccountModel = res.locals.account
76 const actorId = isUserAbleToSearchRemoteURI(res) ? null : undefined 84 const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
77 85
78 const resultList = await VideoModel.listForApi({ 86 const resultList = await VideoModel.listForApi({
79 actorId, 87 followerActorId,
80 start: req.query.start, 88 start: req.query.start,
81 count: req.query.count, 89 count: req.query.count,
82 sort: req.query.sort, 90 sort: req.query.sort,
@@ -86,9 +94,11 @@ async function listAccountVideos (req: express.Request, res: express.Response, n
86 languageOneOf: req.query.languageOneOf, 94 languageOneOf: req.query.languageOneOf,
87 tagsOneOf: req.query.tagsOneOf, 95 tagsOneOf: req.query.tagsOneOf,
88 tagsAllOf: req.query.tagsAllOf, 96 tagsAllOf: req.query.tagsAllOf,
97 filter: req.query.filter,
89 nsfw: buildNSFWFilter(res, req.query.nsfw), 98 nsfw: buildNSFWFilter(res, req.query.nsfw),
90 withFiles: false, 99 withFiles: false,
91 accountId: account.id 100 accountId: account.id,
101 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
92 }) 102 })
93 103
94 return res.json(getFormattedObjects(resultList.data, resultList.total)) 104 return res.json(getFormattedObjects(resultList.data, resultList.total))
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 03c1cec7b..1f3341bc0 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -1,5 +1,5 @@
1import * as express from 'express' 1import * as express from 'express'
2import { omit } from 'lodash' 2import { snakeCase } from 'lodash'
3import { ServerConfig, UserRight } from '../../../shared' 3import { ServerConfig, UserRight } from '../../../shared'
4import { About } from '../../../shared/models/server/about.model' 4import { About } from '../../../shared/models/server/about.model'
5import { CustomConfig } from '../../../shared/models/server/custom-config.model' 5import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -10,7 +10,10 @@ import { customConfigUpdateValidator } from '../../middlewares/validators/config
10import { ClientHtml } from '../../lib/client-html' 10import { ClientHtml } from '../../lib/client-html'
11import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger' 11import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger'
12import { remove, writeJSON } from 'fs-extra' 12import { remove, writeJSON } from 'fs-extra'
13import { getVersion } from '../../helpers/utils' 13import { getServerCommit } from '../../helpers/utils'
14import { Emailer } from '../../lib/emailer'
15import { isNumeric } from 'validator'
16import { objectConverter } from '../../helpers/core-utils'
14 17
15const packageJSON = require('../../../../package.json') 18const packageJSON = require('../../../../package.json')
16const configRouter = express.Router() 19const configRouter = express.Router()
@@ -40,11 +43,11 @@ configRouter.delete('/custom',
40) 43)
41 44
42let serverCommit: string 45let serverCommit: string
43async function getConfig (req: express.Request, res: express.Response, next: express.NextFunction) { 46async function getConfig (req: express.Request, res: express.Response) {
44 const allowed = await isSignupAllowed() 47 const allowed = await isSignupAllowed()
45 const allowedForCurrentIP = isSignupAllowedForCurrentIP(req.ip) 48 const allowedForCurrentIP = isSignupAllowedForCurrentIP(req.ip)
46 serverCommit = (serverCommit) ? serverCommit : await getVersion() 49
47 if (serverCommit === packageJSON.version) serverCommit = '' 50 if (serverCommit === undefined) serverCommit = await getServerCommit()
48 51
49 const enabledResolutions = Object.keys(CONFIG.TRANSCODING.RESOLUTIONS) 52 const enabledResolutions = Object.keys(CONFIG.TRANSCODING.RESOLUTIONS)
50 .filter(key => CONFIG.TRANSCODING.ENABLED === CONFIG.TRANSCODING.RESOLUTIONS[key] === true) 53 .filter(key => CONFIG.TRANSCODING.ENABLED === CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
@@ -61,6 +64,12 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
61 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS 64 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
62 } 65 }
63 }, 66 },
67 email: {
68 enabled: Emailer.isEnabled()
69 },
70 contactForm: {
71 enabled: CONFIG.CONTACT_FORM.ENABLED
72 },
64 serverVersion: packageJSON.version, 73 serverVersion: packageJSON.version,
65 serverCommit, 74 serverCommit,
66 signup: { 75 signup: {
@@ -69,6 +78,9 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
69 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION 78 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
70 }, 79 },
71 transcoding: { 80 transcoding: {
81 hls: {
82 enabled: CONFIG.TRANSCODING.HLS.ENABLED
83 },
72 enabledResolutions 84 enabledResolutions
73 }, 85 },
74 import: { 86 import: {
@@ -111,6 +123,11 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
111 user: { 123 user: {
112 videoQuota: CONFIG.USER.VIDEO_QUOTA, 124 videoQuota: CONFIG.USER.VIDEO_QUOTA,
113 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY 125 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
126 },
127 trending: {
128 videos: {
129 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
130 }
114 } 131 }
115 } 132 }
116 133
@@ -150,32 +167,10 @@ async function deleteCustomConfig (req: express.Request, res: express.Response,
150} 167}
151 168
152async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) { 169async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) {
153 const toUpdate: CustomConfig = req.body
154 const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig()) 170 const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig())
155 171
156 // Force number conversion 172 // camelCase to snake_case key + Force number conversion
157 toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10) 173 const toUpdateJSON = convertCustomConfigBody(req.body)
158 toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
159 toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
160 toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
161 toUpdate.user.videoQuotaDaily = parseInt('' + toUpdate.user.videoQuotaDaily, 10)
162 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
163
164 // camelCase to snake_case key
165 const toUpdateJSON = omit(
166 toUpdate,
167 'user.videoQuota',
168 'instance.defaultClientRoute',
169 'instance.shortDescription',
170 'cache.videoCaptions',
171 'signup.requiresEmailVerification'
172 )
173 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
174 toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily
175 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
176 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
177 toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
178 toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification
179 174
180 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) 175 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
181 176
@@ -237,12 +232,16 @@ function customConfig (): CustomConfig {
237 admin: { 232 admin: {
238 email: CONFIG.ADMIN.EMAIL 233 email: CONFIG.ADMIN.EMAIL
239 }, 234 },
235 contactForm: {
236 enabled: CONFIG.CONTACT_FORM.ENABLED
237 },
240 user: { 238 user: {
241 videoQuota: CONFIG.USER.VIDEO_QUOTA, 239 videoQuota: CONFIG.USER.VIDEO_QUOTA,
242 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY 240 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
243 }, 241 },
244 transcoding: { 242 transcoding: {
245 enabled: CONFIG.TRANSCODING.ENABLED, 243 enabled: CONFIG.TRANSCODING.ENABLED,
244 allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
246 threads: CONFIG.TRANSCODING.THREADS, 245 threads: CONFIG.TRANSCODING.THREADS,
247 resolutions: { 246 resolutions: {
248 '240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ], 247 '240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ],
@@ -250,6 +249,9 @@ function customConfig (): CustomConfig {
250 '480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ], 249 '480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ],
251 '720p': CONFIG.TRANSCODING.RESOLUTIONS[ '720p' ], 250 '720p': CONFIG.TRANSCODING.RESOLUTIONS[ '720p' ],
252 '1080p': CONFIG.TRANSCODING.RESOLUTIONS[ '1080p' ] 251 '1080p': CONFIG.TRANSCODING.RESOLUTIONS[ '1080p' ]
252 },
253 hls: {
254 enabled: CONFIG.TRANSCODING.HLS.ENABLED
253 } 255 }
254 }, 256 },
255 import: { 257 import: {
@@ -264,3 +266,20 @@ function customConfig (): CustomConfig {
264 } 266 }
265 } 267 }
266} 268}
269
270function convertCustomConfigBody (body: CustomConfig) {
271 function keyConverter (k: string) {
272 // Transcoding resolutions exception
273 if (/^\d{3,4}p$/.exec(k)) return k
274
275 return snakeCase(k)
276 }
277
278 function valueConverter (v: any) {
279 if (isNumeric(v + '')) return parseInt('' + v, 10)
280
281 return v
282 }
283
284 return objectConverter(body, keyConverter, valueConverter)
285}
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
index 4be2b5ef7..534305ba6 100644
--- a/server/controllers/api/search.ts
+++ b/server/controllers/api/search.ts
@@ -118,7 +118,8 @@ async function searchVideosDB (query: VideosSearchQuery, res: express.Response)
118 const options = Object.assign(query, { 118 const options = Object.assign(query, {
119 includeLocalVideos: true, 119 includeLocalVideos: true,
120 nsfw: buildNSFWFilter(res, query.nsfw), 120 nsfw: buildNSFWFilter(res, query.nsfw),
121 userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined 121 filter: query.filter,
122 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
122 }) 123 })
123 const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) 124 const resultList = await VideoModel.searchAndPopulateAccountAndServer(options)
124 125
diff --git a/server/controllers/api/server/contact.ts b/server/controllers/api/server/contact.ts
new file mode 100644
index 000000000..b1144c94e
--- /dev/null
+++ b/server/controllers/api/server/contact.ts
@@ -0,0 +1,28 @@
1import * as express from 'express'
2import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares'
3import { Redis } from '../../../lib/redis'
4import { Emailer } from '../../../lib/emailer'
5import { ContactForm } from '../../../../shared/models/server'
6
7const contactRouter = express.Router()
8
9contactRouter.post('/contact',
10 asyncMiddleware(contactAdministratorValidator),
11 asyncMiddleware(contactAdministrator)
12)
13
14async function contactAdministrator (req: express.Request, res: express.Response) {
15 const data = req.body as ContactForm
16
17 await Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.body)
18
19 await Redis.Instance.setContactFormIp(req.ip)
20
21 return res.status(204).end()
22}
23
24// ---------------------------------------------------------------------------
25
26export {
27 contactRouter
28}
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts
index d62400e42..9fa6c34ba 100644
--- a/server/controllers/api/server/follows.ts
+++ b/server/controllers/api/server/follows.ts
@@ -61,14 +61,26 @@ export {
61 61
62async function listFollowing (req: express.Request, res: express.Response, next: express.NextFunction) { 62async function listFollowing (req: express.Request, res: express.Response, next: express.NextFunction) {
63 const serverActor = await getServerActor() 63 const serverActor = await getServerActor()
64 const resultList = await ActorFollowModel.listFollowingForApi(serverActor.id, req.query.start, req.query.count, req.query.sort) 64 const resultList = await ActorFollowModel.listFollowingForApi(
65 serverActor.id,
66 req.query.start,
67 req.query.count,
68 req.query.sort,
69 req.query.search
70 )
65 71
66 return res.json(getFormattedObjects(resultList.data, resultList.total)) 72 return res.json(getFormattedObjects(resultList.data, resultList.total))
67} 73}
68 74
69async function listFollowers (req: express.Request, res: express.Response, next: express.NextFunction) { 75async function listFollowers (req: express.Request, res: express.Response, next: express.NextFunction) {
70 const serverActor = await getServerActor() 76 const serverActor = await getServerActor()
71 const resultList = await ActorFollowModel.listFollowersForApi(serverActor.id, req.query.start, req.query.count, req.query.sort) 77 const resultList = await ActorFollowModel.listFollowersForApi(
78 serverActor.id,
79 req.query.start,
80 req.query.count,
81 req.query.sort,
82 req.query.search
83 )
72 84
73 return res.json(getFormattedObjects(resultList.data, resultList.total)) 85 return res.json(getFormattedObjects(resultList.data, resultList.total))
74} 86}
diff --git a/server/controllers/api/server/index.ts b/server/controllers/api/server/index.ts
index 43bca2c10..814248e5f 100644
--- a/server/controllers/api/server/index.ts
+++ b/server/controllers/api/server/index.ts
@@ -2,12 +2,16 @@ import * as express from 'express'
2import { serverFollowsRouter } from './follows' 2import { serverFollowsRouter } from './follows'
3import { statsRouter } from './stats' 3import { statsRouter } from './stats'
4import { serverRedundancyRouter } from './redundancy' 4import { serverRedundancyRouter } from './redundancy'
5import { serverBlocklistRouter } from './server-blocklist'
6import { contactRouter } from './contact'
5 7
6const serverRouter = express.Router() 8const serverRouter = express.Router()
7 9
8serverRouter.use('/', serverFollowsRouter) 10serverRouter.use('/', serverFollowsRouter)
9serverRouter.use('/', serverRedundancyRouter) 11serverRouter.use('/', serverRedundancyRouter)
10serverRouter.use('/', statsRouter) 12serverRouter.use('/', statsRouter)
13serverRouter.use('/', serverBlocklistRouter)
14serverRouter.use('/', contactRouter)
11 15
12// --------------------------------------------------------------------------- 16// ---------------------------------------------------------------------------
13 17
diff --git a/server/controllers/api/server/server-blocklist.ts b/server/controllers/api/server/server-blocklist.ts
new file mode 100644
index 000000000..3cb3a96e2
--- /dev/null
+++ b/server/controllers/api/server/server-blocklist.ts
@@ -0,0 +1,132 @@
1import * as express from 'express'
2import 'multer'
3import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
4import {
5 asyncMiddleware,
6 asyncRetryTransactionMiddleware,
7 authenticate,
8 ensureUserHasRight,
9 paginationValidator,
10 setDefaultPagination,
11 setDefaultSort
12} from '../../../middlewares'
13import {
14 accountsBlocklistSortValidator,
15 blockAccountValidator,
16 blockServerValidator,
17 serversBlocklistSortValidator,
18 unblockAccountByServerValidator,
19 unblockServerByServerValidator
20} from '../../../middlewares/validators'
21import { AccountModel } from '../../../models/account/account'
22import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
23import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist'
24import { ServerBlocklistModel } from '../../../models/server/server-blocklist'
25import { ServerModel } from '../../../models/server/server'
26import { UserRight } from '../../../../shared/models/users'
27
28const serverBlocklistRouter = express.Router()
29
30serverBlocklistRouter.get('/blocklist/accounts',
31 authenticate,
32 ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
33 paginationValidator,
34 accountsBlocklistSortValidator,
35 setDefaultSort,
36 setDefaultPagination,
37 asyncMiddleware(listBlockedAccounts)
38)
39
40serverBlocklistRouter.post('/blocklist/accounts',
41 authenticate,
42 ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
43 asyncMiddleware(blockAccountValidator),
44 asyncRetryTransactionMiddleware(blockAccount)
45)
46
47serverBlocklistRouter.delete('/blocklist/accounts/:accountName',
48 authenticate,
49 ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
50 asyncMiddleware(unblockAccountByServerValidator),
51 asyncRetryTransactionMiddleware(unblockAccount)
52)
53
54serverBlocklistRouter.get('/blocklist/servers',
55 authenticate,
56 ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
57 paginationValidator,
58 serversBlocklistSortValidator,
59 setDefaultSort,
60 setDefaultPagination,
61 asyncMiddleware(listBlockedServers)
62)
63
64serverBlocklistRouter.post('/blocklist/servers',
65 authenticate,
66 ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
67 asyncMiddleware(blockServerValidator),
68 asyncRetryTransactionMiddleware(blockServer)
69)
70
71serverBlocklistRouter.delete('/blocklist/servers/:host',
72 authenticate,
73 ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
74 asyncMiddleware(unblockServerByServerValidator),
75 asyncRetryTransactionMiddleware(unblockServer)
76)
77
78export {
79 serverBlocklistRouter
80}
81
82// ---------------------------------------------------------------------------
83
84async function listBlockedAccounts (req: express.Request, res: express.Response) {
85 const serverActor = await getServerActor()
86
87 const resultList = await AccountBlocklistModel.listForApi(serverActor.Account.id, req.query.start, req.query.count, req.query.sort)
88
89 return res.json(getFormattedObjects(resultList.data, resultList.total))
90}
91
92async function blockAccount (req: express.Request, res: express.Response) {
93 const serverActor = await getServerActor()
94 const accountToBlock: AccountModel = res.locals.account
95
96 await addAccountInBlocklist(serverActor.Account.id, accountToBlock.id)
97
98 return res.status(204).end()
99}
100
101async function unblockAccount (req: express.Request, res: express.Response) {
102 const accountBlock: AccountBlocklistModel = res.locals.accountBlock
103
104 await removeAccountFromBlocklist(accountBlock)
105
106 return res.status(204).end()
107}
108
109async function listBlockedServers (req: express.Request, res: express.Response) {
110 const serverActor = await getServerActor()
111
112 const resultList = await ServerBlocklistModel.listForApi(serverActor.Account.id, req.query.start, req.query.count, req.query.sort)
113
114 return res.json(getFormattedObjects(resultList.data, resultList.total))
115}
116
117async function blockServer (req: express.Request, res: express.Response) {
118 const serverActor = await getServerActor()
119 const serverToBlock: ServerModel = res.locals.server
120
121 await addServerInBlocklist(serverActor.Account.id, serverToBlock.id)
122
123 return res.status(204).end()
124}
125
126async function unblockServer (req: express.Request, res: express.Response) {
127 const serverBlock: ServerBlocklistModel = res.locals.serverBlock
128
129 await removeServerFromBlocklist(serverBlock)
130
131 return res.status(204).end()
132}
diff --git a/server/controllers/api/server/stats.ts b/server/controllers/api/server/stats.ts
index 85803f69e..89ffd1717 100644
--- a/server/controllers/api/server/stats.ts
+++ b/server/controllers/api/server/stats.ts
@@ -8,6 +8,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
8import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 8import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
9import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../../initializers/constants' 9import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
10import { cacheRoute } from '../../../middlewares/cache' 10import { cacheRoute } from '../../../middlewares/cache'
11import { VideoFileModel } from '../../../models/video/video-file'
11 12
12const statsRouter = express.Router() 13const statsRouter = express.Router()
13 14
@@ -16,11 +17,12 @@ statsRouter.get('/stats',
16 asyncMiddleware(getStats) 17 asyncMiddleware(getStats)
17) 18)
18 19
19async function getStats (req: express.Request, res: express.Response, next: express.NextFunction) { 20async function getStats (req: express.Request, res: express.Response) {
20 const { totalLocalVideos, totalLocalVideoViews, totalVideos } = await VideoModel.getStats() 21 const { totalLocalVideos, totalLocalVideoViews, totalVideos } = await VideoModel.getStats()
21 const { totalLocalVideoComments, totalVideoComments } = await VideoCommentModel.getStats() 22 const { totalLocalVideoComments, totalVideoComments } = await VideoCommentModel.getStats()
22 const { totalUsers } = await UserModel.getStats() 23 const { totalUsers } = await UserModel.getStats()
23 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats() 24 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
25 const { totalLocalVideoFilesSize } = await VideoFileModel.getStats()
24 26
25 const videosRedundancyStats = await Promise.all( 27 const videosRedundancyStats = await Promise.all(
26 CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => { 28 CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => {
@@ -32,8 +34,9 @@ async function getStats (req: express.Request, res: express.Response, next: expr
32 const data: ServerStats = { 34 const data: ServerStats = {
33 totalLocalVideos, 35 totalLocalVideos,
34 totalLocalVideoViews, 36 totalLocalVideoViews,
35 totalVideos, 37 totalLocalVideoFilesSize,
36 totalLocalVideoComments, 38 totalLocalVideoComments,
39 totalVideos,
37 totalVideoComments, 40 totalVideoComments,
38 totalUsers, 41 totalUsers,
39 totalInstanceFollowers, 42 totalInstanceFollowers,
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 0b0081520..e3533a7f6 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -37,6 +37,11 @@ import { UserModel } from '../../../models/account/user'
37import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 37import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
38import { meRouter } from './me' 38import { meRouter } from './me'
39import { deleteUserToken } from '../../../lib/oauth-model' 39import { deleteUserToken } from '../../../lib/oauth-model'
40import { myBlocklistRouter } from './my-blocklist'
41import { myVideosHistoryRouter } from './my-history'
42import { myNotificationsRouter } from './my-notifications'
43import { Notifier } from '../../../lib/notifier'
44import { mySubscriptionsRouter } from './my-subscriptions'
40 45
41const auditLogger = auditLoggerFactory('users') 46const auditLogger = auditLoggerFactory('users')
42 47
@@ -53,6 +58,10 @@ const askSendEmailLimiter = new RateLimit({
53}) 58})
54 59
55const usersRouter = express.Router() 60const usersRouter = express.Router()
61usersRouter.use('/', myNotificationsRouter)
62usersRouter.use('/', mySubscriptionsRouter)
63usersRouter.use('/', myBlocklistRouter)
64usersRouter.use('/', myVideosHistoryRouter)
56usersRouter.use('/', meRouter) 65usersRouter.use('/', meRouter)
57 66
58usersRouter.get('/autocomplete', 67usersRouter.get('/autocomplete',
@@ -207,6 +216,8 @@ async function registerUser (req: express.Request, res: express.Response) {
207 await sendVerifyUserEmail(user) 216 await sendVerifyUserEmail(user)
208 } 217 }
209 218
219 Notifier.Instance.notifyOnNewUserRegistration(user)
220
210 return res.type('json').status(204).end() 221 return res.type('json').status(204).end()
211} 222}
212 223
@@ -218,7 +229,7 @@ async function unblockUser (req: express.Request, res: express.Response, next: e
218 return res.status(204).end() 229 return res.status(204).end()
219} 230}
220 231
221async function blockUser (req: express.Request, res: express.Response, next: express.NextFunction) { 232async function blockUser (req: express.Request, res: express.Response) {
222 const user: UserModel = res.locals.user 233 const user: UserModel = res.locals.user
223 const reason = req.body.reason 234 const reason = req.body.reason
224 235
@@ -227,23 +238,23 @@ async function blockUser (req: express.Request, res: express.Response, next: exp
227 return res.status(204).end() 238 return res.status(204).end()
228} 239}
229 240
230function getUser (req: express.Request, res: express.Response, next: express.NextFunction) { 241function getUser (req: express.Request, res: express.Response) {
231 return res.json((res.locals.user as UserModel).toFormattedJSON()) 242 return res.json((res.locals.user as UserModel).toFormattedJSON())
232} 243}
233 244
234async function autocompleteUsers (req: express.Request, res: express.Response, next: express.NextFunction) { 245async function autocompleteUsers (req: express.Request, res: express.Response) {
235 const resultList = await UserModel.autoComplete(req.query.search as string) 246 const resultList = await UserModel.autoComplete(req.query.search as string)
236 247
237 return res.json(resultList) 248 return res.json(resultList)
238} 249}
239 250
240async function listUsers (req: express.Request, res: express.Response, next: express.NextFunction) { 251async function listUsers (req: express.Request, res: express.Response) {
241 const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort) 252 const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort, req.query.search)
242 253
243 return res.json(getFormattedObjects(resultList.data, resultList.total)) 254 return res.json(getFormattedObjects(resultList.data, resultList.total))
244} 255}
245 256
246async function removeUser (req: express.Request, res: express.Response, next: express.NextFunction) { 257async function removeUser (req: express.Request, res: express.Response) {
247 const user: UserModel = res.locals.user 258 const user: UserModel = res.locals.user
248 259
249 await user.destroy() 260 await user.destroy()
@@ -253,13 +264,15 @@ async function removeUser (req: express.Request, res: express.Response, next: ex
253 return res.sendStatus(204) 264 return res.sendStatus(204)
254} 265}
255 266
256async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) { 267async function updateUser (req: express.Request, res: express.Response) {
257 const body: UserUpdate = req.body 268 const body: UserUpdate = req.body
258 const userToUpdate = res.locals.user as UserModel 269 const userToUpdate = res.locals.user as UserModel
259 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON()) 270 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON())
260 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role 271 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role
261 272
273 if (body.password !== undefined) userToUpdate.password = body.password
262 if (body.email !== undefined) userToUpdate.email = body.email 274 if (body.email !== undefined) userToUpdate.email = body.email
275 if (body.emailVerified !== undefined) userToUpdate.emailVerified = body.emailVerified
263 if (body.videoQuota !== undefined) userToUpdate.videoQuota = body.videoQuota 276 if (body.videoQuota !== undefined) userToUpdate.videoQuota = body.videoQuota
264 if (body.videoQuotaDaily !== undefined) userToUpdate.videoQuotaDaily = body.videoQuotaDaily 277 if (body.videoQuotaDaily !== undefined) userToUpdate.videoQuotaDaily = body.videoQuotaDaily
265 if (body.role !== undefined) userToUpdate.role = body.role 278 if (body.role !== undefined) userToUpdate.role = body.role
@@ -267,11 +280,11 @@ async function updateUser (req: express.Request, res: express.Response, next: ex
267 const user = await userToUpdate.save() 280 const user = await userToUpdate.save()
268 281
269 // Destroy user token to refresh rights 282 // Destroy user token to refresh rights
270 if (roleChanged) await deleteUserToken(userToUpdate.id) 283 if (roleChanged || body.password !== undefined) await deleteUserToken(userToUpdate.id)
271 284
272 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView) 285 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
273 286
274 // Don't need to send this update to followers, these attributes are not propagated 287 // Don't need to send this update to followers, these attributes are not federated
275 288
276 return res.sendStatus(204) 289 return res.sendStatus(204)
277} 290}
@@ -281,7 +294,7 @@ async function askResetUserPassword (req: express.Request, res: express.Response
281 294
282 const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id) 295 const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id)
283 const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString 296 const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
284 await Emailer.Instance.addForgetPasswordEmailJob(user.email, url) 297 await Emailer.Instance.addPasswordResetEmailJob(user.email, url)
285 298
286 return res.status(204).end() 299 return res.status(204).end()
287} 300}
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 591ec6b25..d5e154869 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -2,47 +2,34 @@ import * as express from 'express'
2import 'multer' 2import 'multer'
3import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared' 3import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared'
4import { getFormattedObjects } from '../../../helpers/utils' 4import { getFormattedObjects } from '../../../helpers/utils'
5import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../../initializers' 5import { CONFIG, MIMETYPES, sequelizeTypescript } from '../../../initializers'
6import { sendUpdateActor } from '../../../lib/activitypub/send' 6import { sendUpdateActor } from '../../../lib/activitypub/send'
7import { 7import {
8 asyncMiddleware, 8 asyncMiddleware,
9 asyncRetryTransactionMiddleware, 9 asyncRetryTransactionMiddleware,
10 authenticate, 10 authenticate,
11 commonVideosFiltersValidator,
12 paginationValidator, 11 paginationValidator,
13 setDefaultPagination, 12 setDefaultPagination,
14 setDefaultSort, 13 setDefaultSort,
15 userSubscriptionAddValidator,
16 userSubscriptionGetValidator,
17 usersUpdateMeValidator, 14 usersUpdateMeValidator,
18 usersVideoRatingValidator 15 usersVideoRatingValidator
19} from '../../../middlewares' 16} from '../../../middlewares'
20import { 17import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators'
21 areSubscriptionsExistValidator,
22 deleteMeValidator,
23 userSubscriptionsSortValidator,
24 videoImportsSortValidator,
25 videosSortValidator
26} from '../../../middlewares/validators'
27import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 18import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
28import { UserModel } from '../../../models/account/user' 19import { UserModel } from '../../../models/account/user'
29import { VideoModel } from '../../../models/video/video' 20import { VideoModel } from '../../../models/video/video'
30import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' 21import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
31import { buildNSFWFilter, createReqFiles } from '../../../helpers/express-utils' 22import { createReqFiles } from '../../../helpers/express-utils'
32import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model' 23import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
33import { updateAvatarValidator } from '../../../middlewares/validators/avatar' 24import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
34import { updateActorAvatarFile } from '../../../lib/avatar' 25import { updateActorAvatarFile } from '../../../lib/avatar'
35import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 26import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
36import { VideoImportModel } from '../../../models/video/video-import' 27import { VideoImportModel } from '../../../models/video/video-import'
37import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
38import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
39import { JobQueue } from '../../../lib/job-queue'
40import { logger } from '../../../helpers/logger'
41import { AccountModel } from '../../../models/account/account' 28import { AccountModel } from '../../../models/account/account'
42 29
43const auditLogger = auditLoggerFactory('users-me') 30const auditLogger = auditLoggerFactory('users-me')
44 31
45const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.AVATARS_DIR }) 32const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
46 33
47const meRouter = express.Router() 34const meRouter = express.Router()
48 35
@@ -98,51 +85,6 @@ meRouter.post('/me/avatar/pick',
98 asyncRetryTransactionMiddleware(updateMyAvatar) 85 asyncRetryTransactionMiddleware(updateMyAvatar)
99) 86)
100 87
101// ##### Subscriptions part #####
102
103meRouter.get('/me/subscriptions/videos',
104 authenticate,
105 paginationValidator,
106 videosSortValidator,
107 setDefaultSort,
108 setDefaultPagination,
109 commonVideosFiltersValidator,
110 asyncMiddleware(getUserSubscriptionVideos)
111)
112
113meRouter.get('/me/subscriptions/exist',
114 authenticate,
115 areSubscriptionsExistValidator,
116 asyncMiddleware(areSubscriptionsExist)
117)
118
119meRouter.get('/me/subscriptions',
120 authenticate,
121 paginationValidator,
122 userSubscriptionsSortValidator,
123 setDefaultSort,
124 setDefaultPagination,
125 asyncMiddleware(getUserSubscriptions)
126)
127
128meRouter.post('/me/subscriptions',
129 authenticate,
130 userSubscriptionAddValidator,
131 asyncMiddleware(addUserSubscription)
132)
133
134meRouter.get('/me/subscriptions/:uri',
135 authenticate,
136 userSubscriptionGetValidator,
137 getUserSubscription
138)
139
140meRouter.delete('/me/subscriptions/:uri',
141 authenticate,
142 userSubscriptionGetValidator,
143 asyncRetryTransactionMiddleware(deleteUserSubscription)
144)
145
146// --------------------------------------------------------------------------- 88// ---------------------------------------------------------------------------
147 89
148export { 90export {
@@ -151,99 +93,6 @@ export {
151 93
152// --------------------------------------------------------------------------- 94// ---------------------------------------------------------------------------
153 95
154async function areSubscriptionsExist (req: express.Request, res: express.Response) {
155 const uris = req.query.uris as string[]
156 const user = res.locals.oauth.token.User as UserModel
157
158 const handles = uris.map(u => {
159 let [ name, host ] = u.split('@')
160 if (host === CONFIG.WEBSERVER.HOST) host = null
161
162 return { name, host, uri: u }
163 })
164
165 const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles)
166
167 const existObject: { [id: string ]: boolean } = {}
168 for (const handle of handles) {
169 const obj = results.find(r => {
170 const server = r.ActorFollowing.Server
171
172 return r.ActorFollowing.preferredUsername === handle.name &&
173 (
174 (!server && !handle.host) ||
175 (server.host === handle.host)
176 )
177 })
178
179 existObject[handle.uri] = obj !== undefined
180 }
181
182 return res.json(existObject)
183}
184
185async function addUserSubscription (req: express.Request, res: express.Response) {
186 const user = res.locals.oauth.token.User as UserModel
187 const [ name, host ] = req.body.uri.split('@')
188
189 const payload = {
190 name,
191 host,
192 followerActorId: user.Account.Actor.id
193 }
194
195 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
196 .catch(err => logger.error('Cannot create follow job for subscription %s.', req.body.uri, err))
197
198 return res.status(204).end()
199}
200
201function getUserSubscription (req: express.Request, res: express.Response) {
202 const subscription: ActorFollowModel = res.locals.subscription
203
204 return res.json(subscription.ActorFollowing.VideoChannel.toFormattedJSON())
205}
206
207async function deleteUserSubscription (req: express.Request, res: express.Response) {
208 const subscription: ActorFollowModel = res.locals.subscription
209
210 await sequelizeTypescript.transaction(async t => {
211 return subscription.destroy({ transaction: t })
212 })
213
214 return res.type('json').status(204).end()
215}
216
217async function getUserSubscriptions (req: express.Request, res: express.Response) {
218 const user = res.locals.oauth.token.User as UserModel
219 const actorId = user.Account.Actor.id
220
221 const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort)
222
223 return res.json(getFormattedObjects(resultList.data, resultList.total))
224}
225
226async function getUserSubscriptionVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
227 const user = res.locals.oauth.token.User as UserModel
228 const resultList = await VideoModel.listForApi({
229 start: req.query.start,
230 count: req.query.count,
231 sort: req.query.sort,
232 includeLocalVideos: false,
233 categoryOneOf: req.query.categoryOneOf,
234 licenceOneOf: req.query.licenceOneOf,
235 languageOneOf: req.query.languageOneOf,
236 tagsOneOf: req.query.tagsOneOf,
237 tagsAllOf: req.query.tagsAllOf,
238 nsfw: buildNSFWFilter(res, req.query.nsfw),
239 filter: req.query.filter as VideoFilter,
240 withFiles: false,
241 actorId: user.Account.Actor.id
242 })
243
244 return res.json(getFormattedObjects(resultList.data, resultList.total))
245}
246
247async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 96async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
248 const user = res.locals.oauth.token.User as UserModel 97 const user = res.locals.oauth.token.User as UserModel
249 const resultList = await VideoModel.listUserVideosForApi( 98 const resultList = await VideoModel.listUserVideosForApi(
@@ -318,7 +167,7 @@ async function deleteMe (req: express.Request, res: express.Response) {
318 return res.sendStatus(204) 167 return res.sendStatus(204)
319} 168}
320 169
321async function updateMe (req: express.Request, res: express.Response, next: express.NextFunction) { 170async function updateMe (req: express.Request, res: express.Response) {
322 const body: UserUpdateMe = req.body 171 const body: UserUpdateMe = req.body
323 172
324 const user: UserModel = res.locals.oauth.token.user 173 const user: UserModel = res.locals.oauth.token.user
@@ -327,7 +176,9 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
327 if (body.password !== undefined) user.password = body.password 176 if (body.password !== undefined) user.password = body.password
328 if (body.email !== undefined) user.email = body.email 177 if (body.email !== undefined) user.email = body.email
329 if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy 178 if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy
179 if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
330 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo 180 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
181 if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
331 182
332 await sequelizeTypescript.transaction(async t => { 183 await sequelizeTypescript.transaction(async t => {
333 const userAccount = await AccountModel.load(user.Account.id) 184 const userAccount = await AccountModel.load(user.Account.id)
@@ -346,7 +197,7 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
346 return res.sendStatus(204) 197 return res.sendStatus(204)
347} 198}
348 199
349async function updateMyAvatar (req: express.Request, res: express.Response, next: express.NextFunction) { 200async function updateMyAvatar (req: express.Request, res: express.Response) {
350 const avatarPhysicalFile = req.files[ 'avatarfile' ][ 0 ] 201 const avatarPhysicalFile = req.files[ 'avatarfile' ][ 0 ]
351 const user: UserModel = res.locals.oauth.token.user 202 const user: UserModel = res.locals.oauth.token.user
352 const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) 203 const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
diff --git a/server/controllers/api/users/my-blocklist.ts b/server/controllers/api/users/my-blocklist.ts
new file mode 100644
index 000000000..9575eab46
--- /dev/null
+++ b/server/controllers/api/users/my-blocklist.ts
@@ -0,0 +1,125 @@
1import * as express from 'express'
2import 'multer'
3import { getFormattedObjects } from '../../../helpers/utils'
4import {
5 asyncMiddleware,
6 asyncRetryTransactionMiddleware,
7 authenticate,
8 paginationValidator,
9 setDefaultPagination,
10 setDefaultSort,
11 unblockAccountByAccountValidator
12} from '../../../middlewares'
13import {
14 accountsBlocklistSortValidator,
15 blockAccountValidator,
16 blockServerValidator,
17 serversBlocklistSortValidator,
18 unblockServerByAccountValidator
19} from '../../../middlewares/validators'
20import { UserModel } from '../../../models/account/user'
21import { AccountModel } from '../../../models/account/account'
22import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
23import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist'
24import { ServerBlocklistModel } from '../../../models/server/server-blocklist'
25import { ServerModel } from '../../../models/server/server'
26
27const myBlocklistRouter = express.Router()
28
29myBlocklistRouter.get('/me/blocklist/accounts',
30 authenticate,
31 paginationValidator,
32 accountsBlocklistSortValidator,
33 setDefaultSort,
34 setDefaultPagination,
35 asyncMiddleware(listBlockedAccounts)
36)
37
38myBlocklistRouter.post('/me/blocklist/accounts',
39 authenticate,
40 asyncMiddleware(blockAccountValidator),
41 asyncRetryTransactionMiddleware(blockAccount)
42)
43
44myBlocklistRouter.delete('/me/blocklist/accounts/:accountName',
45 authenticate,
46 asyncMiddleware(unblockAccountByAccountValidator),
47 asyncRetryTransactionMiddleware(unblockAccount)
48)
49
50myBlocklistRouter.get('/me/blocklist/servers',
51 authenticate,
52 paginationValidator,
53 serversBlocklistSortValidator,
54 setDefaultSort,
55 setDefaultPagination,
56 asyncMiddleware(listBlockedServers)
57)
58
59myBlocklistRouter.post('/me/blocklist/servers',
60 authenticate,
61 asyncMiddleware(blockServerValidator),
62 asyncRetryTransactionMiddleware(blockServer)
63)
64
65myBlocklistRouter.delete('/me/blocklist/servers/:host',
66 authenticate,
67 asyncMiddleware(unblockServerByAccountValidator),
68 asyncRetryTransactionMiddleware(unblockServer)
69)
70
71export {
72 myBlocklistRouter
73}
74
75// ---------------------------------------------------------------------------
76
77async function listBlockedAccounts (req: express.Request, res: express.Response) {
78 const user: UserModel = res.locals.oauth.token.User
79
80 const resultList = await AccountBlocklistModel.listForApi(user.Account.id, req.query.start, req.query.count, req.query.sort)
81
82 return res.json(getFormattedObjects(resultList.data, resultList.total))
83}
84
85async function blockAccount (req: express.Request, res: express.Response) {
86 const user: UserModel = res.locals.oauth.token.User
87 const accountToBlock: AccountModel = res.locals.account
88
89 await addAccountInBlocklist(user.Account.id, accountToBlock.id)
90
91 return res.status(204).end()
92}
93
94async function unblockAccount (req: express.Request, res: express.Response) {
95 const accountBlock: AccountBlocklistModel = res.locals.accountBlock
96
97 await removeAccountFromBlocklist(accountBlock)
98
99 return res.status(204).end()
100}
101
102async function listBlockedServers (req: express.Request, res: express.Response) {
103 const user: UserModel = res.locals.oauth.token.User
104
105 const resultList = await ServerBlocklistModel.listForApi(user.Account.id, req.query.start, req.query.count, req.query.sort)
106
107 return res.json(getFormattedObjects(resultList.data, resultList.total))
108}
109
110async function blockServer (req: express.Request, res: express.Response) {
111 const user: UserModel = res.locals.oauth.token.User
112 const serverToBlock: ServerModel = res.locals.server
113
114 await addServerInBlocklist(user.Account.id, serverToBlock.id)
115
116 return res.status(204).end()
117}
118
119async function unblockServer (req: express.Request, res: express.Response) {
120 const serverBlock: ServerBlocklistModel = res.locals.serverBlock
121
122 await removeServerFromBlocklist(serverBlock)
123
124 return res.status(204).end()
125}
diff --git a/server/controllers/api/users/my-history.ts b/server/controllers/api/users/my-history.ts
new file mode 100644
index 000000000..6cd782c47
--- /dev/null
+++ b/server/controllers/api/users/my-history.ts
@@ -0,0 +1,57 @@
1import * as express from 'express'
2import {
3 asyncMiddleware,
4 asyncRetryTransactionMiddleware,
5 authenticate,
6 paginationValidator,
7 setDefaultPagination,
8 userHistoryRemoveValidator
9} from '../../../middlewares'
10import { UserModel } from '../../../models/account/user'
11import { getFormattedObjects } from '../../../helpers/utils'
12import { UserVideoHistoryModel } from '../../../models/account/user-video-history'
13import { sequelizeTypescript } from '../../../initializers'
14
15const myVideosHistoryRouter = express.Router()
16
17myVideosHistoryRouter.get('/me/history/videos',
18 authenticate,
19 paginationValidator,
20 setDefaultPagination,
21 asyncMiddleware(listMyVideosHistory)
22)
23
24myVideosHistoryRouter.post('/me/history/videos/remove',
25 authenticate,
26 userHistoryRemoveValidator,
27 asyncRetryTransactionMiddleware(removeUserHistory)
28)
29
30// ---------------------------------------------------------------------------
31
32export {
33 myVideosHistoryRouter
34}
35
36// ---------------------------------------------------------------------------
37
38async function listMyVideosHistory (req: express.Request, res: express.Response) {
39 const user: UserModel = res.locals.oauth.token.User
40
41 const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count)
42
43 return res.json(getFormattedObjects(resultList.data, resultList.total))
44}
45
46async function removeUserHistory (req: express.Request, res: express.Response) {
47 const user: UserModel = res.locals.oauth.token.User
48 const beforeDate = req.body.beforeDate || null
49
50 await sequelizeTypescript.transaction(t => {
51 return UserVideoHistoryModel.removeHistoryBefore(user, beforeDate, t)
52 })
53
54 // Do not send the delete to other instances, we delete OUR copy of this video abuse
55
56 return res.type('json').status(204).end()
57}
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
new file mode 100644
index 000000000..76cf97587
--- /dev/null
+++ b/server/controllers/api/users/my-notifications.ts
@@ -0,0 +1,108 @@
1import * as express from 'express'
2import 'multer'
3import {
4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 paginationValidator,
8 setDefaultPagination,
9 setDefaultSort,
10 userNotificationsSortValidator
11} from '../../../middlewares'
12import { UserModel } from '../../../models/account/user'
13import { getFormattedObjects } from '../../../helpers/utils'
14import { UserNotificationModel } from '../../../models/account/user-notification'
15import { meRouter } from './me'
16import {
17 listUserNotificationsValidator,
18 markAsReadUserNotificationsValidator,
19 updateNotificationSettingsValidator
20} from '../../../middlewares/validators/user-notifications'
21import { UserNotificationSetting } from '../../../../shared/models/users'
22import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
23
24const myNotificationsRouter = express.Router()
25
26meRouter.put('/me/notification-settings',
27 authenticate,
28 updateNotificationSettingsValidator,
29 asyncRetryTransactionMiddleware(updateNotificationSettings)
30)
31
32myNotificationsRouter.get('/me/notifications',
33 authenticate,
34 paginationValidator,
35 userNotificationsSortValidator,
36 setDefaultSort,
37 setDefaultPagination,
38 listUserNotificationsValidator,
39 asyncMiddleware(listUserNotifications)
40)
41
42myNotificationsRouter.post('/me/notifications/read',
43 authenticate,
44 markAsReadUserNotificationsValidator,
45 asyncMiddleware(markAsReadUserNotifications)
46)
47
48myNotificationsRouter.post('/me/notifications/read-all',
49 authenticate,
50 asyncMiddleware(markAsReadAllUserNotifications)
51)
52
53export {
54 myNotificationsRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function updateNotificationSettings (req: express.Request, res: express.Response) {
60 const user: UserModel = res.locals.oauth.token.User
61 const body = req.body
62
63 const query = {
64 where: {
65 userId: user.id
66 }
67 }
68
69 const values: UserNotificationSetting = {
70 newVideoFromSubscription: body.newVideoFromSubscription,
71 newCommentOnMyVideo: body.newCommentOnMyVideo,
72 videoAbuseAsModerator: body.videoAbuseAsModerator,
73 blacklistOnMyVideo: body.blacklistOnMyVideo,
74 myVideoPublished: body.myVideoPublished,
75 myVideoImportFinished: body.myVideoImportFinished,
76 newFollow: body.newFollow,
77 newUserRegistration: body.newUserRegistration,
78 commentMention: body.commentMention
79 }
80
81 await UserNotificationSettingModel.update(values, query)
82
83 return res.status(204).end()
84}
85
86async function listUserNotifications (req: express.Request, res: express.Response) {
87 const user: UserModel = res.locals.oauth.token.User
88
89 const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort, req.query.unread)
90
91 return res.json(getFormattedObjects(resultList.data, resultList.total))
92}
93
94async function markAsReadUserNotifications (req: express.Request, res: express.Response) {
95 const user: UserModel = res.locals.oauth.token.User
96
97 await UserNotificationModel.markAsRead(user.id, req.body.ids)
98
99 return res.status(204).end()
100}
101
102async function markAsReadAllUserNotifications (req: express.Request, res: express.Response) {
103 const user: UserModel = res.locals.oauth.token.User
104
105 await UserNotificationModel.markAllAsRead(user.id)
106
107 return res.status(204).end()
108}
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
new file mode 100644
index 000000000..accca6d52
--- /dev/null
+++ b/server/controllers/api/users/my-subscriptions.ts
@@ -0,0 +1,170 @@
1import * as express from 'express'
2import 'multer'
3import { getFormattedObjects } from '../../../helpers/utils'
4import { CONFIG, sequelizeTypescript } from '../../../initializers'
5import {
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 commonVideosFiltersValidator,
10 paginationValidator,
11 setDefaultPagination,
12 setDefaultSort,
13 userSubscriptionAddValidator,
14 userSubscriptionGetValidator
15} from '../../../middlewares'
16import { areSubscriptionsExistValidator, userSubscriptionsSortValidator, videosSortValidator } from '../../../middlewares/validators'
17import { UserModel } from '../../../models/account/user'
18import { VideoModel } from '../../../models/video/video'
19import { buildNSFWFilter } from '../../../helpers/express-utils'
20import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
21import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
22import { JobQueue } from '../../../lib/job-queue'
23import { logger } from '../../../helpers/logger'
24
25const mySubscriptionsRouter = express.Router()
26
27mySubscriptionsRouter.get('/me/subscriptions/videos',
28 authenticate,
29 paginationValidator,
30 videosSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 commonVideosFiltersValidator,
34 asyncMiddleware(getUserSubscriptionVideos)
35)
36
37mySubscriptionsRouter.get('/me/subscriptions/exist',
38 authenticate,
39 areSubscriptionsExistValidator,
40 asyncMiddleware(areSubscriptionsExist)
41)
42
43mySubscriptionsRouter.get('/me/subscriptions',
44 authenticate,
45 paginationValidator,
46 userSubscriptionsSortValidator,
47 setDefaultSort,
48 setDefaultPagination,
49 asyncMiddleware(getUserSubscriptions)
50)
51
52mySubscriptionsRouter.post('/me/subscriptions',
53 authenticate,
54 userSubscriptionAddValidator,
55 asyncMiddleware(addUserSubscription)
56)
57
58mySubscriptionsRouter.get('/me/subscriptions/:uri',
59 authenticate,
60 userSubscriptionGetValidator,
61 getUserSubscription
62)
63
64mySubscriptionsRouter.delete('/me/subscriptions/:uri',
65 authenticate,
66 userSubscriptionGetValidator,
67 asyncRetryTransactionMiddleware(deleteUserSubscription)
68)
69
70// ---------------------------------------------------------------------------
71
72export {
73 mySubscriptionsRouter
74}
75
76// ---------------------------------------------------------------------------
77
78async function areSubscriptionsExist (req: express.Request, res: express.Response) {
79 const uris = req.query.uris as string[]
80 const user = res.locals.oauth.token.User as UserModel
81
82 const handles = uris.map(u => {
83 let [ name, host ] = u.split('@')
84 if (host === CONFIG.WEBSERVER.HOST) host = null
85
86 return { name, host, uri: u }
87 })
88
89 const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles)
90
91 const existObject: { [id: string ]: boolean } = {}
92 for (const handle of handles) {
93 const obj = results.find(r => {
94 const server = r.ActorFollowing.Server
95
96 return r.ActorFollowing.preferredUsername === handle.name &&
97 (
98 (!server && !handle.host) ||
99 (server.host === handle.host)
100 )
101 })
102
103 existObject[handle.uri] = obj !== undefined
104 }
105
106 return res.json(existObject)
107}
108
109async function addUserSubscription (req: express.Request, res: express.Response) {
110 const user = res.locals.oauth.token.User as UserModel
111 const [ name, host ] = req.body.uri.split('@')
112
113 const payload = {
114 name,
115 host,
116 followerActorId: user.Account.Actor.id
117 }
118
119 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
120 .catch(err => logger.error('Cannot create follow job for subscription %s.', req.body.uri, err))
121
122 return res.status(204).end()
123}
124
125function getUserSubscription (req: express.Request, res: express.Response) {
126 const subscription: ActorFollowModel = res.locals.subscription
127
128 return res.json(subscription.ActorFollowing.VideoChannel.toFormattedJSON())
129}
130
131async function deleteUserSubscription (req: express.Request, res: express.Response) {
132 const subscription: ActorFollowModel = res.locals.subscription
133
134 await sequelizeTypescript.transaction(async t => {
135 return subscription.destroy({ transaction: t })
136 })
137
138 return res.type('json').status(204).end()
139}
140
141async function getUserSubscriptions (req: express.Request, res: express.Response) {
142 const user = res.locals.oauth.token.User as UserModel
143 const actorId = user.Account.Actor.id
144
145 const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort)
146
147 return res.json(getFormattedObjects(resultList.data, resultList.total))
148}
149
150async function getUserSubscriptionVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
151 const user = res.locals.oauth.token.User as UserModel
152 const resultList = await VideoModel.listForApi({
153 start: req.query.start,
154 count: req.query.count,
155 sort: req.query.sort,
156 includeLocalVideos: false,
157 categoryOneOf: req.query.categoryOneOf,
158 licenceOneOf: req.query.licenceOneOf,
159 languageOneOf: req.query.languageOneOf,
160 tagsOneOf: req.query.tagsOneOf,
161 tagsAllOf: req.query.tagsAllOf,
162 nsfw: buildNSFWFilter(res, req.query.nsfw),
163 filter: req.query.filter as VideoFilter,
164 withFiles: false,
165 followerActorId: user.Account.Actor.id,
166 user
167 })
168
169 return res.json(getFormattedObjects(resultList.data, resultList.total))
170}
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index 1fa842d9c..db7602139 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -22,7 +22,7 @@ import { createVideoChannel } from '../../lib/video-channel'
22import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 22import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
23import { setAsyncActorKeys } from '../../lib/activitypub' 23import { setAsyncActorKeys } from '../../lib/activitypub'
24import { AccountModel } from '../../models/account/account' 24import { AccountModel } from '../../models/account/account'
25import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../initializers' 25import { CONFIG, MIMETYPES, sequelizeTypescript } from '../../initializers'
26import { logger } from '../../helpers/logger' 26import { logger } from '../../helpers/logger'
27import { VideoModel } from '../../models/video/video' 27import { VideoModel } from '../../models/video/video'
28import { updateAvatarValidator } from '../../middlewares/validators/avatar' 28import { updateAvatarValidator } from '../../middlewares/validators/avatar'
@@ -30,9 +30,10 @@ import { updateActorAvatarFile } from '../../lib/avatar'
30import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' 30import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
31import { resetSequelizeInstance } from '../../helpers/database-utils' 31import { resetSequelizeInstance } from '../../helpers/database-utils'
32import { UserModel } from '../../models/account/user' 32import { UserModel } from '../../models/account/user'
33import { JobQueue } from '../../lib/job-queue'
33 34
34const auditLogger = auditLoggerFactory('channels') 35const auditLogger = auditLoggerFactory('channels')
35const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.AVATARS_DIR }) 36const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
36 37
37const videoChannelRouter = express.Router() 38const videoChannelRouter = express.Router()
38 39
@@ -197,15 +198,20 @@ async function removeVideoChannel (req: express.Request, res: express.Response)
197async function getVideoChannel (req: express.Request, res: express.Response, next: express.NextFunction) { 198async function getVideoChannel (req: express.Request, res: express.Response, next: express.NextFunction) {
198 const videoChannelWithVideos = await VideoChannelModel.loadAndPopulateAccountAndVideos(res.locals.videoChannel.id) 199 const videoChannelWithVideos = await VideoChannelModel.loadAndPopulateAccountAndVideos(res.locals.videoChannel.id)
199 200
201 if (videoChannelWithVideos.isOutdated()) {
202 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannelWithVideos.Actor.url } })
203 .catch(err => logger.error('Cannot create AP refresher job for actor %s.', videoChannelWithVideos.Actor.url, { err }))
204 }
205
200 return res.json(videoChannelWithVideos.toFormattedJSON()) 206 return res.json(videoChannelWithVideos.toFormattedJSON())
201} 207}
202 208
203async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 209async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
204 const videoChannelInstance: VideoChannelModel = res.locals.videoChannel 210 const videoChannelInstance: VideoChannelModel = res.locals.videoChannel
205 const actorId = isUserAbleToSearchRemoteURI(res) ? null : undefined 211 const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
206 212
207 const resultList = await VideoModel.listForApi({ 213 const resultList = await VideoModel.listForApi({
208 actorId, 214 followerActorId,
209 start: req.query.start, 215 start: req.query.start,
210 count: req.query.count, 216 count: req.query.count,
211 sort: req.query.sort, 217 sort: req.query.sort,
@@ -215,9 +221,11 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
215 languageOneOf: req.query.languageOneOf, 221 languageOneOf: req.query.languageOneOf,
216 tagsOneOf: req.query.tagsOneOf, 222 tagsOneOf: req.query.tagsOneOf,
217 tagsAllOf: req.query.tagsAllOf, 223 tagsAllOf: req.query.tagsAllOf,
224 filter: req.query.filter,
218 nsfw: buildNSFWFilter(res, req.query.nsfw), 225 nsfw: buildNSFWFilter(res, req.query.nsfw),
219 withFiles: false, 226 withFiles: false,
220 videoChannelId: videoChannelInstance.id 227 videoChannelId: videoChannelInstance.id,
228 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
221 }) 229 })
222 230
223 return res.json(getFormattedObjects(resultList.data, resultList.total)) 231 return res.json(getFormattedObjects(resultList.data, resultList.total))
diff --git a/server/controllers/api/videos/abuse.ts b/server/controllers/api/videos/abuse.ts
index d0c81804b..32f9c4793 100644
--- a/server/controllers/api/videos/abuse.ts
+++ b/server/controllers/api/videos/abuse.ts
@@ -3,7 +3,6 @@ import { UserRight, VideoAbuseCreate, VideoAbuseState } from '../../../../shared
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { getFormattedObjects } from '../../../helpers/utils' 4import { getFormattedObjects } from '../../../helpers/utils'
5import { sequelizeTypescript } from '../../../initializers' 5import { sequelizeTypescript } from '../../../initializers'
6import { sendVideoAbuse } from '../../../lib/activitypub/send'
7import { 6import {
8 asyncMiddleware, 7 asyncMiddleware,
9 asyncRetryTransactionMiddleware, 8 asyncRetryTransactionMiddleware,
@@ -22,6 +21,8 @@ import { VideoModel } from '../../../models/video/video'
22import { VideoAbuseModel } from '../../../models/video/video-abuse' 21import { VideoAbuseModel } from '../../../models/video/video-abuse'
23import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger' 22import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
24import { UserModel } from '../../../models/account/user' 23import { UserModel } from '../../../models/account/user'
24import { Notifier } from '../../../lib/notifier'
25import { sendVideoAbuse } from '../../../lib/activitypub/send/send-flag'
25 26
26const auditLogger = auditLoggerFactory('abuse') 27const auditLogger = auditLoggerFactory('abuse')
27const abuseVideoRouter = express.Router() 28const abuseVideoRouter = express.Router()
@@ -117,6 +118,8 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
117 await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance) 118 await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance)
118 } 119 }
119 120
121 Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
122
120 auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON())) 123 auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON()))
121 124
122 return videoAbuseInstance 125 return videoAbuseInstance
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts
index 7f803c8e9..43b0516e7 100644
--- a/server/controllers/api/videos/blacklist.ts
+++ b/server/controllers/api/videos/blacklist.ts
@@ -16,6 +16,10 @@ import {
16} from '../../../middlewares' 16} from '../../../middlewares'
17import { VideoBlacklistModel } from '../../../models/video/video-blacklist' 17import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
18import { sequelizeTypescript } from '../../../initializers' 18import { sequelizeTypescript } from '../../../initializers'
19import { Notifier } from '../../../lib/notifier'
20import { VideoModel } from '../../../models/video/video'
21import { sendCreateVideo, sendDeleteVideo, sendUpdateVideo } from '../../../lib/activitypub/send'
22import { federateVideoIfNeeded } from '../../../lib/activitypub'
19 23
20const blacklistRouter = express.Router() 24const blacklistRouter = express.Router()
21 25
@@ -64,16 +68,26 @@ async function addVideoToBlacklist (req: express.Request, res: express.Response)
64 68
65 const toCreate = { 69 const toCreate = {
66 videoId: videoInstance.id, 70 videoId: videoInstance.id,
71 unfederated: body.unfederate === true,
67 reason: body.reason 72 reason: body.reason
68 } 73 }
69 74
70 await VideoBlacklistModel.create(toCreate) 75 const blacklist = await VideoBlacklistModel.create(toCreate)
76 blacklist.Video = videoInstance
77
78 if (body.unfederate === true) {
79 await sendDeleteVideo(videoInstance, undefined)
80 }
81
82 Notifier.Instance.notifyOnVideoBlacklist(blacklist)
83
84 logger.info('Video %s blacklisted.', res.locals.video.uuid)
85
71 return res.type('json').status(204).end() 86 return res.type('json').status(204).end()
72} 87}
73 88
74async function updateVideoBlacklistController (req: express.Request, res: express.Response) { 89async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
75 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel 90 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
76 logger.info(videoBlacklist)
77 91
78 if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason 92 if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason
79 93
@@ -92,11 +106,20 @@ async function listBlacklist (req: express.Request, res: express.Response, next:
92 106
93async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) { 107async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) {
94 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel 108 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
109 const video: VideoModel = res.locals.video
95 110
96 await sequelizeTypescript.transaction(t => { 111 await sequelizeTypescript.transaction(async t => {
97 return videoBlacklist.destroy({ transaction: t }) 112 const unfederated = videoBlacklist.unfederated
113 await videoBlacklist.destroy({ transaction: t })
114
115 // Re federate the video
116 if (unfederated === true) {
117 await federateVideoIfNeeded(video, true, t)
118 }
98 }) 119 })
99 120
121 Notifier.Instance.notifyOnVideoUnblacklist(video)
122
100 logger.info('Video %s removed from blacklist.', res.locals.video.uuid) 123 logger.info('Video %s removed from blacklist.', res.locals.video.uuid)
101 124
102 return res.type('json').status(204).end() 125 return res.type('json').status(204).end()
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
index 3ba918189..9b3661368 100644
--- a/server/controllers/api/videos/captions.ts
+++ b/server/controllers/api/videos/captions.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' 2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
3import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' 3import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
4import { createReqFiles } from '../../../helpers/express-utils' 4import { createReqFiles } from '../../../helpers/express-utils'
5import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' 5import { CONFIG, MIMETYPES, sequelizeTypescript } from '../../../initializers'
6import { getFormattedObjects } from '../../../helpers/utils' 6import { getFormattedObjects } from '../../../helpers/utils'
7import { VideoCaptionModel } from '../../../models/video/video-caption' 7import { VideoCaptionModel } from '../../../models/video/video-caption'
8import { VideoModel } from '../../../models/video/video' 8import { VideoModel } from '../../../models/video/video'
@@ -12,7 +12,7 @@ import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
12 12
13const reqVideoCaptionAdd = createReqFiles( 13const reqVideoCaptionAdd = createReqFiles(
14 [ 'captionfile' ], 14 [ 'captionfile' ],
15 VIDEO_CAPTIONS_MIMETYPE_EXT, 15 MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT,
16 { 16 {
17 captionfile: CONFIG.STORAGE.CAPTIONS_DIR 17 captionfile: CONFIG.STORAGE.CAPTIONS_DIR
18 } 18 }
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 4f2b4faee..70c1148ba 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -8,7 +8,7 @@ import { buildFormattedCommentTree, createVideoComment } from '../../../lib/vide
8import { 8import {
9 asyncMiddleware, 9 asyncMiddleware,
10 asyncRetryTransactionMiddleware, 10 asyncRetryTransactionMiddleware,
11 authenticate, 11 authenticate, optionalAuthenticate,
12 paginationValidator, 12 paginationValidator,
13 setDefaultPagination, 13 setDefaultPagination,
14 setDefaultSort 14 setDefaultSort
@@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
26import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' 26import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
27import { AccountModel } from '../../../models/account/account' 27import { AccountModel } from '../../../models/account/account'
28import { UserModel } from '../../../models/account/user' 28import { UserModel } from '../../../models/account/user'
29import { Notifier } from '../../../lib/notifier'
29 30
30const auditLogger = auditLoggerFactory('comments') 31const auditLogger = auditLoggerFactory('comments')
31const videoCommentRouter = express.Router() 32const videoCommentRouter = express.Router()
@@ -36,10 +37,12 @@ videoCommentRouter.get('/:videoId/comment-threads',
36 setDefaultSort, 37 setDefaultSort,
37 setDefaultPagination, 38 setDefaultPagination,
38 asyncMiddleware(listVideoCommentThreadsValidator), 39 asyncMiddleware(listVideoCommentThreadsValidator),
40 optionalAuthenticate,
39 asyncMiddleware(listVideoThreads) 41 asyncMiddleware(listVideoThreads)
40) 42)
41videoCommentRouter.get('/:videoId/comment-threads/:threadId', 43videoCommentRouter.get('/:videoId/comment-threads/:threadId',
42 asyncMiddleware(listVideoThreadCommentsValidator), 44 asyncMiddleware(listVideoThreadCommentsValidator),
45 optionalAuthenticate,
43 asyncMiddleware(listVideoThreadComments) 46 asyncMiddleware(listVideoThreadComments)
44) 47)
45 48
@@ -69,10 +72,12 @@ export {
69 72
70async function listVideoThreads (req: express.Request, res: express.Response, next: express.NextFunction) { 73async function listVideoThreads (req: express.Request, res: express.Response, next: express.NextFunction) {
71 const video = res.locals.video as VideoModel 74 const video = res.locals.video as VideoModel
75 const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
76
72 let resultList: ResultList<VideoCommentModel> 77 let resultList: ResultList<VideoCommentModel>
73 78
74 if (video.commentsEnabled === true) { 79 if (video.commentsEnabled === true) {
75 resultList = await VideoCommentModel.listThreadsForApi(video.id, req.query.start, req.query.count, req.query.sort) 80 resultList = await VideoCommentModel.listThreadsForApi(video.id, req.query.start, req.query.count, req.query.sort, user)
76 } else { 81 } else {
77 resultList = { 82 resultList = {
78 total: 0, 83 total: 0,
@@ -85,10 +90,12 @@ async function listVideoThreads (req: express.Request, res: express.Response, ne
85 90
86async function listVideoThreadComments (req: express.Request, res: express.Response, next: express.NextFunction) { 91async function listVideoThreadComments (req: express.Request, res: express.Response, next: express.NextFunction) {
87 const video = res.locals.video as VideoModel 92 const video = res.locals.video as VideoModel
93 const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
94
88 let resultList: ResultList<VideoCommentModel> 95 let resultList: ResultList<VideoCommentModel>
89 96
90 if (video.commentsEnabled === true) { 97 if (video.commentsEnabled === true) {
91 resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id) 98 resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id, user)
92 } else { 99 } else {
93 resultList = { 100 resultList = {
94 total: 0, 101 total: 0,
@@ -113,6 +120,7 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
113 }, t) 120 }, t)
114 }) 121 })
115 122
123 Notifier.Instance.notifyOnNewComment(comment)
116 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) 124 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
117 125
118 return res.json({ 126 return res.json({
@@ -134,6 +142,7 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
134 }, t) 142 }, t)
135 }) 143 })
136 144
145 Notifier.Instance.notifyOnNewComment(comment)
137 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) 146 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
138 147
139 return res.json({ comment: comment.toFormattedJSON() }).end() 148 return res.json({ comment: comment.toFormattedJSON() }).end()
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 9e51e2000..7053d5253 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -3,14 +3,7 @@ import * as magnetUtil from 'magnet-uri'
3import 'multer' 3import 'multer'
4import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' 4import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
5import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' 5import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
6import { 6import { CONFIG, MIMETYPES, PREVIEWS_SIZE, sequelizeTypescript, THUMBNAILS_SIZE } from '../../../initializers'
7 CONFIG,
8 IMAGE_MIMETYPE_EXT,
9 PREVIEWS_SIZE,
10 sequelizeTypescript,
11 THUMBNAILS_SIZE,
12 TORRENT_MIMETYPE_EXT
13} from '../../../initializers'
14import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl' 7import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
15import { createReqFiles } from '../../../helpers/express-utils' 8import { createReqFiles } from '../../../helpers/express-utils'
16import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
@@ -28,18 +21,18 @@ import { VideoChannelModel } from '../../../models/video/video-channel'
28import * as Bluebird from 'bluebird' 21import * as Bluebird from 'bluebird'
29import * as parseTorrent from 'parse-torrent' 22import * as parseTorrent from 'parse-torrent'
30import { getSecureTorrentName } from '../../../helpers/utils' 23import { getSecureTorrentName } from '../../../helpers/utils'
31import { readFile, rename } from 'fs-extra' 24import { readFile, move } from 'fs-extra'
32 25
33const auditLogger = auditLoggerFactory('video-imports') 26const auditLogger = auditLoggerFactory('video-imports')
34const videoImportsRouter = express.Router() 27const videoImportsRouter = express.Router()
35 28
36const reqVideoFileImport = createReqFiles( 29const reqVideoFileImport = createReqFiles(
37 [ 'thumbnailfile', 'previewfile', 'torrentfile' ], 30 [ 'thumbnailfile', 'previewfile', 'torrentfile' ],
38 Object.assign({}, TORRENT_MIMETYPE_EXT, IMAGE_MIMETYPE_EXT), 31 Object.assign({}, MIMETYPES.TORRENT.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
39 { 32 {
40 thumbnailfile: CONFIG.STORAGE.THUMBNAILS_DIR, 33 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
41 previewfile: CONFIG.STORAGE.PREVIEWS_DIR, 34 previewfile: CONFIG.STORAGE.TMP_DIR,
42 torrentfile: CONFIG.STORAGE.TORRENTS_DIR 35 torrentfile: CONFIG.STORAGE.TMP_DIR
43 } 36 }
44) 37)
45 38
@@ -78,7 +71,7 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
78 71
79 // Rename the torrent to a secured name 72 // Rename the torrent to a secured name
80 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName)) 73 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
81 await rename(torrentfile.path, newTorrentPath) 74 await move(torrentfile.path, newTorrentPath)
82 torrentfile.path = newTorrentPath 75 torrentfile.path = newTorrentPath
83 76
84 const buf = await readFile(torrentfile.path) 77 const buf = await readFile(torrentfile.path)
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 7d55f06b6..76a318d13 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -8,14 +8,13 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../
8import { getFormattedObjects, getServerActor } from '../../../helpers/utils' 8import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
9import { 9import {
10 CONFIG, 10 CONFIG,
11 IMAGE_MIMETYPE_EXT, 11 MIMETYPES,
12 PREVIEWS_SIZE, 12 PREVIEWS_SIZE,
13 sequelizeTypescript, 13 sequelizeTypescript,
14 THUMBNAILS_SIZE, 14 THUMBNAILS_SIZE,
15 VIDEO_CATEGORIES, 15 VIDEO_CATEGORIES,
16 VIDEO_LANGUAGES, 16 VIDEO_LANGUAGES,
17 VIDEO_LICENCES, 17 VIDEO_LICENCES,
18 VIDEO_MIMETYPE_EXT,
19 VIDEO_PRIVACIES 18 VIDEO_PRIVACIES
20} from '../../../initializers' 19} from '../../../initializers'
21import { 20import {
@@ -24,19 +23,20 @@ import {
24 fetchRemoteVideoDescription, 23 fetchRemoteVideoDescription,
25 getVideoActivityPubUrl 24 getVideoActivityPubUrl
26} from '../../../lib/activitypub' 25} from '../../../lib/activitypub'
27import { sendCreateView } from '../../../lib/activitypub/send'
28import { JobQueue } from '../../../lib/job-queue' 26import { JobQueue } from '../../../lib/job-queue'
29import { Redis } from '../../../lib/redis' 27import { Redis } from '../../../lib/redis'
30import { 28import {
31 asyncMiddleware, 29 asyncMiddleware,
32 asyncRetryTransactionMiddleware, 30 asyncRetryTransactionMiddleware,
33 authenticate, 31 authenticate,
32 checkVideoFollowConstraints,
34 commonVideosFiltersValidator, 33 commonVideosFiltersValidator,
35 optionalAuthenticate, 34 optionalAuthenticate,
36 paginationValidator, 35 paginationValidator,
37 setDefaultPagination, 36 setDefaultPagination,
38 setDefaultSort, 37 setDefaultSort,
39 videosAddValidator, 38 videosAddValidator,
39 videosCustomGetValidator,
40 videosGetValidator, 40 videosGetValidator,
41 videosRemoveValidator, 41 videosRemoveValidator,
42 videosSortValidator, 42 videosSortValidator,
@@ -56,27 +56,29 @@ import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-u
56import { videoCaptionsRouter } from './captions' 56import { videoCaptionsRouter } from './captions'
57import { videoImportsRouter } from './import' 57import { videoImportsRouter } from './import'
58import { resetSequelizeInstance } from '../../../helpers/database-utils' 58import { resetSequelizeInstance } from '../../../helpers/database-utils'
59import { rename } from 'fs-extra' 59import { move } from 'fs-extra'
60import { watchingRouter } from './watching' 60import { watchingRouter } from './watching'
61import { Notifier } from '../../../lib/notifier'
62import { sendView } from '../../../lib/activitypub/send/send-view'
61 63
62const auditLogger = auditLoggerFactory('videos') 64const auditLogger = auditLoggerFactory('videos')
63const videosRouter = express.Router() 65const videosRouter = express.Router()
64 66
65const reqVideoFileAdd = createReqFiles( 67const reqVideoFileAdd = createReqFiles(
66 [ 'videofile', 'thumbnailfile', 'previewfile' ], 68 [ 'videofile', 'thumbnailfile', 'previewfile' ],
67 Object.assign({}, VIDEO_MIMETYPE_EXT, IMAGE_MIMETYPE_EXT), 69 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
68 { 70 {
69 videofile: CONFIG.STORAGE.VIDEOS_DIR, 71 videofile: CONFIG.STORAGE.TMP_DIR,
70 thumbnailfile: CONFIG.STORAGE.THUMBNAILS_DIR, 72 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
71 previewfile: CONFIG.STORAGE.PREVIEWS_DIR 73 previewfile: CONFIG.STORAGE.TMP_DIR
72 } 74 }
73) 75)
74const reqVideoFileUpdate = createReqFiles( 76const reqVideoFileUpdate = createReqFiles(
75 [ 'thumbnailfile', 'previewfile' ], 77 [ 'thumbnailfile', 'previewfile' ],
76 IMAGE_MIMETYPE_EXT, 78 MIMETYPES.IMAGE.MIMETYPE_EXT,
77 { 79 {
78 thumbnailfile: CONFIG.STORAGE.THUMBNAILS_DIR, 80 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
79 previewfile: CONFIG.STORAGE.PREVIEWS_DIR 81 previewfile: CONFIG.STORAGE.TMP_DIR
80 } 82 }
81) 83)
82 84
@@ -122,8 +124,9 @@ videosRouter.get('/:id/description',
122) 124)
123videosRouter.get('/:id', 125videosRouter.get('/:id',
124 optionalAuthenticate, 126 optionalAuthenticate,
125 asyncMiddleware(videosGetValidator), 127 asyncMiddleware(videosCustomGetValidator('only-video-with-rights')),
126 getVideo 128 asyncMiddleware(checkVideoFollowConstraints),
129 asyncMiddleware(getVideo)
127) 130)
128videosRouter.post('/:id/views', 131videosRouter.post('/:id/views',
129 asyncMiddleware(videosGetValidator), 132 asyncMiddleware(videosGetValidator),
@@ -207,7 +210,7 @@ async function addVideo (req: express.Request, res: express.Response) {
207 // Move physical file 210 // Move physical file
208 const videoDir = CONFIG.STORAGE.VIDEOS_DIR 211 const videoDir = CONFIG.STORAGE.VIDEOS_DIR
209 const destination = join(videoDir, video.getVideoFilename(videoFile)) 212 const destination = join(videoDir, video.getVideoFilename(videoFile))
210 await rename(videoPhysicalFile.path, destination) 213 await move(videoPhysicalFile.path, destination)
211 // This is important in case if there is another attempt in the retry process 214 // This is important in case if there is another attempt in the retry process
212 videoPhysicalFile.filename = video.getVideoFilename(videoFile) 215 videoPhysicalFile.filename = video.getVideoFilename(videoFile)
213 videoPhysicalFile.path = destination 216 videoPhysicalFile.path = destination
@@ -270,6 +273,8 @@ async function addVideo (req: express.Request, res: express.Response) {
270 return videoCreated 273 return videoCreated
271 }) 274 })
272 275
276 Notifier.Instance.notifyOnNewVideo(videoCreated)
277
273 if (video.state === VideoState.TO_TRANSCODE) { 278 if (video.state === VideoState.TO_TRANSCODE) {
274 // Put uuid because we don't have id auto incremented for now 279 // Put uuid because we don't have id auto incremented for now
275 const dataInput = { 280 const dataInput = {
@@ -294,6 +299,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
294 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON()) 299 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
295 const videoInfoToUpdate: VideoUpdate = req.body 300 const videoInfoToUpdate: VideoUpdate = req.body
296 const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE 301 const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE
302 const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
297 303
298 // Process thumbnail or create it from the video 304 // Process thumbnail or create it from the video
299 if (req.files && req.files['thumbnailfile']) { 305 if (req.files && req.files['thumbnailfile']) {
@@ -308,10 +314,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
308 } 314 }
309 315
310 try { 316 try {
311 await sequelizeTypescript.transaction(async t => { 317 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
312 const sequelizeOptions = { 318 const sequelizeOptions = { transaction: t }
313 transaction: t
314 }
315 const oldVideoChannel = videoInstance.VideoChannel 319 const oldVideoChannel = videoInstance.VideoChannel
316 320
317 if (videoInfoToUpdate.name !== undefined) videoInstance.set('name', videoInfoToUpdate.name) 321 if (videoInfoToUpdate.name !== undefined) videoInstance.set('name', videoInfoToUpdate.name)
@@ -363,7 +367,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
363 } 367 }
364 368
365 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE 369 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
366 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t) 370
371 // Don't send update if the video was unfederated
372 if (!videoInstanceUpdated.VideoBlacklist || videoInstanceUpdated.VideoBlacklist.unfederated === false) {
373 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
374 }
367 375
368 auditLogger.update( 376 auditLogger.update(
369 getAuditIdFromRes(res), 377 getAuditIdFromRes(res),
@@ -371,7 +379,13 @@ async function updateVideo (req: express.Request, res: express.Response) {
371 oldVideoAuditView 379 oldVideoAuditView
372 ) 380 )
373 logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid) 381 logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid)
382
383 return videoInstanceUpdated
374 }) 384 })
385
386 if (wasUnlistedVideo || wasPrivateVideo) {
387 Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
388 }
375 } catch (err) { 389 } catch (err) {
376 // Force fields we want to update 390 // Force fields we want to update
377 // If the transaction is retried, sequelize will think the object has not changed 391 // If the transaction is retried, sequelize will think the object has not changed
@@ -384,10 +398,17 @@ async function updateVideo (req: express.Request, res: express.Response) {
384 return res.type('json').status(204).end() 398 return res.type('json').status(204).end()
385} 399}
386 400
387function getVideo (req: express.Request, res: express.Response) { 401async function getVideo (req: express.Request, res: express.Response) {
388 const videoInstance = res.locals.video 402 // We need more attributes
403 const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
404 const video: VideoModel = await VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId)
389 405
390 return res.json(videoInstance.toFormattedDetailsJSON()) 406 if (video.isOutdated()) {
407 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
408 .catch(err => logger.error('Cannot create AP refresher job for video %s.', video.url, { err }))
409 }
410
411 return res.json(video.toFormattedDetailsJSON())
391} 412}
392 413
393async function viewVideo (req: express.Request, res: express.Response) { 414async function viewVideo (req: express.Request, res: express.Response) {
@@ -406,8 +427,7 @@ async function viewVideo (req: express.Request, res: express.Response) {
406 ]) 427 ])
407 428
408 const serverActor = await getServerActor() 429 const serverActor = await getServerActor()
409 430 await sendView(serverActor, videoInstance, undefined)
410 await sendCreateView(serverActor, videoInstance, undefined)
411 431
412 return res.status(204).end() 432 return res.status(204).end()
413} 433}
@@ -425,7 +445,7 @@ async function getVideoDescription (req: express.Request, res: express.Response)
425 return res.json({ description }) 445 return res.json({ description })
426} 446}
427 447
428async function listVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 448async function listVideos (req: express.Request, res: express.Response) {
429 const resultList = await VideoModel.listForApi({ 449 const resultList = await VideoModel.listForApi({
430 start: req.query.start, 450 start: req.query.start,
431 count: req.query.count, 451 count: req.query.count,
@@ -439,7 +459,7 @@ async function listVideos (req: express.Request, res: express.Response, next: ex
439 nsfw: buildNSFWFilter(res, req.query.nsfw), 459 nsfw: buildNSFWFilter(res, req.query.nsfw),
440 filter: req.query.filter as VideoFilter, 460 filter: req.query.filter as VideoFilter,
441 withFiles: false, 461 withFiles: false,
442 userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined 462 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
443 }) 463 })
444 464
445 return res.json(getFormattedObjects(resultList.data, resultList.total)) 465 return res.json(getFormattedObjects(resultList.data, resultList.total))
diff --git a/server/controllers/api/videos/rate.ts b/server/controllers/api/videos/rate.ts
index dc322bb0c..53952a0a2 100644
--- a/server/controllers/api/videos/rate.ts
+++ b/server/controllers/api/videos/rate.ts
@@ -2,8 +2,8 @@ import * as express from 'express'
2import { UserVideoRateUpdate } from '../../../../shared' 2import { UserVideoRateUpdate } from '../../../../shared'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { sequelizeTypescript, VIDEO_RATE_TYPES } from '../../../initializers' 4import { sequelizeTypescript, VIDEO_RATE_TYPES } from '../../../initializers'
5import { sendVideoRateChange } from '../../../lib/activitypub' 5import { getRateUrl, sendVideoRateChange } from '../../../lib/activitypub'
6import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoRateValidator } from '../../../middlewares' 6import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoUpdateRateValidator } from '../../../middlewares'
7import { AccountModel } from '../../../models/account/account' 7import { AccountModel } from '../../../models/account/account'
8import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 8import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
9import { VideoModel } from '../../../models/video/video' 9import { VideoModel } from '../../../models/video/video'
@@ -12,7 +12,7 @@ const rateVideoRouter = express.Router()
12 12
13rateVideoRouter.put('/:id/rate', 13rateVideoRouter.put('/:id/rate',
14 authenticate, 14 authenticate,
15 asyncMiddleware(videoRateValidator), 15 asyncMiddleware(videoUpdateRateValidator),
16 asyncRetryTransactionMiddleware(rateVideo) 16 asyncRetryTransactionMiddleware(rateVideo)
17) 17)
18 18
@@ -28,11 +28,12 @@ async function rateVideo (req: express.Request, res: express.Response) {
28 const body: UserVideoRateUpdate = req.body 28 const body: UserVideoRateUpdate = req.body
29 const rateType = body.rating 29 const rateType = body.rating
30 const videoInstance: VideoModel = res.locals.video 30 const videoInstance: VideoModel = res.locals.video
31 const userAccount: AccountModel = res.locals.oauth.token.User.Account
31 32
32 await sequelizeTypescript.transaction(async t => { 33 await sequelizeTypescript.transaction(async t => {
33 const sequelizeOptions = { transaction: t } 34 const sequelizeOptions = { transaction: t }
34 35
35 const accountInstance = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) 36 const accountInstance = await AccountModel.load(userAccount.id, t)
36 const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t) 37 const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t)
37 38
38 let likesToIncrement = 0 39 let likesToIncrement = 0
@@ -44,20 +45,22 @@ async function rateVideo (req: express.Request, res: express.Response) {
44 // There was a previous rate, update it 45 // There was a previous rate, update it
45 if (previousRate) { 46 if (previousRate) {
46 // We will remove the previous rate, so we will need to update the video count attribute 47 // We will remove the previous rate, so we will need to update the video count attribute
47 if (previousRate.type === VIDEO_RATE_TYPES.LIKE) likesToIncrement-- 48 if (previousRate.type === 'like') likesToIncrement--
48 else if (previousRate.type === VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement-- 49 else if (previousRate.type === 'dislike') dislikesToIncrement--
49 50
50 if (rateType === 'none') { // Destroy previous rate 51 if (rateType === 'none') { // Destroy previous rate
51 await previousRate.destroy(sequelizeOptions) 52 await previousRate.destroy(sequelizeOptions)
52 } else { // Update previous rate 53 } else { // Update previous rate
53 previousRate.type = rateType 54 previousRate.type = rateType
55 previousRate.url = getRateUrl(rateType, userAccount.Actor, videoInstance)
54 await previousRate.save(sequelizeOptions) 56 await previousRate.save(sequelizeOptions)
55 } 57 }
56 } else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate 58 } else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate
57 const query = { 59 const query = {
58 accountId: accountInstance.id, 60 accountId: accountInstance.id,
59 videoId: videoInstance.id, 61 videoId: videoInstance.id,
60 type: rateType 62 type: rateType,
63 url: getRateUrl(rateType, userAccount.Actor, videoInstance)
61 } 64 }
62 65
63 await AccountVideoRateModel.create(query, sequelizeOptions) 66 await AccountVideoRateModel.create(query, sequelizeOptions)
diff --git a/server/controllers/bots.ts b/server/controllers/bots.ts
new file mode 100644
index 000000000..2db86a2d8
--- /dev/null
+++ b/server/controllers/bots.ts
@@ -0,0 +1,101 @@
1import * as express from 'express'
2import { asyncMiddleware } from '../middlewares'
3import { CONFIG, ROUTE_CACHE_LIFETIME } from '../initializers'
4import * as sitemapModule from 'sitemap'
5import { logger } from '../helpers/logger'
6import { VideoModel } from '../models/video/video'
7import { VideoChannelModel } from '../models/video/video-channel'
8import { AccountModel } from '../models/account/account'
9import { cacheRoute } from '../middlewares/cache'
10import { buildNSFWFilter } from '../helpers/express-utils'
11import { truncate } from 'lodash'
12
13const botsRouter = express.Router()
14
15// Special route that add OpenGraph and oEmbed tags
16// Do not use a template engine for a so little thing
17botsRouter.use('/sitemap.xml',
18 asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.SITEMAP)),
19 asyncMiddleware(getSitemap)
20)
21
22// ---------------------------------------------------------------------------
23
24export {
25 botsRouter
26}
27
28// ---------------------------------------------------------------------------
29
30async function getSitemap (req: express.Request, res: express.Response) {
31 let urls = getSitemapBasicUrls()
32
33 urls = urls.concat(await getSitemapLocalVideoUrls())
34 urls = urls.concat(await getSitemapVideoChannelUrls())
35 urls = urls.concat(await getSitemapAccountUrls())
36
37 const sitemap = sitemapModule.createSitemap({
38 hostname: CONFIG.WEBSERVER.URL,
39 urls: urls
40 })
41
42 sitemap.toXML((err, xml) => {
43 if (err) {
44 logger.error('Cannot generate sitemap.', { err })
45 return res.sendStatus(500)
46 }
47
48 res.header('Content-Type', 'application/xml')
49 res.send(xml)
50 })
51}
52
53async function getSitemapVideoChannelUrls () {
54 const rows = await VideoChannelModel.listLocalsForSitemap('createdAt')
55
56 return rows.map(channel => ({
57 url: CONFIG.WEBSERVER.URL + '/video-channels/' + channel.Actor.preferredUsername
58 }))
59}
60
61async function getSitemapAccountUrls () {
62 const rows = await AccountModel.listLocalsForSitemap('createdAt')
63
64 return rows.map(channel => ({
65 url: CONFIG.WEBSERVER.URL + '/accounts/' + channel.Actor.preferredUsername
66 }))
67}
68
69async function getSitemapLocalVideoUrls () {
70 const resultList = await VideoModel.listForApi({
71 start: 0,
72 count: undefined,
73 sort: 'createdAt',
74 includeLocalVideos: true,
75 nsfw: buildNSFWFilter(),
76 filter: 'local',
77 withFiles: false
78 })
79
80 return resultList.data.map(v => ({
81 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + v.uuid,
82 video: [
83 {
84 title: v.name,
85 // Sitemap description should be < 2000 characters
86 description: truncate(v.description || v.name, { length: 2000, omission: '...' }),
87 player_loc: CONFIG.WEBSERVER.URL + '/videos/embed/' + v.uuid,
88 thumbnail_loc: CONFIG.WEBSERVER.URL + v.getThumbnailStaticPath()
89 }
90 ]
91 }))
92}
93
94function getSitemapBasicUrls () {
95 const paths = [
96 '/about/instance',
97 '/videos/local'
98 ]
99
100 return paths.map(p => ({ url: CONFIG.WEBSERVER.URL + p }))
101}
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index 73b40cf65..f17f2a5d2 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import { join } from 'path' 2import { join } from 'path'
3import { root } from '../helpers/core-utils' 3import { root } from '../helpers/core-utils'
4import { ACCEPT_HEADERS, STATIC_MAX_AGE } from '../initializers' 4import { ACCEPT_HEADERS, STATIC_MAX_AGE } from '../initializers'
5import { asyncMiddleware } from '../middlewares' 5import { asyncMiddleware, embedCSP } from '../middlewares'
6import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n' 6import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n'
7import { ClientHtml } from '../lib/client-html' 7import { ClientHtml } from '../lib/client-html'
8import { logger } from '../helpers/logger' 8import { logger } from '../helpers/logger'
@@ -16,21 +16,20 @@ const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
16 16
17// Special route that add OpenGraph and oEmbed tags 17// Special route that add OpenGraph and oEmbed tags
18// Do not use a template engine for a so little thing 18// Do not use a template engine for a so little thing
19clientsRouter.use('/videos/watch/:id', 19clientsRouter.use('/videos/watch/:id', asyncMiddleware(generateWatchHtmlPage))
20 asyncMiddleware(generateWatchHtmlPage)
21)
22 20
23clientsRouter.use('' + 21clientsRouter.use(
24 '/videos/embed', 22 '/videos/embed',
25 (req: express.Request, res: express.Response, next: express.NextFunction) => { 23 embedCSP,
24 (req: express.Request, res: express.Response) => {
26 res.removeHeader('X-Frame-Options') 25 res.removeHeader('X-Frame-Options')
27 res.sendFile(embedPath) 26 res.sendFile(embedPath)
28 } 27 }
29) 28)
30clientsRouter.use('' + 29clientsRouter.use(
31 '/videos/test-embed', (req: express.Request, res: express.Response, next: express.NextFunction) => { 30 '/videos/test-embed',
32 res.sendFile(testEmbedPath) 31 (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
33}) 32)
34 33
35// Static HTML/CSS/JS client files 34// Static HTML/CSS/JS client files
36 35
@@ -89,7 +88,7 @@ export {
89// --------------------------------------------------------------------------- 88// ---------------------------------------------------------------------------
90 89
91async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) { 90async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
92 const html = await ClientHtml.getIndexHTML(req, res, paramLang) 91 const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
93 92
94 return sendHTML(html, res) 93 return sendHTML(html, res)
95} 94}
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index b30ad8e8d..960085af1 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -1,7 +1,14 @@
1import * as express from 'express' 1import * as express from 'express'
2import { CONFIG, FEEDS, ROUTE_CACHE_LIFETIME } from '../initializers/constants' 2import { CONFIG, FEEDS, ROUTE_CACHE_LIFETIME } from '../initializers/constants'
3import { THUMBNAILS_SIZE } from '../initializers' 3import { THUMBNAILS_SIZE } from '../initializers'
4import { asyncMiddleware, setDefaultSort, videoCommentsFeedsValidator, videoFeedsValidator, videosSortValidator } from '../middlewares' 4import {
5 asyncMiddleware,
6 commonVideosFiltersValidator,
7 setDefaultSort,
8 videoCommentsFeedsValidator,
9 videoFeedsValidator,
10 videosSortValidator
11} from '../middlewares'
5import { VideoModel } from '../models/video/video' 12import { VideoModel } from '../models/video/video'
6import * as Feed from 'pfeed' 13import * as Feed from 'pfeed'
7import { AccountModel } from '../models/account/account' 14import { AccountModel } from '../models/account/account'
@@ -22,6 +29,7 @@ feedsRouter.get('/feeds/videos.:format',
22 videosSortValidator, 29 videosSortValidator,
23 setDefaultSort, 30 setDefaultSort,
24 asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)), 31 asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)),
32 commonVideosFiltersValidator,
25 asyncMiddleware(videoFeedsValidator), 33 asyncMiddleware(videoFeedsValidator),
26 asyncMiddleware(generateVideoFeed) 34 asyncMiddleware(generateVideoFeed)
27) 35)
@@ -48,7 +56,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res
48 56
49 // Adding video items to the feed, one at a time 57 // Adding video items to the feed, one at a time
50 comments.forEach(comment => { 58 comments.forEach(comment => {
51 const link = CONFIG.WEBSERVER.URL + '/videos/watch/' + comment.Video.uuid + ';threadId=' + comment.getThreadId() 59 const link = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
52 60
53 feed.addItem({ 61 feed.addItem({
54 title: `${comment.Video.name} - ${comment.Account.getDisplayName()}`, 62 title: `${comment.Video.name} - ${comment.Account.getDisplayName()}`,
diff --git a/server/controllers/index.ts b/server/controllers/index.ts
index 197fa897a..a88a03c79 100644
--- a/server/controllers/index.ts
+++ b/server/controllers/index.ts
@@ -6,3 +6,4 @@ export * from './services'
6export * from './static' 6export * from './static'
7export * from './webfinger' 7export * from './webfinger'
8export * from './tracker' 8export * from './tracker'
9export * from './bots'
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 75e30353c..b21f9da00 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -1,6 +1,6 @@
1import * as cors from 'cors' 1import * as cors from 'cors'
2import * as express from 'express' 2import * as express from 'express'
3import { CONFIG, ROUTE_CACHE_LIFETIME, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers' 3import { CONFIG, HLS_PLAYLIST_DIRECTORY, ROUTE_CACHE_LIFETIME, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers'
4import { VideosPreviewCache } from '../lib/cache' 4import { VideosPreviewCache } from '../lib/cache'
5import { cacheRoute } from '../middlewares/cache' 5import { cacheRoute } from '../middlewares/cache'
6import { asyncMiddleware, videosGetValidator } from '../middlewares' 6import { asyncMiddleware, videosGetValidator } from '../middlewares'
@@ -34,18 +34,30 @@ staticRouter.use(
34) 34)
35 35
36// Videos path for webseeding 36// Videos path for webseeding
37const videosPhysicalPath = CONFIG.STORAGE.VIDEOS_DIR
38staticRouter.use( 37staticRouter.use(
39 STATIC_PATHS.WEBSEED, 38 STATIC_PATHS.WEBSEED,
40 cors(), 39 cors(),
41 express.static(videosPhysicalPath) 40 express.static(CONFIG.STORAGE.VIDEOS_DIR, { fallthrough: false }) // 404 because we don't have this video
42) 41)
43staticRouter.use( 42staticRouter.use(
43 STATIC_PATHS.REDUNDANCY,
44 cors(),
45 express.static(CONFIG.STORAGE.REDUNDANCY_DIR, { fallthrough: false }) // 404 because we don't have this video
46)
47
48staticRouter.use(
44 STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', 49 STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
45 asyncMiddleware(videosGetValidator), 50 asyncMiddleware(videosGetValidator),
46 asyncMiddleware(downloadVideoFile) 51 asyncMiddleware(downloadVideoFile)
47) 52)
48 53
54// HLS
55staticRouter.use(
56 STATIC_PATHS.PLAYLISTS.HLS,
57 cors(),
58 express.static(HLS_PLAYLIST_DIRECTORY, { fallthrough: false }) // 404 if the file does not exist
59)
60
49// Thumbnails path for express 61// Thumbnails path for express
50const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR 62const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR
51staticRouter.use( 63staticRouter.use(
@@ -131,6 +143,12 @@ staticRouter.use('/.well-known/dnt/',
131 } 143 }
132) 144)
133 145
146staticRouter.use('/.well-known/change-password',
147 (_, res: express.Response) => {
148 res.redirect('/my-account/settings')
149 }
150)
151
134// --------------------------------------------------------------------------- 152// ---------------------------------------------------------------------------
135 153
136export { 154export {
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 9bc7586d1..8b77d9de7 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -6,6 +6,8 @@ import * as proxyAddr from 'proxy-addr'
6import { Server as WebSocketServer } from 'ws' 6import { Server as WebSocketServer } from 'ws'
7import { CONFIG, TRACKER_RATE_LIMITS } from '../initializers/constants' 7import { CONFIG, TRACKER_RATE_LIMITS } from '../initializers/constants'
8import { VideoFileModel } from '../models/video/video-file' 8import { VideoFileModel } from '../models/video/video-file'
9import { parse } from 'url'
10import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
9 11
10const TrackerServer = bitTorrentTracker.Server 12const TrackerServer = bitTorrentTracker.Server
11 13
@@ -20,7 +22,7 @@ const trackerServer = new TrackerServer({
20 udp: false, 22 udp: false,
21 ws: false, 23 ws: false,
22 dht: false, 24 dht: false,
23 filter: function (infoHash, params, cb) { 25 filter: async function (infoHash, params, cb) {
24 let ip: string 26 let ip: string
25 27
26 if (params.type === 'ws') { 28 if (params.type === 'ws') {
@@ -31,19 +33,25 @@ const trackerServer = new TrackerServer({
31 33
32 const key = ip + '-' + infoHash 34 const key = ip + '-' + infoHash
33 35
34 peersIps[ip] = peersIps[ip] ? peersIps[ip] + 1 : 1 36 peersIps[ ip ] = peersIps[ ip ] ? peersIps[ ip ] + 1 : 1
35 peersIpInfoHash[key] = peersIpInfoHash[key] ? peersIpInfoHash[key] + 1 : 1 37 peersIpInfoHash[ key ] = peersIpInfoHash[ key ] ? peersIpInfoHash[ key ] + 1 : 1
36 38
37 if (peersIpInfoHash[key] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) { 39 if (peersIpInfoHash[ key ] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) {
38 return cb(new Error(`Too many requests (${peersIpInfoHash[ key ]} of ip ${ip} for torrent ${infoHash}`)) 40 return cb(new Error(`Too many requests (${peersIpInfoHash[ key ]} of ip ${ip} for torrent ${infoHash}`))
39 } 41 }
40 42
41 VideoFileModel.isInfohashExists(infoHash) 43 try {
42 .then(exists => { 44 const videoFileExists = await VideoFileModel.doesInfohashExist(infoHash)
43 if (exists === false) return cb(new Error(`Unknown infoHash ${infoHash}`)) 45 if (videoFileExists === true) return cb()
44 46
45 return cb() 47 const playlistExists = await VideoStreamingPlaylistModel.doesInfohashExist(infoHash)
46 }) 48 if (playlistExists === true) return cb()
49
50 return cb(new Error(`Unknown infoHash ${infoHash}`))
51 } catch (err) {
52 logger.error('Error in tracker filter.', { err })
53 return cb(err)
54 }
47 } 55 }
48}) 56})
49 57
@@ -59,16 +67,26 @@ const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer)
59trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' })) 67trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
60trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' })) 68trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
61 69
62function createWebsocketServer (app: express.Application) { 70function createWebsocketTrackerServer (app: express.Application) {
63 const server = http.createServer(app) 71 const server = http.createServer(app)
64 const wss = new WebSocketServer({ server: server, path: '/tracker/socket' }) 72 const wss = new WebSocketServer({ noServer: true })
73
65 wss.on('connection', function (ws, req) { 74 wss.on('connection', function (ws, req) {
66 const ip = proxyAddr(req, CONFIG.TRUST_PROXY) 75 ws['ip'] = proxyAddr(req, CONFIG.TRUST_PROXY)
67 ws['ip'] = ip
68 76
69 trackerServer.onWebSocketConnection(ws) 77 trackerServer.onWebSocketConnection(ws)
70 }) 78 })
71 79
80 server.on('upgrade', (request, socket, head) => {
81 const pathname = parse(request.url).pathname
82
83 if (pathname === '/tracker/socket') {
84 wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request))
85 }
86
87 // Don't destroy socket, we have Socket.IO too
88 })
89
72 return server 90 return server
73} 91}
74 92
@@ -76,7 +94,7 @@ function createWebsocketServer (app: express.Application) {
76 94
77export { 95export {
78 trackerRouter, 96 trackerRouter,
79 createWebsocketServer 97 createWebsocketTrackerServer
80} 98}
81 99
82// --------------------------------------------------------------------------- 100// ---------------------------------------------------------------------------
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index 2469b37b1..62d78373e 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -1,11 +1,12 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import * as validator from 'validator' 2import * as validator from 'validator'
3import { ResultList } from '../../shared/models' 3import { ResultList } from '../../shared/models'
4import { Activity, ActivityPubActor } from '../../shared/models/activitypub' 4import { Activity } from '../../shared/models/activitypub'
5import { ACTIVITY_PUB } from '../initializers' 5import { ACTIVITY_PUB } from '../initializers'
6import { ActorModel } from '../models/activitypub/actor' 6import { ActorModel } from '../models/activitypub/actor'
7import { signObject } from './peertube-crypto' 7import { signJsonLDObject } from './peertube-crypto'
8import { pageToStartAndCount } from './core-utils' 8import { pageToStartAndCount } from './core-utils'
9import { parse } from 'url'
9 10
10function activityPubContextify <T> (data: T) { 11function activityPubContextify <T> (data: T) {
11 return Object.assign(data, { 12 return Object.assign(data, {
@@ -14,25 +15,26 @@ function activityPubContextify <T> (data: T) {
14 'https://w3id.org/security/v1', 15 'https://w3id.org/security/v1',
15 { 16 {
16 RsaSignature2017: 'https://w3id.org/security#RsaSignature2017', 17 RsaSignature2017: 'https://w3id.org/security#RsaSignature2017',
17 pt: 'https://joinpeertube.org/ns', 18 pt: 'https://joinpeertube.org/ns#',
18 schema: 'http://schema.org#', 19 sc: 'http://schema.org#',
19 Hashtag: 'as:Hashtag', 20 Hashtag: 'as:Hashtag',
20 uuid: 'schema:identifier', 21 uuid: 'sc:identifier',
21 category: 'schema:category', 22 category: 'sc:category',
22 licence: 'schema:license', 23 licence: 'sc:license',
23 subtitleLanguage: 'schema:subtitleLanguage', 24 subtitleLanguage: 'sc:subtitleLanguage',
24 sensitive: 'as:sensitive', 25 sensitive: 'as:sensitive',
25 language: 'schema:inLanguage', 26 language: 'sc:inLanguage',
26 views: 'schema:Number', 27 views: 'sc:Number',
27 stats: 'schema:Number', 28 state: 'sc:Number',
28 size: 'schema:Number', 29 size: 'sc:Number',
29 fps: 'schema:Number', 30 fps: 'sc:Number',
30 commentsEnabled: 'schema:Boolean', 31 commentsEnabled: 'sc:Boolean',
31 downloadEnabled: 'schema:Boolean', 32 downloadEnabled: 'sc:Boolean',
32 waitTranscoding: 'schema:Boolean', 33 waitTranscoding: 'sc:Boolean',
33 expires: 'schema:expires', 34 expires: 'sc:expires',
34 support: 'schema:Text', 35 support: 'sc:Text',
35 CacheFile: 'pt:CacheFile' 36 CacheFile: 'pt:CacheFile',
37 Infohash: 'pt:Infohash'
36 }, 38 },
37 { 39 {
38 likes: { 40 likes: {
@@ -57,16 +59,16 @@ function activityPubContextify <T> (data: T) {
57} 59}
58 60
59type ActivityPubCollectionPaginationHandler = (start: number, count: number) => Bluebird<ResultList<any>> | Promise<ResultList<any>> 61type ActivityPubCollectionPaginationHandler = (start: number, count: number) => Bluebird<ResultList<any>> | Promise<ResultList<any>>
60async function activityPubCollectionPagination (url: string, handler: ActivityPubCollectionPaginationHandler, page?: any) { 62async function activityPubCollectionPagination (baseUrl: string, handler: ActivityPubCollectionPaginationHandler, page?: any) {
61 if (!page || !validator.isInt(page)) { 63 if (!page || !validator.isInt(page)) {
62 // We just display the first page URL, we only need the total items 64 // We just display the first page URL, we only need the total items
63 const result = await handler(0, 1) 65 const result = await handler(0, 1)
64 66
65 return { 67 return {
66 id: url, 68 id: baseUrl,
67 type: 'OrderedCollection', 69 type: 'OrderedCollection',
68 totalItems: result.total, 70 totalItems: result.total,
69 first: url + '?page=1' 71 first: baseUrl + '?page=1'
70 } 72 }
71 } 73 }
72 74
@@ -81,19 +83,19 @@ async function activityPubCollectionPagination (url: string, handler: ActivityPu
81 83
82 // There are more results 84 // There are more results
83 if (result.total > page * ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE) { 85 if (result.total > page * ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE) {
84 next = url + '?page=' + (page + 1) 86 next = baseUrl + '?page=' + (page + 1)
85 } 87 }
86 88
87 if (page > 1) { 89 if (page > 1) {
88 prev = url + '?page=' + (page - 1) 90 prev = baseUrl + '?page=' + (page - 1)
89 } 91 }
90 92
91 return { 93 return {
92 id: url + '?page=' + page, 94 id: baseUrl + '?page=' + page,
93 type: 'OrderedCollectionPage', 95 type: 'OrderedCollectionPage',
94 prev, 96 prev,
95 next, 97 next,
96 partOf: url, 98 partOf: baseUrl,
97 orderedItems: result.data, 99 orderedItems: result.data,
98 totalItems: result.total 100 totalItems: result.total
99 } 101 }
@@ -103,19 +105,27 @@ async function activityPubCollectionPagination (url: string, handler: ActivityPu
103function buildSignedActivity (byActor: ActorModel, data: Object) { 105function buildSignedActivity (byActor: ActorModel, data: Object) {
104 const activity = activityPubContextify(data) 106 const activity = activityPubContextify(data)
105 107
106 return signObject(byActor, activity) as Promise<Activity> 108 return signJsonLDObject(byActor, activity) as Promise<Activity>
107} 109}
108 110
109function getActorUrl (activityActor: string | ActivityPubActor) { 111function getAPId (activity: string | { id: string }) {
110 if (typeof activityActor === 'string') return activityActor 112 if (typeof activity === 'string') return activity
111 113
112 return activityActor.id 114 return activity.id
115}
116
117function checkUrlsSameHost (url1: string, url2: string) {
118 const idHost = parse(url1).host
119 const actorHost = parse(url2).host
120
121 return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
113} 122}
114 123
115// --------------------------------------------------------------------------- 124// ---------------------------------------------------------------------------
116 125
117export { 126export {
118 getActorUrl, 127 checkUrlsSameHost,
128 getAPId,
119 activityPubContextify, 129 activityPubContextify,
120 activityPubCollectionPagination, 130 activityPubCollectionPagination,
121 buildSignedActivity 131 buildSignedActivity
diff --git a/server/helpers/captions-utils.ts b/server/helpers/captions-utils.ts
index 660dce65c..0fb11a125 100644
--- a/server/helpers/captions-utils.ts
+++ b/server/helpers/captions-utils.ts
@@ -2,7 +2,7 @@ import { join } from 'path'
2import { CONFIG } from '../initializers' 2import { CONFIG } from '../initializers'
3import { VideoCaptionModel } from '../models/video/video-caption' 3import { VideoCaptionModel } from '../models/video/video-caption'
4import * as srt2vtt from 'srt-to-vtt' 4import * as srt2vtt from 'srt-to-vtt'
5import { createReadStream, createWriteStream, remove, rename } from 'fs-extra' 5import { createReadStream, createWriteStream, remove, move } from 'fs-extra'
6 6
7async function moveAndProcessCaptionFile (physicalFile: { filename: string, path: string }, videoCaption: VideoCaptionModel) { 7async function moveAndProcessCaptionFile (physicalFile: { filename: string, path: string }, videoCaption: VideoCaptionModel) {
8 const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR 8 const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
@@ -13,7 +13,7 @@ async function moveAndProcessCaptionFile (physicalFile: { filename: string, path
13 await convertSrtToVtt(physicalFile.path, destination) 13 await convertSrtToVtt(physicalFile.path, destination)
14 await remove(physicalFile.path) 14 await remove(physicalFile.path)
15 } else { // Just move the vtt file 15 } else { // Just move the vtt file
16 await rename(physicalFile.path, destination) 16 await move(physicalFile.path, destination, { overwrite: true })
17 } 17 }
18 18
19 // This is important in case if there is another attempt in the retry process 19 // This is important in case if there is another attempt in the retry process
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index 00bc0bdda..f38b82d97 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -5,12 +5,31 @@
5 5
6import * as bcrypt from 'bcrypt' 6import * as bcrypt from 'bcrypt'
7import * as createTorrent from 'create-torrent' 7import * as createTorrent from 'create-torrent'
8import { createHash, pseudoRandomBytes } from 'crypto' 8import { createHash, HexBase64Latin1Encoding, pseudoRandomBytes } from 'crypto'
9import { isAbsolute, join } from 'path' 9import { isAbsolute, join } from 'path'
10import * as pem from 'pem' 10import * as pem from 'pem'
11import { URL } from 'url' 11import { URL } from 'url'
12import { truncate } from 'lodash' 12import { truncate } from 'lodash'
13import { exec } from 'child_process' 13import { exec } from 'child_process'
14import { isArray } from './custom-validators/misc'
15
16const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => {
17 if (!oldObject || typeof oldObject !== 'object') {
18 return valueConverter(oldObject)
19 }
20
21 if (isArray(oldObject)) {
22 return oldObject.map(e => objectConverter(e, keyConverter, valueConverter))
23 }
24
25 const newObject = {}
26 Object.keys(oldObject).forEach(oldKey => {
27 const newKey = keyConverter(oldKey)
28 newObject[ newKey ] = objectConverter(oldObject[ oldKey ], keyConverter, valueConverter)
29 })
30
31 return newObject
32}
14 33
15const timeTable = { 34const timeTable = {
16 ms: 1, 35 ms: 1,
@@ -21,6 +40,7 @@ const timeTable = {
21 week: 3600000 * 24 * 7, 40 week: 3600000 * 24 * 7,
22 month: 3600000 * 24 * 30 41 month: 3600000 * 24 * 30
23} 42}
43
24export function parseDuration (duration: number | string): number { 44export function parseDuration (duration: number | string): number {
25 if (typeof duration === 'number') return duration 45 if (typeof duration === 'number') return duration
26 46
@@ -41,6 +61,53 @@ export function parseDuration (duration: number | string): number {
41 throw new Error('Duration could not be properly parsed') 61 throw new Error('Duration could not be properly parsed')
42} 62}
43 63
64export function parseBytes (value: string | number): number {
65 if (typeof value === 'number') return value
66
67 const tgm = /^(\d+)\s*TB\s*(\d+)\s*GB\s*(\d+)\s*MB$/
68 const tg = /^(\d+)\s*TB\s*(\d+)\s*GB$/
69 const tm = /^(\d+)\s*TB\s*(\d+)\s*MB$/
70 const gm = /^(\d+)\s*GB\s*(\d+)\s*MB$/
71 const t = /^(\d+)\s*TB$/
72 const g = /^(\d+)\s*GB$/
73 const m = /^(\d+)\s*MB$/
74 const b = /^(\d+)\s*B$/
75 let match
76
77 if (value.match(tgm)) {
78 match = value.match(tgm)
79 return parseInt(match[1], 10) * 1024 * 1024 * 1024 * 1024
80 + parseInt(match[2], 10) * 1024 * 1024 * 1024
81 + parseInt(match[3], 10) * 1024 * 1024
82 } else if (value.match(tg)) {
83 match = value.match(tg)
84 return parseInt(match[1], 10) * 1024 * 1024 * 1024 * 1024
85 + parseInt(match[2], 10) * 1024 * 1024 * 1024
86 } else if (value.match(tm)) {
87 match = value.match(tm)
88 return parseInt(match[1], 10) * 1024 * 1024 * 1024 * 1024
89 + parseInt(match[2], 10) * 1024 * 1024
90 } else if (value.match(gm)) {
91 match = value.match(gm)
92 return parseInt(match[1], 10) * 1024 * 1024 * 1024
93 + parseInt(match[2], 10) * 1024 * 1024
94 } else if (value.match(t)) {
95 match = value.match(t)
96 return parseInt(match[1], 10) * 1024 * 1024 * 1024 * 1024
97 } else if (value.match(g)) {
98 match = value.match(g)
99 return parseInt(match[1], 10) * 1024 * 1024 * 1024
100 } else if (value.match(m)) {
101 match = value.match(m)
102 return parseInt(match[1], 10) * 1024 * 1024
103 } else if (value.match(b)) {
104 match = value.match(b)
105 return parseInt(match[1], 10) * 1024
106 } else {
107 return parseInt(value, 10)
108 }
109}
110
44function sanitizeUrl (url: string) { 111function sanitizeUrl (url: string) {
45 const urlObject = new URL(url) 112 const urlObject = new URL(url)
46 113
@@ -126,8 +193,12 @@ function peertubeTruncate (str: string, maxLength: number) {
126 return truncate(str, options) 193 return truncate(str, options)
127} 194}
128 195
129function sha256 (str: string) { 196function sha256 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') {
130 return createHash('sha256').update(str).digest('hex') 197 return createHash('sha256').update(str).digest(encoding)
198}
199
200function sha1 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') {
201 return createHash('sha1').update(str).digest(encoding)
131} 202}
132 203
133function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> { 204function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
@@ -187,6 +258,7 @@ export {
187 isTestInstance, 258 isTestInstance,
188 isProdInstance, 259 isProdInstance,
189 260
261 objectConverter,
190 root, 262 root,
191 escapeHTML, 263 escapeHTML,
192 pageToStartAndCount, 264 pageToStartAndCount,
@@ -194,7 +266,9 @@ export {
194 sanitizeHost, 266 sanitizeHost,
195 buildPath, 267 buildPath,
196 peertubeTruncate, 268 peertubeTruncate,
269
197 sha256, 270 sha256,
271 sha1,
198 272
199 promisify0, 273 promisify0,
200 promisify1, 274 promisify1,
diff --git a/server/helpers/custom-jsonld-signature.ts b/server/helpers/custom-jsonld-signature.ts
index e4f28018e..27a187db1 100644
--- a/server/helpers/custom-jsonld-signature.ts
+++ b/server/helpers/custom-jsonld-signature.ts
@@ -1,5 +1,5 @@
1import * as AsyncLRU from 'async-lru' 1import * as AsyncLRU from 'async-lru'
2import * as jsonld from 'jsonld/' 2import * as jsonld from 'jsonld'
3import * as jsig from 'jsonld-signatures' 3import * as jsig from 'jsonld-signatures'
4 4
5const nodeDocumentLoader = jsonld.documentLoaders.node() 5const nodeDocumentLoader = jsonld.documentLoaders.node()
@@ -17,4 +17,4 @@ jsonld.documentLoader = (url, cb) => {
17 17
18jsig.use('jsonld', jsonld) 18jsig.use('jsonld', jsonld)
19 19
20export { jsig } 20export { jsig, jsonld }
diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts
index 2562ead9b..b24590d9d 100644
--- a/server/helpers/custom-validators/activitypub/activity.ts
+++ b/server/helpers/custom-validators/activitypub/activity.ts
@@ -1,26 +1,14 @@
1import * as validator from 'validator' 1import * as validator from 'validator'
2import { Activity, ActivityType } from '../../../../shared/models/activitypub' 2import { Activity, ActivityType } from '../../../../shared/models/activitypub'
3import { 3import { sanitizeAndCheckActorObject } from './actor'
4 isActorAcceptActivityValid, 4import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc'
5 isActorDeleteActivityValid, 5import { isDislikeActivityValid } from './rate'
6 isActorFollowActivityValid, 6import { sanitizeAndCheckVideoCommentObject } from './video-comments'
7 isActorRejectActivityValid, 7import { sanitizeAndCheckVideoTorrentObject } from './videos'
8 isActorUpdateActivityValid
9} from './actor'
10import { isAnnounceActivityValid } from './announce'
11import { isActivityPubUrlValid } from './misc'
12import { isDislikeActivityValid, isLikeActivityValid } from './rate'
13import { isUndoActivityValid } from './undo'
14import { isVideoCommentCreateActivityValid, isVideoCommentDeleteActivityValid } from './video-comments'
15import {
16 isVideoFlagValid,
17 isVideoTorrentDeleteActivityValid,
18 sanitizeAndCheckVideoTorrentCreateActivity,
19 sanitizeAndCheckVideoTorrentUpdateActivity
20} from './videos'
21import { isViewActivityValid } from './view' 8import { isViewActivityValid } from './view'
22import { exists } from '../misc' 9import { exists } from '../misc'
23import { isCacheFileCreateActivityValid, isCacheFileUpdateActivityValid } from './cache-file' 10import { isCacheFileObjectValid } from './cache-file'
11import { isFlagActivityValid } from './flag'
24 12
25function isRootActivityValid (activity: any) { 13function isRootActivityValid (activity: any) {
26 return Array.isArray(activity['@context']) && ( 14 return Array.isArray(activity['@context']) && (
@@ -46,7 +34,10 @@ const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean
46 Reject: checkRejectActivity, 34 Reject: checkRejectActivity,
47 Announce: checkAnnounceActivity, 35 Announce: checkAnnounceActivity,
48 Undo: checkUndoActivity, 36 Undo: checkUndoActivity,
49 Like: checkLikeActivity 37 Like: checkLikeActivity,
38 View: checkViewActivity,
39 Flag: checkFlagActivity,
40 Dislike: checkDislikeActivity
50} 41}
51 42
52function isActivityValid (activity: any) { 43function isActivityValid (activity: any) {
@@ -66,47 +57,79 @@ export {
66 57
67// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
68 59
60function checkViewActivity (activity: any) {
61 return isBaseActivityValid(activity, 'View') &&
62 isViewActivityValid(activity)
63}
64
65function checkFlagActivity (activity: any) {
66 return isBaseActivityValid(activity, 'Flag') &&
67 isFlagActivityValid(activity)
68}
69
70function checkDislikeActivity (activity: any) {
71 return isBaseActivityValid(activity, 'Dislike') &&
72 isDislikeActivityValid(activity)
73}
74
69function checkCreateActivity (activity: any) { 75function checkCreateActivity (activity: any) {
70 return isViewActivityValid(activity) || 76 return isBaseActivityValid(activity, 'Create') &&
71 isDislikeActivityValid(activity) || 77 (
72 sanitizeAndCheckVideoTorrentCreateActivity(activity) || 78 isViewActivityValid(activity.object) ||
73 isVideoFlagValid(activity) || 79 isDislikeActivityValid(activity.object) ||
74 isVideoCommentCreateActivityValid(activity) || 80 isFlagActivityValid(activity.object) ||
75 isCacheFileCreateActivityValid(activity) 81
82 isCacheFileObjectValid(activity.object) ||
83 sanitizeAndCheckVideoCommentObject(activity.object) ||
84 sanitizeAndCheckVideoTorrentObject(activity.object)
85 )
76} 86}
77 87
78function checkUpdateActivity (activity: any) { 88function checkUpdateActivity (activity: any) {
79 return isCacheFileUpdateActivityValid(activity) || 89 return isBaseActivityValid(activity, 'Update') &&
80 sanitizeAndCheckVideoTorrentUpdateActivity(activity) || 90 (
81 isActorUpdateActivityValid(activity) 91 isCacheFileObjectValid(activity.object) ||
92 sanitizeAndCheckVideoTorrentObject(activity.object) ||
93 sanitizeAndCheckActorObject(activity.object)
94 )
82} 95}
83 96
84function checkDeleteActivity (activity: any) { 97function checkDeleteActivity (activity: any) {
85 return isVideoTorrentDeleteActivityValid(activity) || 98 // We don't really check objects
86 isActorDeleteActivityValid(activity) || 99 return isBaseActivityValid(activity, 'Delete') &&
87 isVideoCommentDeleteActivityValid(activity) 100 isObjectValid(activity.object)
88} 101}
89 102
90function checkFollowActivity (activity: any) { 103function checkFollowActivity (activity: any) {
91 return isActorFollowActivityValid(activity) 104 return isBaseActivityValid(activity, 'Follow') &&
105 isObjectValid(activity.object)
92} 106}
93 107
94function checkAcceptActivity (activity: any) { 108function checkAcceptActivity (activity: any) {
95 return isActorAcceptActivityValid(activity) 109 return isBaseActivityValid(activity, 'Accept')
96} 110}
97 111
98function checkRejectActivity (activity: any) { 112function checkRejectActivity (activity: any) {
99 return isActorRejectActivityValid(activity) 113 return isBaseActivityValid(activity, 'Reject')
100} 114}
101 115
102function checkAnnounceActivity (activity: any) { 116function checkAnnounceActivity (activity: any) {
103 return isAnnounceActivityValid(activity) 117 return isBaseActivityValid(activity, 'Announce') &&
118 isObjectValid(activity.object)
104} 119}
105 120
106function checkUndoActivity (activity: any) { 121function checkUndoActivity (activity: any) {
107 return isUndoActivityValid(activity) 122 return isBaseActivityValid(activity, 'Undo') &&
123 (
124 checkFollowActivity(activity.object) ||
125 checkLikeActivity(activity.object) ||
126 checkDislikeActivity(activity.object) ||
127 checkAnnounceActivity(activity.object) ||
128 checkCreateActivity(activity.object)
129 )
108} 130}
109 131
110function checkLikeActivity (activity: any) { 132function checkLikeActivity (activity: any) {
111 return isLikeActivityValid(activity) 133 return isBaseActivityValid(activity, 'Like') &&
134 isObjectValid(activity.object)
112} 135}
diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts
index 77c003cdf..c05f60f14 100644
--- a/server/helpers/custom-validators/activitypub/actor.ts
+++ b/server/helpers/custom-validators/activitypub/actor.ts
@@ -27,7 +27,8 @@ function isActorPublicKeyValid (publicKey: string) {
27 validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY) 27 validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY)
28} 28}
29 29
30const actorNameRegExp = new RegExp('^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_\.]+$') 30const actorNameAlphabet = '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_.]'
31const actorNameRegExp = new RegExp(`^${actorNameAlphabet}+$`)
31function isActorPreferredUsernameValid (preferredUsername: string) { 32function isActorPreferredUsernameValid (preferredUsername: string) {
32 return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp) 33 return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp)
33} 34}
@@ -72,24 +73,10 @@ function isActorDeleteActivityValid (activity: any) {
72 return isBaseActivityValid(activity, 'Delete') 73 return isBaseActivityValid(activity, 'Delete')
73} 74}
74 75
75function isActorFollowActivityValid (activity: any) { 76function sanitizeAndCheckActorObject (object: any) {
76 return isBaseActivityValid(activity, 'Follow') && 77 normalizeActor(object)
77 isActivityPubUrlValid(activity.object)
78}
79
80function isActorAcceptActivityValid (activity: any) {
81 return isBaseActivityValid(activity, 'Accept')
82}
83
84function isActorRejectActivityValid (activity: any) {
85 return isBaseActivityValid(activity, 'Reject')
86}
87
88function isActorUpdateActivityValid (activity: any) {
89 normalizeActor(activity.object)
90 78
91 return isBaseActivityValid(activity, 'Update') && 79 return isActorObjectValid(object)
92 isActorObjectValid(activity.object)
93} 80}
94 81
95function normalizeActor (actor: any) { 82function normalizeActor (actor: any) {
@@ -127,6 +114,7 @@ function areValidActorHandles (handles: string[]) {
127 114
128export { 115export {
129 normalizeActor, 116 normalizeActor,
117 actorNameAlphabet,
130 areValidActorHandles, 118 areValidActorHandles,
131 isActorEndpointsObjectValid, 119 isActorEndpointsObjectValid,
132 isActorPublicKeyObjectValid, 120 isActorPublicKeyObjectValid,
@@ -137,10 +125,7 @@ export {
137 isActorObjectValid, 125 isActorObjectValid,
138 isActorFollowingCountValid, 126 isActorFollowingCountValid,
139 isActorFollowersCountValid, 127 isActorFollowersCountValid,
140 isActorFollowActivityValid,
141 isActorAcceptActivityValid,
142 isActorRejectActivityValid,
143 isActorDeleteActivityValid, 128 isActorDeleteActivityValid,
144 isActorUpdateActivityValid, 129 sanitizeAndCheckActorObject,
145 isValidActorHandle 130 isValidActorHandle
146} 131}
diff --git a/server/helpers/custom-validators/activitypub/announce.ts b/server/helpers/custom-validators/activitypub/announce.ts
deleted file mode 100644
index 0519c6026..000000000
--- a/server/helpers/custom-validators/activitypub/announce.ts
+++ /dev/null
@@ -1,13 +0,0 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
2
3function isAnnounceActivityValid (activity: any) {
4 return isBaseActivityValid(activity, 'Announce') &&
5 (
6 isActivityPubUrlValid(activity.object) ||
7 (activity.object && isActivityPubUrlValid(activity.object.id))
8 )
9}
10
11export {
12 isAnnounceActivityValid
13}
diff --git a/server/helpers/custom-validators/activitypub/cache-file.ts b/server/helpers/custom-validators/activitypub/cache-file.ts
index bd70934c8..21d5c53ca 100644
--- a/server/helpers/custom-validators/activitypub/cache-file.ts
+++ b/server/helpers/custom-validators/activitypub/cache-file.ts
@@ -1,28 +1,26 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 1import { isActivityPubUrlValid } from './misc'
2import { isRemoteVideoUrlValid } from './videos' 2import { isRemoteVideoUrlValid } from './videos'
3import { isDateValid, exists } from '../misc' 3import { exists, isDateValid } from '../misc'
4import { CacheFileObject } from '../../../../shared/models/activitypub/objects' 4import { CacheFileObject } from '../../../../shared/models/activitypub/objects'
5 5
6function isCacheFileCreateActivityValid (activity: any) {
7 return isBaseActivityValid(activity, 'Create') &&
8 isCacheFileObjectValid(activity.object)
9}
10
11function isCacheFileUpdateActivityValid (activity: any) {
12 return isBaseActivityValid(activity, 'Update') &&
13 isCacheFileObjectValid(activity.object)
14}
15
16function isCacheFileObjectValid (object: CacheFileObject) { 6function isCacheFileObjectValid (object: CacheFileObject) {
17 return exists(object) && 7 return exists(object) &&
18 object.type === 'CacheFile' && 8 object.type === 'CacheFile' &&
19 isDateValid(object.expires) && 9 isDateValid(object.expires) &&
20 isActivityPubUrlValid(object.object) && 10 isActivityPubUrlValid(object.object) &&
21 isRemoteVideoUrlValid(object.url) 11 (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
22} 12}
23 13
14// ---------------------------------------------------------------------------
15
24export { 16export {
25 isCacheFileUpdateActivityValid,
26 isCacheFileCreateActivityValid,
27 isCacheFileObjectValid 17 isCacheFileObjectValid
28} 18}
19
20// ---------------------------------------------------------------------------
21
22function isPlaylistRedundancyUrlValid (url: any) {
23 return url.type === 'Link' &&
24 (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
25 isActivityPubUrlValid(url.href)
26}
diff --git a/server/helpers/custom-validators/activitypub/flag.ts b/server/helpers/custom-validators/activitypub/flag.ts
new file mode 100644
index 000000000..6452e297c
--- /dev/null
+++ b/server/helpers/custom-validators/activitypub/flag.ts
@@ -0,0 +1,14 @@
1import { isActivityPubUrlValid } from './misc'
2import { isVideoAbuseReasonValid } from '../video-abuses'
3
4function isFlagActivityValid (activity: any) {
5 return activity.type === 'Flag' &&
6 isVideoAbuseReasonValid(activity.content) &&
7 isActivityPubUrlValid(activity.object)
8}
9
10// ---------------------------------------------------------------------------
11
12export {
13 isFlagActivityValid
14}
diff --git a/server/helpers/custom-validators/activitypub/misc.ts b/server/helpers/custom-validators/activitypub/misc.ts
index 4e2c57f04..f1762d11c 100644
--- a/server/helpers/custom-validators/activitypub/misc.ts
+++ b/server/helpers/custom-validators/activitypub/misc.ts
@@ -28,15 +28,20 @@ function isBaseActivityValid (activity: any, type: string) {
28 return (activity['@context'] === undefined || Array.isArray(activity['@context'])) && 28 return (activity['@context'] === undefined || Array.isArray(activity['@context'])) &&
29 activity.type === type && 29 activity.type === type &&
30 isActivityPubUrlValid(activity.id) && 30 isActivityPubUrlValid(activity.id) &&
31 exists(activity.actor) && 31 isObjectValid(activity.actor) &&
32 (isActivityPubUrlValid(activity.actor) || isActivityPubUrlValid(activity.actor.id)) && 32 isUrlCollectionValid(activity.to) &&
33 ( 33 isUrlCollectionValid(activity.cc)
34 activity.to === undefined || 34}
35 (Array.isArray(activity.to) && activity.to.every(t => isActivityPubUrlValid(t))) 35
36 ) && 36function isUrlCollectionValid (collection: any) {
37 return collection === undefined ||
38 (Array.isArray(collection) && collection.every(t => isActivityPubUrlValid(t)))
39}
40
41function isObjectValid (object: any) {
42 return exists(object) &&
37 ( 43 (
38 activity.cc === undefined || 44 isActivityPubUrlValid(object) || isActivityPubUrlValid(object.id)
39 (Array.isArray(activity.cc) && activity.cc.every(t => isActivityPubUrlValid(t)))
40 ) 45 )
41} 46}
42 47
@@ -57,5 +62,6 @@ export {
57 isUrlValid, 62 isUrlValid,
58 isActivityPubUrlValid, 63 isActivityPubUrlValid,
59 isBaseActivityValid, 64 isBaseActivityValid,
60 setValidAttributedTo 65 setValidAttributedTo,
66 isObjectValid
61} 67}
diff --git a/server/helpers/custom-validators/activitypub/rate.ts b/server/helpers/custom-validators/activitypub/rate.ts
index e70bd94b8..ba68e8074 100644
--- a/server/helpers/custom-validators/activitypub/rate.ts
+++ b/server/helpers/custom-validators/activitypub/rate.ts
@@ -1,20 +1,13 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 1import { isActivityPubUrlValid, isObjectValid } from './misc'
2
3function isLikeActivityValid (activity: any) {
4 return isBaseActivityValid(activity, 'Like') &&
5 isActivityPubUrlValid(activity.object)
6}
7 2
8function isDislikeActivityValid (activity: any) { 3function isDislikeActivityValid (activity: any) {
9 return isBaseActivityValid(activity, 'Create') && 4 return activity.type === 'Dislike' &&
10 activity.object.type === 'Dislike' && 5 isActivityPubUrlValid(activity.actor) &&
11 isActivityPubUrlValid(activity.object.actor) && 6 isObjectValid(activity.object)
12 isActivityPubUrlValid(activity.object.object)
13} 7}
14 8
15// --------------------------------------------------------------------------- 9// ---------------------------------------------------------------------------
16 10
17export { 11export {
18 isLikeActivityValid,
19 isDislikeActivityValid 12 isDislikeActivityValid
20} 13}
diff --git a/server/helpers/custom-validators/activitypub/undo.ts b/server/helpers/custom-validators/activitypub/undo.ts
deleted file mode 100644
index 578035893..000000000
--- a/server/helpers/custom-validators/activitypub/undo.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import { isActorFollowActivityValid } from './actor'
2import { isBaseActivityValid } from './misc'
3import { isDislikeActivityValid, isLikeActivityValid } from './rate'
4import { isAnnounceActivityValid } from './announce'
5import { isCacheFileCreateActivityValid } from './cache-file'
6
7function isUndoActivityValid (activity: any) {
8 return isBaseActivityValid(activity, 'Undo') &&
9 (
10 isActorFollowActivityValid(activity.object) ||
11 isLikeActivityValid(activity.object) ||
12 isDislikeActivityValid(activity.object) ||
13 isAnnounceActivityValid(activity.object) ||
14 isCacheFileCreateActivityValid(activity.object)
15 )
16}
17
18export {
19 isUndoActivityValid
20}
diff --git a/server/helpers/custom-validators/activitypub/video-comments.ts b/server/helpers/custom-validators/activitypub/video-comments.ts
index 051c4565a..0415db21c 100644
--- a/server/helpers/custom-validators/activitypub/video-comments.ts
+++ b/server/helpers/custom-validators/activitypub/video-comments.ts
@@ -3,11 +3,6 @@ import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers'
3import { exists, isArray, isDateValid } from '../misc' 3import { exists, isArray, isDateValid } from '../misc'
4import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 4import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
5 5
6function isVideoCommentCreateActivityValid (activity: any) {
7 return isBaseActivityValid(activity, 'Create') &&
8 sanitizeAndCheckVideoCommentObject(activity.object)
9}
10
11function sanitizeAndCheckVideoCommentObject (comment: any) { 6function sanitizeAndCheckVideoCommentObject (comment: any) {
12 if (!comment || comment.type !== 'Note') return false 7 if (!comment || comment.type !== 'Note') return false
13 8
@@ -25,15 +20,9 @@ function sanitizeAndCheckVideoCommentObject (comment: any) {
25 ) // Only accept public comments 20 ) // Only accept public comments
26} 21}
27 22
28function isVideoCommentDeleteActivityValid (activity: any) {
29 return isBaseActivityValid(activity, 'Delete')
30}
31
32// --------------------------------------------------------------------------- 23// ---------------------------------------------------------------------------
33 24
34export { 25export {
35 isVideoCommentCreateActivityValid,
36 isVideoCommentDeleteActivityValid,
37 sanitizeAndCheckVideoCommentObject 26 sanitizeAndCheckVideoCommentObject
38} 27}
39 28
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index 5015c59dd..53ad0588d 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -1,7 +1,7 @@
1import * as validator from 'validator' 1import * as validator from 'validator'
2import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers' 2import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers'
3import { peertubeTruncate } from '../../core-utils' 3import { peertubeTruncate } from '../../core-utils'
4import { exists, isBooleanValid, isDateValid, isUUIDValid } from '../misc' 4import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
5import { 5import {
6 isVideoDurationValid, 6 isVideoDurationValid,
7 isVideoNameValid, 7 isVideoNameValid,
@@ -12,29 +12,12 @@ import {
12} from '../videos' 12} from '../videos'
13import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' 13import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
14import { VideoState } from '../../../../shared/models/videos' 14import { VideoState } from '../../../../shared/models/videos'
15import { isVideoAbuseReasonValid } from '../video-abuses'
16
17function sanitizeAndCheckVideoTorrentCreateActivity (activity: any) {
18 return isBaseActivityValid(activity, 'Create') &&
19 sanitizeAndCheckVideoTorrentObject(activity.object)
20}
21 15
22function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) { 16function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) {
23 return isBaseActivityValid(activity, 'Update') && 17 return isBaseActivityValid(activity, 'Update') &&
24 sanitizeAndCheckVideoTorrentObject(activity.object) 18 sanitizeAndCheckVideoTorrentObject(activity.object)
25} 19}
26 20
27function isVideoTorrentDeleteActivityValid (activity: any) {
28 return isBaseActivityValid(activity, 'Delete')
29}
30
31function isVideoFlagValid (activity: any) {
32 return isBaseActivityValid(activity, 'Create') &&
33 activity.object.type === 'Flag' &&
34 isVideoAbuseReasonValid(activity.object.content) &&
35 isActivityPubUrlValid(activity.object.object)
36}
37
38function isActivityPubVideoDurationValid (value: string) { 21function isActivityPubVideoDurationValid (value: string) {
39 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration 22 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
40 return exists(value) && 23 return exists(value) &&
@@ -83,32 +66,35 @@ function isRemoteVideoUrlValid (url: any) {
83 66
84 return url.type === 'Link' && 67 return url.type === 'Link' &&
85 ( 68 (
86 ACTIVITY_PUB.URL_MIME_TYPES.VIDEO.indexOf(url.mimeType) !== -1 && 69 // TODO: remove mimeType (backward compatibility, introduced in v1.1.0)
70 ACTIVITY_PUB.URL_MIME_TYPES.VIDEO.indexOf(url.mediaType || url.mimeType) !== -1 &&
87 isActivityPubUrlValid(url.href) && 71 isActivityPubUrlValid(url.href) &&
88 validator.isInt(url.height + '', { min: 0 }) && 72 validator.isInt(url.height + '', { min: 0 }) &&
89 validator.isInt(url.size + '', { min: 0 }) && 73 validator.isInt(url.size + '', { min: 0 }) &&
90 (!url.fps || validator.isInt(url.fps + '', { min: -1 })) 74 (!url.fps || validator.isInt(url.fps + '', { min: -1 }))
91 ) || 75 ) ||
92 ( 76 (
93 ACTIVITY_PUB.URL_MIME_TYPES.TORRENT.indexOf(url.mimeType) !== -1 && 77 ACTIVITY_PUB.URL_MIME_TYPES.TORRENT.indexOf(url.mediaType || url.mimeType) !== -1 &&
94 isActivityPubUrlValid(url.href) && 78 isActivityPubUrlValid(url.href) &&
95 validator.isInt(url.height + '', { min: 0 }) 79 validator.isInt(url.height + '', { min: 0 })
96 ) || 80 ) ||
97 ( 81 (
98 ACTIVITY_PUB.URL_MIME_TYPES.MAGNET.indexOf(url.mimeType) !== -1 && 82 ACTIVITY_PUB.URL_MIME_TYPES.MAGNET.indexOf(url.mediaType || url.mimeType) !== -1 &&
99 validator.isLength(url.href, { min: 5 }) && 83 validator.isLength(url.href, { min: 5 }) &&
100 validator.isInt(url.height + '', { min: 0 }) 84 validator.isInt(url.height + '', { min: 0 })
85 ) ||
86 (
87 (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
88 isActivityPubUrlValid(url.href) &&
89 isArray(url.tag)
101 ) 90 )
102} 91}
103 92
104// --------------------------------------------------------------------------- 93// ---------------------------------------------------------------------------
105 94
106export { 95export {
107 sanitizeAndCheckVideoTorrentCreateActivity,
108 sanitizeAndCheckVideoTorrentUpdateActivity, 96 sanitizeAndCheckVideoTorrentUpdateActivity,
109 isVideoTorrentDeleteActivityValid,
110 isRemoteStringIdentifierValid, 97 isRemoteStringIdentifierValid,
111 isVideoFlagValid,
112 sanitizeAndCheckVideoTorrentObject, 98 sanitizeAndCheckVideoTorrentObject,
113 isRemoteVideoUrlValid 99 isRemoteVideoUrlValid
114} 100}
diff --git a/server/helpers/custom-validators/activitypub/view.ts b/server/helpers/custom-validators/activitypub/view.ts
index 7a3aca6f5..41d16469f 100644
--- a/server/helpers/custom-validators/activitypub/view.ts
+++ b/server/helpers/custom-validators/activitypub/view.ts
@@ -1,11 +1,11 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 1import { isActivityPubUrlValid } from './misc'
2 2
3function isViewActivityValid (activity: any) { 3function isViewActivityValid (activity: any) {
4 return isBaseActivityValid(activity, 'Create') && 4 return activity.type === 'View' &&
5 activity.object.type === 'View' && 5 isActivityPubUrlValid(activity.actor) &&
6 isActivityPubUrlValid(activity.object.actor) && 6 isActivityPubUrlValid(activity.object)
7 isActivityPubUrlValid(activity.object.object)
8} 7}
8
9// --------------------------------------------------------------------------- 9// ---------------------------------------------------------------------------
10 10
11export { 11export {
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 6d10a65a8..76647fea2 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -9,6 +9,14 @@ function isArray (value: any) {
9 return Array.isArray(value) 9 return Array.isArray(value)
10} 10}
11 11
12function isNotEmptyIntArray (value: any) {
13 return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0
14}
15
16function isArrayOf (value: any, validator: (value: any) => boolean) {
17 return isArray(value) && value.every(v => validator(v))
18}
19
12function isDateValid (value: string) { 20function isDateValid (value: string) {
13 return exists(value) && validator.isISO8601(value) 21 return exists(value) && validator.isISO8601(value)
14} 22}
@@ -78,6 +86,8 @@ function isFileValid (
78 86
79export { 87export {
80 exists, 88 exists,
89 isArrayOf,
90 isNotEmptyIntArray,
81 isArray, 91 isArray,
82 isIdValid, 92 isIdValid,
83 isUUIDValid, 93 isUUIDValid,
diff --git a/server/helpers/custom-validators/servers.ts b/server/helpers/custom-validators/servers.ts
index d5021bf38..18c80ec8f 100644
--- a/server/helpers/custom-validators/servers.ts
+++ b/server/helpers/custom-validators/servers.ts
@@ -3,6 +3,7 @@ import 'express-validator'
3 3
4import { isArray, exists } from './misc' 4import { isArray, exists } from './misc'
5import { isTestInstance } from '../core-utils' 5import { isTestInstance } from '../core-utils'
6import { CONSTRAINTS_FIELDS } from '../../initializers'
6 7
7function isHostValid (host: string) { 8function isHostValid (host: string) {
8 const isURLOptions = { 9 const isURLOptions = {
@@ -26,9 +27,19 @@ function isEachUniqueHostValid (hosts: string[]) {
26 }) 27 })
27} 28}
28 29
30function isValidContactBody (value: any) {
31 return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.BODY)
32}
33
34function isValidContactFromName (value: any) {
35 return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.FROM_NAME)
36}
37
29// --------------------------------------------------------------------------- 38// ---------------------------------------------------------------------------
30 39
31export { 40export {
41 isValidContactBody,
42 isValidContactFromName,
32 isEachUniqueHostValid, 43 isEachUniqueHostValid,
33 isHostValid 44 isHostValid
34} 45}
diff --git a/server/helpers/custom-validators/user-notifications.ts b/server/helpers/custom-validators/user-notifications.ts
new file mode 100644
index 000000000..02ea3bbc2
--- /dev/null
+++ b/server/helpers/custom-validators/user-notifications.ts
@@ -0,0 +1,23 @@
1import { exists } from './misc'
2import * as validator from 'validator'
3import { UserNotificationType } from '../../../shared/models/users'
4import { UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
5
6function isUserNotificationTypeValid (value: any) {
7 return exists(value) && validator.isInt('' + value) && UserNotificationType[value] !== undefined
8}
9
10function isUserNotificationSettingValid (value: any) {
11 return exists(value) &&
12 validator.isInt('' + value) && (
13 value === UserNotificationSettingValue.NONE ||
14 value === UserNotificationSettingValue.WEB ||
15 value === UserNotificationSettingValue.EMAIL ||
16 value === (UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL)
17 )
18}
19
20export {
21 isUserNotificationSettingValid,
22 isUserNotificationTypeValid
23}
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts
index 90fc74a48..80652b479 100644
--- a/server/helpers/custom-validators/users.ts
+++ b/server/helpers/custom-validators/users.ts
@@ -42,6 +42,14 @@ function isUserNSFWPolicyValid (value: any) {
42 return exists(value) && nsfwPolicies.indexOf(value) !== -1 42 return exists(value) && nsfwPolicies.indexOf(value) !== -1
43} 43}
44 44
45function isUserWebTorrentEnabledValid (value: any) {
46 return isBooleanValid(value)
47}
48
49function isUserVideosHistoryEnabledValid (value: any) {
50 return isBooleanValid(value)
51}
52
45function isUserAutoPlayVideoValid (value: any) { 53function isUserAutoPlayVideoValid (value: any) {
46 return isBooleanValid(value) 54 return isBooleanValid(value)
47} 55}
@@ -69,6 +77,7 @@ function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } |
69// --------------------------------------------------------------------------- 77// ---------------------------------------------------------------------------
70 78
71export { 79export {
80 isUserVideosHistoryEnabledValid,
72 isUserBlockedValid, 81 isUserBlockedValid,
73 isUserPasswordValid, 82 isUserPasswordValid,
74 isUserBlockedReasonValid, 83 isUserBlockedReasonValid,
@@ -78,6 +87,7 @@ export {
78 isUserUsernameValid, 87 isUserUsernameValid,
79 isUserEmailVerifiedValid, 88 isUserEmailVerifiedValid,
80 isUserNSFWPolicyValid, 89 isUserNSFWPolicyValid,
90 isUserWebTorrentEnabledValid,
81 isUserAutoPlayVideoValid, 91 isUserAutoPlayVideoValid,
82 isUserDisplayNameValid, 92 isUserDisplayNameValid,
83 isUserDescriptionValid, 93 isUserDescriptionValid,
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 177e9e86e..b33d90e18 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -1,4 +1,4 @@
1import { CONSTRAINTS_FIELDS, VIDEO_CAPTIONS_MIMETYPE_EXT, VIDEO_LANGUAGES } from '../../initializers' 1import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers'
2import { exists, isFileValid } from './misc' 2import { exists, isFileValid } from './misc'
3import { Response } from 'express' 3import { Response } from 'express'
4import { VideoModel } from '../../models/video/video' 4import { VideoModel } from '../../models/video/video'
@@ -8,7 +8,7 @@ function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined 8 return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
9} 9}
10 10
11const videoCaptionTypes = Object.keys(VIDEO_CAPTIONS_MIMETYPE_EXT) 11const videoCaptionTypes = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream >< 12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream ><
13 .map(m => `(${m})`) 13 .map(m => `(${m})`)
14const videoCaptionTypesRegex = videoCaptionTypes.join('|') 14const videoCaptionTypesRegex = videoCaptionTypes.join('|')
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index 4d6ab1fa4..ce9e9193c 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -1,7 +1,7 @@
1import 'express-validator' 1import 'express-validator'
2import 'multer' 2import 'multer'
3import * as validator from 'validator' 3import * as validator from 'validator'
4import { CONSTRAINTS_FIELDS, TORRENT_MIMETYPE_EXT, VIDEO_IMPORT_STATES } from '../../initializers' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers'
5import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
6import * as express from 'express' 6import * as express from 'express'
7import { VideoImportModel } from '../../models/video/video-import' 7import { VideoImportModel } from '../../models/video/video-import'
@@ -24,7 +24,7 @@ function isVideoImportStateValid (value: any) {
24 return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined 24 return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined
25} 25}
26 26
27const videoTorrentImportTypes = Object.keys(TORRENT_MIMETYPE_EXT).map(m => `(${m})`) 27const videoTorrentImportTypes = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT).map(m => `(${m})`)
28const videoTorrentImportRegex = videoTorrentImportTypes.join('|') 28const videoTorrentImportRegex = videoTorrentImportTypes.join('|')
29function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 29function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
30 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true) 30 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 714f7ac95..95e256b8f 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -3,12 +3,11 @@ import 'express-validator'
3import { values } from 'lodash' 3import { values } from 'lodash'
4import 'multer' 4import 'multer'
5import * as validator from 'validator' 5import * as validator from 'validator'
6import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared' 6import { UserRight, VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared'
7import { 7import {
8 CONSTRAINTS_FIELDS, 8 CONSTRAINTS_FIELDS, MIMETYPES,
9 VIDEO_CATEGORIES, 9 VIDEO_CATEGORIES,
10 VIDEO_LICENCES, 10 VIDEO_LICENCES,
11 VIDEO_MIMETYPE_EXT,
12 VIDEO_PRIVACIES, 11 VIDEO_PRIVACIES,
13 VIDEO_RATE_TYPES, 12 VIDEO_RATE_TYPES,
14 VIDEO_STATES 13 VIDEO_STATES
@@ -22,6 +21,10 @@ import { fetchVideo, VideoFetchType } from '../video'
22 21
23const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 22const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
24 23
24function isVideoFilterValid (filter: VideoFilter) {
25 return filter === 'local' || filter === 'all-local'
26}
27
25function isVideoCategoryValid (value: any) { 28function isVideoCategoryValid (value: any) {
26 return value === null || VIDEO_CATEGORIES[ value ] !== undefined 29 return value === null || VIDEO_CATEGORIES[ value ] !== undefined
27} 30}
@@ -79,10 +82,15 @@ function isVideoRatingTypeValid (value: string) {
79 return value === 'none' || values(VIDEO_RATE_TYPES).indexOf(value as VideoRateType) !== -1 82 return value === 'none' || values(VIDEO_RATE_TYPES).indexOf(value as VideoRateType) !== -1
80} 83}
81 84
82const videoFileTypes = Object.keys(VIDEO_MIMETYPE_EXT).map(m => `(${m})`) 85function isVideoFileExtnameValid (value: string) {
83const videoFileTypesRegex = videoFileTypes.join('|') 86 return exists(value) && MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined
87}
84 88
85function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 89function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
90 const videoFileTypesRegex = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
91 .map(m => `(${m})`)
92 .join('|')
93
86 return isFileValid(files, videoFileTypesRegex, 'videofile', null) 94 return isFileValid(files, videoFileTypesRegex, 'videofile', null)
87} 95}
88 96
@@ -217,6 +225,7 @@ export {
217 isVideoStateValid, 225 isVideoStateValid,
218 isVideoViewsValid, 226 isVideoViewsValid,
219 isVideoRatingTypeValid, 227 isVideoRatingTypeValid,
228 isVideoFileExtnameValid,
220 isVideoDurationValid, 229 isVideoDurationValid,
221 isVideoTagValid, 230 isVideoTagValid,
222 isVideoPrivacyValid, 231 isVideoPrivacyValid,
@@ -225,5 +234,6 @@ export {
225 isVideoExist, 234 isVideoExist,
226 isVideoImage, 235 isVideoImage,
227 isVideoChannelOfAccountExist, 236 isVideoChannelOfAccountExist,
228 isVideoSupportValid 237 isVideoSupportValid,
238 isVideoFilterValid
229} 239}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 8a9cee8c5..9a72ee96d 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -2,18 +2,17 @@ import * as express from 'express'
2import * as multer from 'multer' 2import * as multer from 'multer'
3import { CONFIG, REMOTE_SCHEME } from '../initializers' 3import { CONFIG, REMOTE_SCHEME } from '../initializers'
4import { logger } from './logger' 4import { logger } from './logger'
5import { User } from '../../shared/models/users'
6import { deleteFileAsync, generateRandomString } from './utils' 5import { deleteFileAsync, generateRandomString } from './utils'
7import { extname } from 'path' 6import { extname } from 'path'
8import { isArray } from './custom-validators/misc' 7import { isArray } from './custom-validators/misc'
9import { UserModel } from '../models/account/user' 8import { UserModel } from '../models/account/user'
10 9
11function buildNSFWFilter (res: express.Response, paramNSFW?: string) { 10function buildNSFWFilter (res?: express.Response, paramNSFW?: string) {
12 if (paramNSFW === 'true') return true 11 if (paramNSFW === 'true') return true
13 if (paramNSFW === 'false') return false 12 if (paramNSFW === 'false') return false
14 if (paramNSFW === 'both') return undefined 13 if (paramNSFW === 'both') return undefined
15 14
16 if (res.locals.oauth) { 15 if (res && res.locals.oauth) {
17 const user: UserModel = res.locals.oauth.token.User 16 const user: UserModel = res.locals.oauth.token.User
18 17
19 // User does not want NSFW videos 18 // User does not want NSFW videos
@@ -101,7 +100,7 @@ function createReqFiles (
101} 100}
102 101
103function isUserAbleToSearchRemoteURI (res: express.Response) { 102function isUserAbleToSearchRemoteURI (res: express.Response) {
104 const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined 103 const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
105 104
106 return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true || 105 return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
107 (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined) 106 (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 22bc25476..133b1b03b 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,7 +1,7 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { join } from 'path' 2import { dirname, join } from 'path'
3import { VideoResolution } from '../../shared/models/videos' 3import { getTargetBitrate, VideoResolution } from '../../shared/models/videos'
4import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers' 4import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
5import { processImage } from './image-utils' 5import { processImage } from './image-utils'
6import { logger } from './logger' 6import { logger } from './logger'
7import { checkFFmpegEncoders } from '../initializers/checker-before-init' 7import { checkFFmpegEncoders } from '../initializers/checker-before-init'
@@ -29,19 +29,28 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
29 return resolutionsEnabled 29 return resolutionsEnabled
30} 30}
31 31
32async function getVideoFileResolution (path: string) { 32async function getVideoFileSize (path: string) {
33 const videoStream = await getVideoFileStream(path) 33 const videoStream = await getVideoFileStream(path)
34 34
35 return { 35 return {
36 videoFileResolution: Math.min(videoStream.height, videoStream.width), 36 width: videoStream.width,
37 isPortraitMode: videoStream.height > videoStream.width 37 height: videoStream.height
38 }
39}
40
41async function getVideoFileResolution (path: string) {
42 const size = await getVideoFileSize(path)
43
44 return {
45 videoFileResolution: Math.min(size.height, size.width),
46 isPortraitMode: size.height > size.width
38 } 47 }
39} 48}
40 49
41async function getVideoFileFPS (path: string) { 50async function getVideoFileFPS (path: string) {
42 const videoStream = await getVideoFileStream(path) 51 const videoStream = await getVideoFileStream(path)
43 52
44 for (const key of [ 'r_frame_rate' , 'avg_frame_rate' ]) { 53 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
45 const valuesText: string = videoStream[key] 54 const valuesText: string = videoStream[key]
46 if (!valuesText) continue 55 if (!valuesText) continue
47 56
@@ -55,6 +64,16 @@ async function getVideoFileFPS (path: string) {
55 return 0 64 return 0
56} 65}
57 66
67async function getVideoFileBitrate (path: string) {
68 return new Promise<number>((res, rej) => {
69 ffmpeg.ffprobe(path, (err, metadata) => {
70 if (err) return rej(err)
71
72 return res(metadata.format.bit_rate)
73 })
74 })
75}
76
58function getDurationFromVideoFile (path: string) { 77function getDurationFromVideoFile (path: string) {
59 return new Promise<number>((res, rej) => { 78 return new Promise<number>((res, rej) => {
60 ffmpeg.ffprobe(path, (err, metadata) => { 79 ffmpeg.ffprobe(path, (err, metadata) => {
@@ -100,64 +119,87 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
100type TranscodeOptions = { 119type TranscodeOptions = {
101 inputPath: string 120 inputPath: string
102 outputPath: string 121 outputPath: string
103 resolution?: VideoResolution 122 resolution: VideoResolution
104 isPortraitMode?: boolean 123 isPortraitMode?: boolean
124
125 hlsPlaylist?: {
126 videoFilename: string
127 }
105} 128}
106 129
107function transcode (options: TranscodeOptions) { 130function transcode (options: TranscodeOptions) {
108 return new Promise<void>(async (res, rej) => { 131 return new Promise<void>(async (res, rej) => {
109 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) 132 try {
110 .output(options.outputPath) 133 let fps = await getVideoFileFPS(options.inputPath)
111 .preset(standard)
112
113 if (CONFIG.TRANSCODING.THREADS > 0) {
114 // if we don't set any threads ffmpeg will chose automatically
115 command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
116 }
117
118 let fps = await getVideoFileFPS(options.inputPath)
119 if (options.resolution !== undefined) {
120 // '?x720' or '720x?' for example
121 const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
122 command = command.size(size)
123
124 // On small/medium resolutions, limit FPS 134 // On small/medium resolutions, limit FPS
125 if ( 135 if (
136 options.resolution !== undefined &&
126 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && 137 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
127 fps > VIDEO_TRANSCODING_FPS.AVERAGE 138 fps > VIDEO_TRANSCODING_FPS.AVERAGE
128 ) { 139 ) {
129 fps = VIDEO_TRANSCODING_FPS.AVERAGE 140 fps = VIDEO_TRANSCODING_FPS.AVERAGE
130 } 141 }
131 }
132 142
133 if (fps) { 143 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
134 // Hard FPS limits 144 .output(options.outputPath)
135 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX 145 command = await presetH264(command, options.resolution, fps)
136 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
137 146
138 command = command.withFPS(fps) 147 if (CONFIG.TRANSCODING.THREADS > 0) {
139 } 148 // if we don't set any threads ffmpeg will chose automatically
149 command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
150 }
151
152 if (options.resolution !== undefined) {
153 // '?x720' or '720x?' for example
154 const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
155 command = command.size(size)
156 }
157
158 if (fps) {
159 // Hard FPS limits
160 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX
161 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
162
163 command = command.withFPS(fps)
164 }
165
166 if (options.hlsPlaylist) {
167 const videoPath = `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
168
169 command = command.outputOption('-hls_time 4')
170 .outputOption('-hls_list_size 0')
171 .outputOption('-hls_playlist_type vod')
172 .outputOption('-hls_segment_filename ' + videoPath)
173 .outputOption('-hls_segment_type fmp4')
174 .outputOption('-f hls')
175 .outputOption('-hls_flags single_file')
176 }
140 177
141 command 178 command
142 .on('error', (err, stdout, stderr) => { 179 .on('error', (err, stdout, stderr) => {
143 logger.error('Error in transcoding job.', { stdout, stderr }) 180 logger.error('Error in transcoding job.', { stdout, stderr })
144 return rej(err) 181 return rej(err)
145 }) 182 })
146 .on('end', res) 183 .on('end', res)
147 .run() 184 .run()
185 } catch (err) {
186 return rej(err)
187 }
148 }) 188 })
149} 189}
150 190
151// --------------------------------------------------------------------------- 191// ---------------------------------------------------------------------------
152 192
153export { 193export {
194 getVideoFileSize,
154 getVideoFileResolution, 195 getVideoFileResolution,
155 getDurationFromVideoFile, 196 getDurationFromVideoFile,
156 generateImageFromVideoFile, 197 generateImageFromVideoFile,
157 transcode, 198 transcode,
158 getVideoFileFPS, 199 getVideoFileFPS,
159 computeResolutionsToTranscode, 200 computeResolutionsToTranscode,
160 audio 201 audio,
202 getVideoFileBitrate
161} 203}
162 204
163// --------------------------------------------------------------------------- 205// ---------------------------------------------------------------------------
@@ -168,7 +210,7 @@ function getVideoFileStream (path: string) {
168 if (err) return rej(err) 210 if (err) return rej(err)
169 211
170 const videoStream = metadata.streams.find(s => s.codec_type === 'video') 212 const videoStream = metadata.streams.find(s => s.codec_type === 'video')
171 if (!videoStream) throw new Error('Cannot find video stream of ' + path) 213 if (!videoStream) return rej(new Error('Cannot find video stream of ' + path))
172 214
173 return res(videoStream) 215 return res(videoStream)
174 }) 216 })
@@ -182,11 +224,10 @@ function getVideoFileStream (path: string) {
182 * and quality. Superfast and ultrafast will give you better 224 * and quality. Superfast and ultrafast will give you better
183 * performance, but then quality is noticeably worse. 225 * performance, but then quality is noticeably worse.
184 */ 226 */
185function veryfast (_ffmpeg) { 227async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
186 _ffmpeg 228 let localCommand = await presetH264(command, resolution, fps)
187 .preset(standard) 229 localCommand = localCommand.outputOption('-preset:v veryfast')
188 .outputOption('-preset:v veryfast') 230 .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ])
189 .outputOption(['--aq-mode=2', '--aq-strength=1.3'])
190 /* 231 /*
191 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html 232 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
192 Our target situation is closer to a livestream than a stream, 233 Our target situation is closer to a livestream than a stream,
@@ -198,31 +239,39 @@ function veryfast (_ffmpeg) {
198 Make up for most of the loss of grain and macroblocking 239 Make up for most of the loss of grain and macroblocking
199 with less computing power. 240 with less computing power.
200 */ 241 */
242
243 return localCommand
201} 244}
202 245
203/** 246/**
204 * A preset optimised for a stillimage audio video 247 * A preset optimised for a stillimage audio video
205 */ 248 */
206function audio (_ffmpeg) { 249async function presetStillImageWithAudio (
207 _ffmpeg 250 command: ffmpeg.FfmpegCommand,
208 .preset(veryfast) 251 resolution: VideoResolution,
209 .outputOption('-tune stillimage') 252 fps: number
253): Promise<ffmpeg.FfmpegCommand> {
254 let localCommand = await presetH264VeryFast(command, resolution, fps)
255 localCommand = localCommand.outputOption('-tune stillimage')
256
257 return localCommand
210} 258}
211 259
212/** 260/**
213 * A toolbox to play with audio 261 * A toolbox to play with audio
214 */ 262 */
215namespace audio { 263namespace audio {
216 export const get = (_ffmpeg, pos: number | string = 0) => { 264 export const get = (option: ffmpeg.FfmpegCommand | string) => {
217 // without position, ffprobe considers the last input only 265 // without position, ffprobe considers the last input only
218 // we make it consider the first input only 266 // we make it consider the first input only
219 // if you pass a file path to pos, then ffprobe acts on that file directly 267 // if you pass a file path to pos, then ffprobe acts on that file directly
220 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => { 268 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
221 _ffmpeg.ffprobe(pos, (err,data) => { 269
270 function parseFfprobe (err: any, data: ffmpeg.FfprobeData) {
222 if (err) return rej(err) 271 if (err) return rej(err)
223 272
224 if ('streams' in data) { 273 if ('streams' in data) {
225 const audioStream = data['streams'].find(stream => stream['codec_type'] === 'audio') 274 const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
226 if (audioStream) { 275 if (audioStream) {
227 return res({ 276 return res({
228 absolutePath: data.format.filename, 277 absolutePath: data.format.filename,
@@ -230,8 +279,15 @@ namespace audio {
230 }) 279 })
231 } 280 }
232 } 281 }
282
233 return res({ absolutePath: data.format.filename }) 283 return res({ absolutePath: data.format.filename })
234 }) 284 }
285
286 if (typeof option === 'string') {
287 return ffmpeg.ffprobe(option, parseFfprobe)
288 }
289
290 return option.ffprobe(parseFfprobe)
235 }) 291 })
236 } 292 }
237 293
@@ -273,39 +329,48 @@ namespace audio {
273 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel 329 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
274 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr 330 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
275 */ 331 */
276async function standard (_ffmpeg) { 332async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
277 let localFfmpeg = _ffmpeg 333 let localCommand = command
278 .format('mp4') 334 .format('mp4')
279 .videoCodec('libx264') 335 .videoCodec('libx264')
280 .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution 336 .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution
281 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it 337 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it
282 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 338 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
339 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
283 .outputOption('-map_metadata -1') // strip all metadata 340 .outputOption('-map_metadata -1') // strip all metadata
284 .outputOption('-movflags faststart') 341 .outputOption('-movflags faststart')
285 const _audio = await audio.get(localFfmpeg)
286 342
287 if (!_audio.audioStream) { 343 const parsedAudio = await audio.get(localCommand)
288 return localFfmpeg.noAudio()
289 }
290 344
291 // we favor VBR, if a good AAC encoder is available 345 if (!parsedAudio.audioStream) {
292 if ((await checkFFmpegEncoders()).get('libfdk_aac')) { 346 localCommand = localCommand.noAudio()
293 return localFfmpeg 347 } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available
348 localCommand = localCommand
294 .audioCodec('libfdk_aac') 349 .audioCodec('libfdk_aac')
295 .audioQuality(5) 350 .audioQuality(5)
351 } else {
352 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates
353 // of course this is far from perfect, but it might save some space in the end
354 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
355 let bitrate: number
356 if (audio.bitrate[ audioCodecName ]) {
357 localCommand = localCommand.audioCodec('aac')
358
359 bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
360 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
361 }
296 } 362 }
297 363
298 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates 364 // Constrained Encoding (VBV)
299 // of course this is far from perfect, but it might save some space in the end 365 // https://slhck.info/video/2017/03/01/rate-control.html
300 const audioCodecName = _audio.audioStream['codec_name'] 366 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
301 let bitrate: number 367 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
302 if (audio.bitrate[audioCodecName]) { 368 localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`])
303 bitrate = audio.bitrate[audioCodecName](_audio.audioStream['bit_rate'])
304
305 if (bitrate === -1) return localFfmpeg.audioCodec('copy')
306 }
307 369
308 if (bitrate !== undefined) return localFfmpeg.audioBitrate(bitrate) 370 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
371 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
372 // https://superuser.com/a/908325
373 localCommand = localCommand.outputOption(`-g ${ fps * 2 }`)
309 374
310 return localFfmpeg 375 return localCommand
311} 376}
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index 3eaa674ed..e43ea3f1d 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -1,13 +1,26 @@
1import 'multer' 1import 'multer'
2import * as sharp from 'sharp' 2import * as sharp from 'sharp'
3import { remove } from 'fs-extra' 3import { readFile, remove } from 'fs-extra'
4import { logger } from './logger'
4 5
5async function processImage ( 6async function processImage (
6 physicalFile: { path: string }, 7 physicalFile: { path: string },
7 destination: string, 8 destination: string,
8 newSize: { width: number, height: number } 9 newSize: { width: number, height: number }
9) { 10) {
10 await sharp(physicalFile.path) 11 if (physicalFile.path === destination) {
12 throw new Error('Sharp needs an input path different that the output path.')
13 }
14
15 logger.debug('Processing image %s to %s.', physicalFile.path, destination)
16
17 // Avoid sharp cache
18 const buf = await readFile(physicalFile.path)
19 const sharpInstance = sharp(buf)
20
21 await remove(destination)
22
23 await sharpInstance
11 .resize(newSize.width, newSize.height) 24 .resize(newSize.width, newSize.height)
12 .toFile(destination) 25 .toFile(destination)
13 26
diff --git a/server/helpers/peertube-crypto.ts b/server/helpers/peertube-crypto.ts
index 5c182961d..ab9ec077e 100644
--- a/server/helpers/peertube-crypto.ts
+++ b/server/helpers/peertube-crypto.ts
@@ -1,8 +1,14 @@
1import { BCRYPT_SALT_SIZE, PRIVATE_RSA_KEY_SIZE } from '../initializers' 1import { Request } from 'express'
2import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers'
2import { ActorModel } from '../models/activitypub/actor' 3import { ActorModel } from '../models/activitypub/actor'
3import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey } from './core-utils' 4import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey, sha256 } from './core-utils'
4import { jsig } from './custom-jsonld-signature' 5import { jsig, jsonld } from './custom-jsonld-signature'
5import { logger } from './logger' 6import { logger } from './logger'
7import { cloneDeep } from 'lodash'
8import { createVerify } from 'crypto'
9import { buildDigest } from '../lib/job-queue/handlers/utils/activitypub-http-utils'
10
11const httpSignature = require('http-signature')
6 12
7async function createPrivateAndPublicKeys () { 13async function createPrivateAndPublicKeys () {
8 logger.info('Generating a RSA key...') 14 logger.info('Generating a RSA key...')
@@ -13,18 +19,57 @@ async function createPrivateAndPublicKeys () {
13 return { privateKey: key, publicKey } 19 return { privateKey: key, publicKey }
14} 20}
15 21
16function isSignatureVerified (fromActor: ActorModel, signedDocument: object) { 22// User password checks
23
24function comparePassword (plainPassword: string, hashPassword: string) {
25 return bcryptComparePromise(plainPassword, hashPassword)
26}
27
28async function cryptPassword (password: string) {
29 const salt = await bcryptGenSaltPromise(BCRYPT_SALT_SIZE)
30
31 return bcryptHashPromise(password, salt)
32}
33
34// HTTP Signature
35
36function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
37 if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
38 return buildDigest(rawBody.toString()) === req.headers['digest']
39 }
40
41 return true
42}
43
44function isHTTPSignatureVerified (httpSignatureParsed: any, actor: ActorModel): boolean {
45 return httpSignature.verifySignature(httpSignatureParsed, actor.publicKey) === true
46}
47
48function parseHTTPSignature (req: Request, clockSkew?: number) {
49 return httpSignature.parse(req, { authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, clockSkew })
50}
51
52// JSONLD
53
54async function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any): Promise<boolean> {
55 if (signedDocument.signature.type === 'RsaSignature2017') {
56 // Mastodon algorithm
57 const res = await isJsonLDRSA2017Verified(fromActor, signedDocument)
58 // Success? If no, try with our library
59 if (res === true) return true
60 }
61
17 const publicKeyObject = { 62 const publicKeyObject = {
18 '@context': jsig.SECURITY_CONTEXT_URL, 63 '@context': jsig.SECURITY_CONTEXT_URL,
19 '@id': fromActor.url, 64 id: fromActor.url,
20 '@type': 'CryptographicKey', 65 type: 'CryptographicKey',
21 owner: fromActor.url, 66 owner: fromActor.url,
22 publicKeyPem: fromActor.publicKey 67 publicKeyPem: fromActor.publicKey
23 } 68 }
24 69
25 const publicKeyOwnerObject = { 70 const publicKeyOwnerObject = {
26 '@context': jsig.SECURITY_CONTEXT_URL, 71 '@context': jsig.SECURITY_CONTEXT_URL,
27 '@id': fromActor.url, 72 id: fromActor.url,
28 publicKey: [ publicKeyObject ] 73 publicKey: [ publicKeyObject ]
29 } 74 }
30 75
@@ -33,14 +78,54 @@ function isSignatureVerified (fromActor: ActorModel, signedDocument: object) {
33 publicKeyOwner: publicKeyOwnerObject 78 publicKeyOwner: publicKeyOwnerObject
34 } 79 }
35 80
36 return jsig.promises.verify(signedDocument, options) 81 return jsig.promises
37 .catch(err => { 82 .verify(signedDocument, options)
38 logger.error('Cannot check signature.', { err }) 83 .then((result: { verified: boolean }) => result.verified)
39 return false 84 .catch(err => {
40 }) 85 logger.error('Cannot check signature.', { err })
86 return false
87 })
88}
89
90// Backward compatibility with "other" implementations
91async function isJsonLDRSA2017Verified (fromActor: ActorModel, signedDocument: any) {
92 function hash (obj: any): Promise<any> {
93 return jsonld.promises
94 .normalize(obj, {
95 algorithm: 'URDNA2015',
96 format: 'application/n-quads'
97 })
98 .then(res => sha256(res))
99 }
100
101 const signatureCopy = cloneDeep(signedDocument.signature)
102 Object.assign(signatureCopy, {
103 '@context': [
104 'https://w3id.org/security/v1',
105 { RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' }
106 ]
107 })
108 delete signatureCopy.type
109 delete signatureCopy.id
110 delete signatureCopy.signatureValue
111
112 const docWithoutSignature = cloneDeep(signedDocument)
113 delete docWithoutSignature.signature
114
115 const [ documentHash, optionsHash ] = await Promise.all([
116 hash(docWithoutSignature),
117 hash(signatureCopy)
118 ])
119
120 const toVerify = optionsHash + documentHash
121
122 const verify = createVerify('RSA-SHA256')
123 verify.update(toVerify, 'utf8')
124
125 return verify.verify(fromActor.publicKey, signedDocument.signature.signatureValue, 'base64')
41} 126}
42 127
43function signObject (byActor: ActorModel, data: any) { 128function signJsonLDObject (byActor: ActorModel, data: any) {
44 const options = { 129 const options = {
45 privateKeyPem: byActor.privateKey, 130 privateKeyPem: byActor.privateKey,
46 creator: byActor.url, 131 creator: byActor.url,
@@ -50,22 +135,15 @@ function signObject (byActor: ActorModel, data: any) {
50 return jsig.promises.sign(data, options) 135 return jsig.promises.sign(data, options)
51} 136}
52 137
53function comparePassword (plainPassword: string, hashPassword: string) {
54 return bcryptComparePromise(plainPassword, hashPassword)
55}
56
57async function cryptPassword (password: string) {
58 const salt = await bcryptGenSaltPromise(BCRYPT_SALT_SIZE)
59
60 return bcryptHashPromise(password, salt)
61}
62
63// --------------------------------------------------------------------------- 138// ---------------------------------------------------------------------------
64 139
65export { 140export {
66 isSignatureVerified, 141 isHTTPSignatureDigestValid,
142 parseHTTPSignature,
143 isHTTPSignatureVerified,
144 isJsonLDSignatureVerified,
67 comparePassword, 145 comparePassword,
68 createPrivateAndPublicKeys, 146 createPrivateAndPublicKeys,
69 cryptPassword, 147 cryptPassword,
70 signObject 148 signJsonLDObject
71} 149}
diff --git a/server/helpers/regexp.ts b/server/helpers/regexp.ts
new file mode 100644
index 000000000..2336654b0
--- /dev/null
+++ b/server/helpers/regexp.ts
@@ -0,0 +1,23 @@
1// Thanks to https://regex101.com
2function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
3 let m: RegExpExecArray
4 let i = 0
5 let result: RegExpExecArray[] = []
6
7 // tslint:disable:no-conditional-assignment
8 while ((m = regex.exec(str)) !== null && i < maxIterations) {
9 // This is necessary to avoid infinite loops with zero-width matches
10 if (m.index === regex.lastIndex) {
11 regex.lastIndex++
12 }
13
14 result.push(m)
15 i++
16 }
17
18 return result
19}
20
21export {
22 regexpCapture
23}
diff --git a/server/helpers/requests.ts b/server/helpers/requests.ts
index ee9e80404..5c6dc5e19 100644
--- a/server/helpers/requests.ts
+++ b/server/helpers/requests.ts
@@ -1,17 +1,19 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { createWriteStream } from 'fs-extra' 2import { createWriteStream } from 'fs-extra'
3import * as request from 'request' 3import * as request from 'request'
4import { ACTIVITY_PUB } from '../initializers' 4import { ACTIVITY_PUB, CONFIG } from '../initializers'
5import { processImage } from './image-utils'
6import { join } from 'path'
5 7
6function doRequest ( 8function doRequest <T> (
7 requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean } 9 requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean }
8): Bluebird<{ response: request.RequestResponse, body: any }> { 10): Bluebird<{ response: request.RequestResponse, body: T }> {
9 if (requestOptions.activityPub === true) { 11 if (requestOptions.activityPub === true) {
10 if (!Array.isArray(requestOptions.headers)) requestOptions.headers = {} 12 if (!Array.isArray(requestOptions.headers)) requestOptions.headers = {}
11 requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER 13 requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER
12 } 14 }
13 15
14 return new Bluebird<{ response: request.RequestResponse, body: any }>((res, rej) => { 16 return new Bluebird<{ response: request.RequestResponse, body: T }>((res, rej) => {
15 request(requestOptions, (err, response, body) => err ? rej(err) : res({ response, body })) 17 request(requestOptions, (err, response, body) => err ? rej(err) : res({ response, body }))
16 }) 18 })
17} 19}
@@ -27,9 +29,18 @@ function doRequestAndSaveToFile (requestOptions: request.CoreOptions & request.U
27 }) 29 })
28} 30}
29 31
32async function downloadImage (url: string, destDir: string, destName: string, size: { width: number, height: number }) {
33 const tmpPath = join(CONFIG.STORAGE.TMP_DIR, 'pending-' + destName)
34 await doRequestAndSaveToFile({ method: 'GET', uri: url }, tmpPath)
35
36 const destPath = join(destDir, destName)
37 await processImage({ path: tmpPath }, destPath, size)
38}
39
30// --------------------------------------------------------------------------- 40// ---------------------------------------------------------------------------
31 41
32export { 42export {
33 doRequest, 43 doRequest,
34 doRequestAndSaveToFile 44 doRequestAndSaveToFile,
45 downloadImage
35} 46}
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index 6228fec04..cb0e823c5 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -19,10 +19,7 @@ async function generateRandomString (size: number) {
19 return raw.toString('hex') 19 return raw.toString('hex')
20} 20}
21 21
22interface FormattableToJSON { 22interface FormattableToJSON { toFormattedJSON (args?: any) }
23 toFormattedJSON (args?: any)
24}
25
26function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) { 23function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) {
27 const formattedObjects: U[] = [] 24 const formattedObjects: U[] = []
28 25
@@ -40,21 +37,24 @@ const getServerActor = memoizee(async function () {
40 const application = await ApplicationModel.load() 37 const application = await ApplicationModel.load()
41 if (!application) throw Error('Could not load Application from database.') 38 if (!application) throw Error('Could not load Application from database.')
42 39
43 return application.Account.Actor 40 const actor = application.Account.Actor
41 actor.Account = application.Account
42
43 return actor
44}) 44})
45 45
46function generateVideoTmpPath (target: string | ParseTorrent) { 46function generateVideoImportTmpPath (target: string | ParseTorrent) {
47 const id = typeof target === 'string' ? target : target.infoHash 47 const id = typeof target === 'string' ? target : target.infoHash
48 48
49 const hash = sha256(id) 49 const hash = sha256(id)
50 return join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4') 50 return join(CONFIG.STORAGE.TMP_DIR, hash + '-import.mp4')
51} 51}
52 52
53function getSecureTorrentName (originalName: string) { 53function getSecureTorrentName (originalName: string) {
54 return sha256(originalName) + '.torrent' 54 return sha256(originalName) + '.torrent'
55} 55}
56 56
57async function getVersion () { 57async function getServerCommit () {
58 try { 58 try {
59 const tag = await execPromise2( 59 const tag = await execPromise2(
60 '[ ! -d .git ] || git name-rev --name-only --tags --no-undefined HEAD 2>/dev/null || true', 60 '[ ! -d .git ] || git name-rev --name-only --tags --no-undefined HEAD 2>/dev/null || true',
@@ -74,7 +74,21 @@ async function getVersion () {
74 logger.debug('Cannot get version from git HEAD.', { err }) 74 logger.debug('Cannot get version from git HEAD.', { err })
75 } 75 }
76 76
77 return require('../../../package.json').version 77 return ''
78}
79
80/**
81 * From a filename like "ede4cba5-742b-46fa-a388-9a6eb3a3aeb3.mp4", returns
82 * only the "ede4cba5-742b-46fa-a388-9a6eb3a3aeb3" part. If the filename does
83 * not contain a UUID, returns null.
84 */
85function getUUIDFromFilename (filename: string) {
86 const regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
87 const result = filename.match(regex)
88
89 if (!result || Array.isArray(result) === false) return null
90
91 return result[0]
78} 92}
79 93
80// --------------------------------------------------------------------------- 94// ---------------------------------------------------------------------------
@@ -85,6 +99,7 @@ export {
85 getFormattedObjects, 99 getFormattedObjects,
86 getSecureTorrentName, 100 getSecureTorrentName,
87 getServerActor, 101 getServerActor,
88 getVersion, 102 getServerCommit,
89 generateVideoTmpPath 103 generateVideoImportTmpPath,
104 getUUIDFromFilename
90} 105}
diff --git a/server/helpers/video.ts b/server/helpers/video.ts
index 1bd21467d..c90fe06c7 100644
--- a/server/helpers/video.ts
+++ b/server/helpers/video.ts
@@ -1,10 +1,12 @@
1import { VideoModel } from '../models/video/video' 1import { VideoModel } from '../models/video/video'
2 2
3type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' 3type VideoFetchType = 'all' | 'only-video' | 'only-video-with-rights' | 'id' | 'none'
4 4
5function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) { 5function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) {
6 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId) 6 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
7 7
8 if (fetchType === 'only-video-with-rights') return VideoModel.loadWithRights(id)
9
8 if (fetchType === 'only-video') return VideoModel.load(id) 10 if (fetchType === 'only-video') return VideoModel.load(id)
9 11
10 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id) 12 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts
index ce35b87da..3c9a0b96a 100644
--- a/server/helpers/webtorrent.ts
+++ b/server/helpers/webtorrent.ts
@@ -1,5 +1,5 @@
1import { logger } from './logger' 1import { logger } from './logger'
2import { generateVideoTmpPath } from './utils' 2import { generateVideoImportTmpPath } from './utils'
3import * as WebTorrent from 'webtorrent' 3import * as WebTorrent from 'webtorrent'
4import { createWriteStream, ensureDir, remove } from 'fs-extra' 4import { createWriteStream, ensureDir, remove } from 'fs-extra'
5import { CONFIG } from '../initializers' 5import { CONFIG } from '../initializers'
@@ -9,10 +9,10 @@ async function downloadWebTorrentVideo (target: { magnetUri: string, torrentName
9 const id = target.magnetUri || target.torrentName 9 const id = target.magnetUri || target.torrentName
10 let timer 10 let timer
11 11
12 const path = generateVideoTmpPath(id) 12 const path = generateVideoImportTmpPath(id)
13 logger.info('Importing torrent video %s', id) 13 logger.info('Importing torrent video %s', id)
14 14
15 const directoryPath = join(CONFIG.STORAGE.VIDEOS_DIR, 'import') 15 const directoryPath = join(CONFIG.STORAGE.TMP_DIR, 'webtorrent')
16 await ensureDir(directoryPath) 16 await ensureDir(directoryPath)
17 17
18 return new Promise<string>((res, rej) => { 18 return new Promise<string>((res, rej) => {
diff --git a/server/helpers/youtube-dl.ts b/server/helpers/youtube-dl.ts
index 70b4e1b78..b74351b42 100644
--- a/server/helpers/youtube-dl.ts
+++ b/server/helpers/youtube-dl.ts
@@ -1,7 +1,7 @@
1import { truncate } from 'lodash' 1import { truncate } from 'lodash'
2import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers' 2import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers'
3import { logger } from './logger' 3import { logger } from './logger'
4import { generateVideoTmpPath } from './utils' 4import { generateVideoImportTmpPath } from './utils'
5import { join } from 'path' 5import { join } from 'path'
6import { root } from './core-utils' 6import { root } from './core-utils'
7import { ensureDir, writeFile, remove } from 'fs-extra' 7import { ensureDir, writeFile, remove } from 'fs-extra'
@@ -24,10 +24,10 @@ const processOptions = {
24 24
25function getYoutubeDLInfo (url: string, opts?: string[]): Promise<YoutubeDLInfo> { 25function getYoutubeDLInfo (url: string, opts?: string[]): Promise<YoutubeDLInfo> {
26 return new Promise<YoutubeDLInfo>(async (res, rej) => { 26 return new Promise<YoutubeDLInfo>(async (res, rej) => {
27 const options = opts || [ '-j', '--flat-playlist' ] 27 const args = opts || [ '-j', '--flat-playlist' ]
28 28
29 const youtubeDL = await safeGetYoutubeDL() 29 const youtubeDL = await safeGetYoutubeDL()
30 youtubeDL.getInfo(url, options, (err, info) => { 30 youtubeDL.getInfo(url, args, processOptions, (err, info) => {
31 if (err) return rej(err) 31 if (err) return rej(err)
32 if (info.is_live === true) return rej(new Error('Cannot download a live streaming.')) 32 if (info.is_live === true) return rej(new Error('Cannot download a live streaming.'))
33 33
@@ -40,7 +40,7 @@ function getYoutubeDLInfo (url: string, opts?: string[]): Promise<YoutubeDLInfo>
40} 40}
41 41
42function downloadYoutubeDLVideo (url: string, timeout: number) { 42function downloadYoutubeDLVideo (url: string, timeout: number) {
43 const path = generateVideoTmpPath(url) 43 const path = generateVideoImportTmpPath(url)
44 let timer 44 let timer
45 45
46 logger.info('Importing youtubeDL video %s', url) 46 logger.info('Importing youtubeDL video %s', url)
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index 72d846957..955d55206 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -10,6 +10,7 @@ import { getServerActor } from '../helpers/utils'
10import { RecentlyAddedStrategy } from '../../shared/models/redundancy' 10import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
11import { isArray } from '../helpers/custom-validators/misc' 11import { isArray } from '../helpers/custom-validators/misc'
12import { uniq } from 'lodash' 12import { uniq } from 'lodash'
13import { Emailer } from '../lib/emailer'
13 14
14async function checkActivityPubUrls () { 15async function checkActivityPubUrls () {
15 const actor = await getServerActor() 16 const actor = await getServerActor()
@@ -32,9 +33,19 @@ async function checkActivityPubUrls () {
32// Some checks on configuration files 33// Some checks on configuration files
33// Return an error message, or null if everything is okay 34// Return an error message, or null if everything is okay
34function checkConfig () { 35function checkConfig () {
35 const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY 36
37 if (!Emailer.isEnabled()) {
38 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
39 return 'Emailer is disabled but you require signup email verification.'
40 }
41
42 if (CONFIG.CONTACT_FORM.ENABLED) {
43 logger.warn('Emailer is disabled so the contact form will not work.')
44 }
45 }
36 46
37 // NSFW policy 47 // NSFW policy
48 const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
38 { 49 {
39 const available = [ 'do_not_list', 'blur', 'display' ] 50 const available = [ 'do_not_list', 'blur', 'display' ]
40 if (available.indexOf(defaultNSFWPolicy) === -1) { 51 if (available.indexOf(defaultNSFWPolicy) === -1) {
@@ -68,6 +79,7 @@ function checkConfig () {
68 } 79 }
69 } 80 }
70 81
82 // Check storage directory locations
71 if (isProdInstance()) { 83 if (isProdInstance()) {
72 const configStorage = config.get('storage') 84 const configStorage = config.get('storage')
73 for (const key of Object.keys(configStorage)) { 85 for (const key of Object.keys(configStorage)) {
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 4f46d406a..29fdb263e 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -12,13 +12,14 @@ function checkMissedConfig () {
12 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'database.pool.max', 12 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'database.pool.max',
13 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 13 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
14 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache', 14 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache',
15 'storage.redundancy', 'storage.tmp', 'storage.playlists',
15 'log.level', 16 'log.level',
16 'user.video_quota', 'user.video_quota_daily', 17 'user.video_quota', 'user.video_quota_daily',
17 'cache.previews.size', 'admin.email', 18 'cache.previews.size', 'admin.email', 'contact_form.enabled',
18 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 19 'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
19 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 20 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
20 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 21 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
21 'transcoding.enabled', 'transcoding.threads', 22 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions',
22 'import.videos.http.enabled', 'import.videos.torrent.enabled', 23 'import.videos.http.enabled', 'import.videos.torrent.enabled',
23 'trending.videos.interval_days', 24 'trending.videos.interval_days',
24 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', 25 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
@@ -77,7 +78,7 @@ async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) {
77 } 78 }
78 } 79 }
79 80
80 checkFFmpegEncoders() 81 return checkFFmpegEncoders()
81} 82}
82 83
83// Optional encoders, if present, can be used to improve transcoding 84// Optional encoders, if present, can be used to improve transcoding
@@ -95,10 +96,10 @@ async function checkFFmpegEncoders (): Promise<Map<string, boolean>> {
95 supportedOptionalEncoders = new Map<string, boolean>() 96 supportedOptionalEncoders = new Map<string, boolean>()
96 97
97 for (const encoder of optionalEncoders) { 98 for (const encoder of optionalEncoders) {
98 supportedOptionalEncoders.set(encoder, 99 supportedOptionalEncoders.set(encoder, encoders[encoder] !== undefined)
99 encoders[encoder] !== undefined
100 )
101 } 100 }
101
102 return supportedOptionalEncoders
102} 103}
103 104
104// --------------------------------------------------------------------------- 105// ---------------------------------------------------------------------------
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 1a3b52015..e5c4c4e63 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -3,9 +3,9 @@ import { dirname, join } from 'path'
3import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models' 3import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models'
4import { ActivityPubActorType } from '../../shared/models/activitypub' 4import { ActivityPubActorType } from '../../shared/models/activitypub'
5import { FollowState } from '../../shared/models/actors' 5import { FollowState } from '../../shared/models/actors'
6import { VideoAbuseState, VideoImportState, VideoPrivacy } from '../../shared/models/videos' 6import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos'
7// Do not use barrels, remain constants as independent as possible 7// Do not use barrels, remain constants as independent as possible
8import { buildPath, isTestInstance, parseDuration, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' 8import { buildPath, isTestInstance, parseDuration, parseBytes, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
10import { invert } from 'lodash' 10import { invert } from 'lodash'
11import { CronRepeatOptions, EveryRepeatOptions } from 'bull' 11import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
@@ -16,7 +16,7 @@ let config: IConfig = require('config')
16 16
17// --------------------------------------------------------------------------- 17// ---------------------------------------------------------------------------
18 18
19const LAST_MIGRATION_VERSION = 275 19const LAST_MIGRATION_VERSION = 330
20 20
21// --------------------------------------------------------------------------- 21// ---------------------------------------------------------------------------
22 22
@@ -47,7 +47,12 @@ const SORTABLE_COLUMNS = {
47 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'trending' ], 47 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'trending' ],
48 48
49 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'match' ], 49 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'match' ],
50 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ] 50 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
51
52 ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
53 SERVERS_BLOCKLIST: [ 'createdAt' ],
54
55 USER_NOTIFICATIONS: [ 'createdAt' ]
51} 56}
52 57
53const OAUTH_LIFETIME = { 58const OAUTH_LIFETIME = {
@@ -58,6 +63,7 @@ const OAUTH_LIFETIME = {
58const ROUTE_CACHE_LIFETIME = { 63const ROUTE_CACHE_LIFETIME = {
59 FEEDS: '15 minutes', 64 FEEDS: '15 minutes',
60 ROBOTS: '2 hours', 65 ROBOTS: '2 hours',
66 SITEMAP: '1 day',
61 SECURITYTXT: '2 hours', 67 SECURITYTXT: '2 hours',
62 NODEINFO: '10 minutes', 68 NODEINFO: '10 minutes',
63 DNT_POLICY: '1 week', 69 DNT_POLICY: '1 week',
@@ -99,7 +105,8 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = {
99 'video-file': 1, 105 'video-file': 1,
100 'video-import': 1, 106 'video-import': 1,
101 'email': 5, 107 'email': 5,
102 'videos-views': 1 108 'videos-views': 1,
109 'activitypub-refresher': 1
103} 110}
104const JOB_CONCURRENCY: { [ id in JobType ]: number } = { 111const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
105 'activitypub-http-broadcast': 1, 112 'activitypub-http-broadcast': 1,
@@ -110,7 +117,8 @@ const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
110 'video-file': 1, 117 'video-file': 1,
111 'video-import': 1, 118 'video-import': 1,
112 'email': 5, 119 'email': 5,
113 'videos-views': 1 120 'videos-views': 1,
121 'activitypub-refresher': 1
114} 122}
115const JOB_TTL: { [ id in JobType ]: number } = { 123const JOB_TTL: { [ id in JobType ]: number } = {
116 'activitypub-http-broadcast': 60000 * 10, // 10 minutes 124 'activitypub-http-broadcast': 60000 * 10, // 10 minutes
@@ -121,11 +129,12 @@ const JOB_TTL: { [ id in JobType ]: number } = {
121 'video-file': 1000 * 3600 * 48, // 2 days, transcoding could be long 129 'video-file': 1000 * 3600 * 48, // 2 days, transcoding could be long
122 'video-import': 1000 * 3600 * 2, // hours 130 'video-import': 1000 * 3600 * 2, // hours
123 'email': 60000 * 10, // 10 minutes 131 'email': 60000 * 10, // 10 minutes
124 'videos-views': undefined // Unlimited 132 'videos-views': undefined, // Unlimited
133 'activitypub-refresher': 60000 * 10 // 10 minutes
125} 134}
126const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = { 135const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = {
127 'videos-views': { 136 'videos-views': {
128 cron: '1 * * * *' // At 1 minutes past the hour 137 cron: '1 * * * *' // At 1 minute past the hour
129 } 138 }
130} 139}
131 140
@@ -137,7 +146,7 @@ const VIDEO_IMPORT_TIMEOUT = 1000 * 3600 // 1 hour
137 146
138// 1 hour 147// 1 hour
139let SCHEDULER_INTERVALS_MS = { 148let SCHEDULER_INTERVALS_MS = {
140 badActorFollow: 60000 * 60, // 1 hour 149 actorFollowScores: 60000 * 60, // 1 hour
141 removeOldJobs: 60000 * 60, // 1 hour 150 removeOldJobs: 60000 * 60, // 1 hour
142 updateVideos: 60000, // 1 minute 151 updateVideos: 60000, // 1 minute
143 youtubeDLUpdate: 60000 * 60 * 24 // 1 day 152 youtubeDLUpdate: 60000 * 60 * 24 // 1 day
@@ -179,9 +188,12 @@ const CONFIG = {
179 FROM_ADDRESS: config.get<string>('smtp.from_address') 188 FROM_ADDRESS: config.get<string>('smtp.from_address')
180 }, 189 },
181 STORAGE: { 190 STORAGE: {
191 TMP_DIR: buildPath(config.get<string>('storage.tmp')),
182 AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), 192 AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
183 LOG_DIR: buildPath(config.get<string>('storage.logs')), 193 LOG_DIR: buildPath(config.get<string>('storage.logs')),
184 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')), 194 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
195 PLAYLISTS_DIR: buildPath(config.get<string>('storage.playlists')),
196 REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
185 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), 197 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
186 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), 198 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
187 CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')), 199 CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
@@ -220,6 +232,9 @@ const CONFIG = {
220 ADMIN: { 232 ADMIN: {
221 get EMAIL () { return config.get<string>('admin.email') } 233 get EMAIL () { return config.get<string>('admin.email') }
222 }, 234 },
235 CONTACT_FORM: {
236 get ENABLED () { return config.get<boolean>('contact_form.enabled') }
237 },
223 SIGNUP: { 238 SIGNUP: {
224 get ENABLED () { return config.get<boolean>('signup.enabled') }, 239 get ENABLED () { return config.get<boolean>('signup.enabled') },
225 get LIMIT () { return config.get<number>('signup.limit') }, 240 get LIMIT () { return config.get<number>('signup.limit') },
@@ -232,11 +247,12 @@ const CONFIG = {
232 } 247 }
233 }, 248 },
234 USER: { 249 USER: {
235 get VIDEO_QUOTA () { return config.get<number>('user.video_quota') }, 250 get VIDEO_QUOTA () { return parseBytes(config.get<number>('user.video_quota')) },
236 get VIDEO_QUOTA_DAILY () { return config.get<number>('user.video_quota_daily') } 251 get VIDEO_QUOTA_DAILY () { return parseBytes(config.get<number>('user.video_quota_daily')) }
237 }, 252 },
238 TRANSCODING: { 253 TRANSCODING: {
239 get ENABLED () { return config.get<boolean>('transcoding.enabled') }, 254 get ENABLED () { return config.get<boolean>('transcoding.enabled') },
255 get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') },
240 get THREADS () { return config.get<number>('transcoding.threads') }, 256 get THREADS () { return config.get<number>('transcoding.threads') },
241 RESOLUTIONS: { 257 RESOLUTIONS: {
242 get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') }, 258 get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') },
@@ -244,6 +260,9 @@ const CONFIG = {
244 get '480p' () { return config.get<boolean>('transcoding.resolutions.480p') }, 260 get '480p' () { return config.get<boolean>('transcoding.resolutions.480p') },
245 get '720p' () { return config.get<boolean>('transcoding.resolutions.720p') }, 261 get '720p' () { return config.get<boolean>('transcoding.resolutions.720p') },
246 get '1080p' () { return config.get<boolean>('transcoding.resolutions.1080p') } 262 get '1080p' () { return config.get<boolean>('transcoding.resolutions.1080p') }
263 },
264 HLS: {
265 get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') }
247 } 266 }
248 }, 267 },
249 IMPORT: { 268 IMPORT: {
@@ -280,6 +299,7 @@ const CONFIG = {
280 get SECURITYTXT_CONTACT () { return config.get<string>('admin.email') } 299 get SECURITYTXT_CONTACT () { return config.get<string>('admin.email') }
281 }, 300 },
282 SERVICES: { 301 SERVICES: {
302 get 'CSP-LOGGER' () { return config.get<string>('services.csp-logger') },
283 TWITTER: { 303 TWITTER: {
284 get USERNAME () { return config.get<string>('services.twitter.username') }, 304 get USERNAME () { return config.get<string>('services.twitter.username') },
285 get WHITELISTED () { return config.get<boolean>('services.twitter.whitelisted') } 305 get WHITELISTED () { return config.get<boolean>('services.twitter.whitelisted') }
@@ -289,27 +309,27 @@ const CONFIG = {
289 309
290// --------------------------------------------------------------------------- 310// ---------------------------------------------------------------------------
291 311
292const CONSTRAINTS_FIELDS = { 312let CONSTRAINTS_FIELDS = {
293 USERS: { 313 USERS: {
294 NAME: { min: 3, max: 120 }, // Length 314 NAME: { min: 1, max: 120 }, // Length
295 DESCRIPTION: { min: 3, max: 250 }, // Length 315 DESCRIPTION: { min: 3, max: 1000 }, // Length
296 USERNAME: { min: 3, max: 20 }, // Length 316 USERNAME: { min: 1, max: 50 }, // Length
297 PASSWORD: { min: 6, max: 255 }, // Length 317 PASSWORD: { min: 6, max: 255 }, // Length
298 VIDEO_QUOTA: { min: -1 }, 318 VIDEO_QUOTA: { min: -1 },
299 VIDEO_QUOTA_DAILY: { min: -1 }, 319 VIDEO_QUOTA_DAILY: { min: -1 },
300 BLOCKED_REASON: { min: 3, max: 250 } // Length 320 BLOCKED_REASON: { min: 3, max: 250 } // Length
301 }, 321 },
302 VIDEO_ABUSES: { 322 VIDEO_ABUSES: {
303 REASON: { min: 2, max: 300 }, // Length 323 REASON: { min: 2, max: 3000 }, // Length
304 MODERATION_COMMENT: { min: 2, max: 300 } // Length 324 MODERATION_COMMENT: { min: 2, max: 3000 } // Length
305 }, 325 },
306 VIDEO_BLACKLIST: { 326 VIDEO_BLACKLIST: {
307 REASON: { min: 2, max: 300 } // Length 327 REASON: { min: 2, max: 300 } // Length
308 }, 328 },
309 VIDEO_CHANNELS: { 329 VIDEO_CHANNELS: {
310 NAME: { min: 3, max: 120 }, // Length 330 NAME: { min: 1, max: 120 }, // Length
311 DESCRIPTION: { min: 3, max: 500 }, // Length 331 DESCRIPTION: { min: 3, max: 1000 }, // Length
312 SUPPORT: { min: 3, max: 500 }, // Length 332 SUPPORT: { min: 3, max: 1000 }, // Length
313 URL: { min: 3, max: 2000 } // Length 333 URL: { min: 3, max: 2000 } // Length
314 }, 334 },
315 VIDEO_CAPTIONS: { 335 VIDEO_CAPTIONS: {
@@ -333,19 +353,22 @@ const CONSTRAINTS_FIELDS = {
333 VIDEOS_REDUNDANCY: { 353 VIDEOS_REDUNDANCY: {
334 URL: { min: 3, max: 2000 } // Length 354 URL: { min: 3, max: 2000 } // Length
335 }, 355 },
356 VIDEO_RATES: {
357 URL: { min: 3, max: 2000 } // Length
358 },
336 VIDEOS: { 359 VIDEOS: {
337 NAME: { min: 3, max: 120 }, // Length 360 NAME: { min: 3, max: 120 }, // Length
338 LANGUAGE: { min: 1, max: 10 }, // Length 361 LANGUAGE: { min: 1, max: 10 }, // Length
339 TRUNCATED_DESCRIPTION: { min: 3, max: 250 }, // Length 362 TRUNCATED_DESCRIPTION: { min: 3, max: 250 }, // Length
340 DESCRIPTION: { min: 3, max: 10000 }, // Length 363 DESCRIPTION: { min: 3, max: 10000 }, // Length
341 SUPPORT: { min: 3, max: 500 }, // Length 364 SUPPORT: { min: 3, max: 1000 }, // Length
342 IMAGE: { 365 IMAGE: {
343 EXTNAME: [ '.jpg', '.jpeg' ], 366 EXTNAME: [ '.jpg', '.jpeg' ],
344 FILE_SIZE: { 367 FILE_SIZE: {
345 max: 2 * 1024 * 1024 // 2MB 368 max: 2 * 1024 * 1024 // 2MB
346 } 369 }
347 }, 370 },
348 EXTNAME: [ '.mp4', '.ogv', '.webm' ], 371 EXTNAME: buildVideosExtname(),
349 INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2 372 INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2
350 DURATION: { min: 0 }, // Number 373 DURATION: { min: 0 }, // Number
351 TAGS: { min: 0, max: 5 }, // Number of total tags 374 TAGS: { min: 0, max: 5 }, // Number of total tags
@@ -378,6 +401,10 @@ const CONSTRAINTS_FIELDS = {
378 }, 401 },
379 VIDEO_SHARE: { 402 VIDEO_SHARE: {
380 URL: { min: 3, max: 2000 } // Length 403 URL: { min: 3, max: 2000 } // Length
404 },
405 CONTACT_FORM: {
406 FROM_NAME: { min: 1, max: 120 }, // Length
407 BODY: { min: 3, max: 5000 } // Length
381 } 408 }
382} 409}
383 410
@@ -393,7 +420,9 @@ const RATES_LIMIT = {
393} 420}
394 421
395let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour 422let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour
396const VIDEO_TRANSCODING_FPS = { 423let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
424
425const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
397 MIN: 10, 426 MIN: 10,
398 AVERAGE: 30, 427 AVERAGE: 30,
399 MAX: 60, 428 MAX: 60,
@@ -421,7 +450,7 @@ const VIDEO_CATEGORIES = {
421 8: 'People', 450 8: 'People',
422 9: 'Comedy', 451 9: 'Comedy',
423 10: 'Entertainment', 452 10: 'Entertainment',
424 11: 'News', 453 11: 'News & Politics',
425 12: 'How To', 454 12: 'How To',
426 13: 'Education', 455 13: 'Education',
427 14: 'Activism', 456 14: 'Activism',
@@ -468,27 +497,31 @@ const VIDEO_ABUSE_STATES = {
468 [VideoAbuseState.ACCEPTED]: 'Accepted' 497 [VideoAbuseState.ACCEPTED]: 'Accepted'
469} 498}
470 499
471const VIDEO_MIMETYPE_EXT = { 500const MIMETYPES = {
472 'video/webm': '.webm', 501 VIDEO: {
473 'video/ogg': '.ogv', 502 MIMETYPE_EXT: buildVideoMimetypeExt(),
474 'video/mp4': '.mp4' 503 EXT_MIMETYPE: null as { [ id: string ]: string }
475} 504 },
476const VIDEO_EXT_MIMETYPE = invert(VIDEO_MIMETYPE_EXT) 505 IMAGE: {
477 506 MIMETYPE_EXT: {
478const IMAGE_MIMETYPE_EXT = { 507 'image/png': '.png',
479 'image/png': '.png', 508 'image/jpg': '.jpg',
480 'image/jpg': '.jpg', 509 'image/jpeg': '.jpg'
481 'image/jpeg': '.jpg' 510 }
482} 511 },
483 512 VIDEO_CAPTIONS: {
484const VIDEO_CAPTIONS_MIMETYPE_EXT = { 513 MIMETYPE_EXT: {
485 'text/vtt': '.vtt', 514 'text/vtt': '.vtt',
486 'application/x-subrip': '.srt' 515 'application/x-subrip': '.srt'
487} 516 }
488 517 },
489const TORRENT_MIMETYPE_EXT = { 518 TORRENT: {
490 'application/x-bittorrent': '.torrent' 519 MIMETYPE_EXT: {
520 'application/x-bittorrent': '.torrent'
521 }
522 }
491} 523}
524MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
492 525
493// --------------------------------------------------------------------------- 526// ---------------------------------------------------------------------------
494 527
@@ -514,7 +547,7 @@ const ACTIVITY_PUB = {
514 COLLECTION_ITEMS_PER_PAGE: 10, 547 COLLECTION_ITEMS_PER_PAGE: 10,
515 FETCH_PAGE_LIMIT: 100, 548 FETCH_PAGE_LIMIT: 100,
516 URL_MIME_TYPES: { 549 URL_MIME_TYPES: {
517 VIDEO: Object.keys(VIDEO_MIMETYPE_EXT), 550 VIDEO: Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT),
518 TORRENT: [ 'application/x-bittorrent' ], 551 TORRENT: [ 'application/x-bittorrent' ],
519 MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ] 552 MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ]
520 }, 553 },
@@ -529,9 +562,15 @@ const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = {
529 APPLICATION: 'Application' 562 APPLICATION: 'Application'
530} 563}
531 564
565const HTTP_SIGNATURE = {
566 HEADER_NAME: 'signature',
567 ALGORITHM: 'rsa-sha256',
568 HEADERS_TO_SIGN: [ '(request-target)', 'host', 'date', 'digest' ]
569}
570
532// --------------------------------------------------------------------------- 571// ---------------------------------------------------------------------------
533 572
534const PRIVATE_RSA_KEY_SIZE = 2048 573let PRIVATE_RSA_KEY_SIZE = 2048
535 574
536// Password encryption 575// Password encryption
537const BCRYPT_SALT_SIZE = 10 576const BCRYPT_SALT_SIZE = 10
@@ -554,6 +593,10 @@ const STATIC_PATHS = {
554 THUMBNAILS: '/static/thumbnails/', 593 THUMBNAILS: '/static/thumbnails/',
555 TORRENTS: '/static/torrents/', 594 TORRENTS: '/static/torrents/',
556 WEBSEED: '/static/webseed/', 595 WEBSEED: '/static/webseed/',
596 REDUNDANCY: '/static/redundancy/',
597 PLAYLISTS: {
598 HLS: '/static/playlists/hls'
599 },
557 AVATARS: '/static/avatars/', 600 AVATARS: '/static/avatars/',
558 VIDEO_CAPTIONS: '/static/video-captions/' 601 VIDEO_CAPTIONS: '/static/video-captions/'
559} 602}
@@ -596,6 +639,9 @@ const CACHE = {
596 } 639 }
597} 640}
598 641
642const HLS_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.PLAYLISTS_DIR, 'hls')
643const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
644
599const MEMOIZE_TTL = { 645const MEMOIZE_TTL = {
600 OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours 646 OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours
601} 647}
@@ -635,6 +681,8 @@ const TRACKER_RATE_LIMITS = {
635 681
636// Special constants for a test instance 682// Special constants for a test instance
637if (isTestInstance() === true) { 683if (isTestInstance() === true) {
684 PRIVATE_RSA_KEY_SIZE = 1024
685
638 ACTOR_FOLLOW_SCORE.BASE = 20 686 ACTOR_FOLLOW_SCORE.BASE = 20
639 687
640 REMOTE_SCHEME.HTTP = 'http' 688 REMOTE_SCHEME.HTTP = 'http'
@@ -648,7 +696,7 @@ if (isTestInstance() === true) {
648 696
649 CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB 697 CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
650 698
651 SCHEDULER_INTERVALS_MS.badActorFollow = 10000 699 SCHEDULER_INTERVALS_MS.actorFollowScores = 1000
652 SCHEDULER_INTERVALS_MS.removeOldJobs = 10000 700 SCHEDULER_INTERVALS_MS.removeOldJobs = 10000
653 SCHEDULER_INTERVALS_MS.updateVideos = 5000 701 SCHEDULER_INTERVALS_MS.updateVideos = 5000
654 REPEAT_JOBS['videos-views'] = { every: 5000 } 702 REPEAT_JOBS['videos-views'] = { every: 5000 }
@@ -656,21 +704,24 @@ if (isTestInstance() === true) {
656 REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1 704 REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1
657 705
658 VIDEO_VIEW_LIFETIME = 1000 // 1 second 706 VIDEO_VIEW_LIFETIME = 1000 // 1 second
707 CONTACT_FORM_LIFETIME = 1000 // 1 second
659 708
660 JOB_ATTEMPTS['email'] = 1 709 JOB_ATTEMPTS['email'] = 1
661 710
662 CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000 711 CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
663 MEMOIZE_TTL.OVERVIEWS_SAMPLE = 1 712 MEMOIZE_TTL.OVERVIEWS_SAMPLE = 1
664 ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms' 713 ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms'
714
715 RATES_LIMIT.LOGIN.MAX = 20
665} 716}
666 717
667updateWebserverConfig() 718updateWebserverUrls()
668 719
669// --------------------------------------------------------------------------- 720// ---------------------------------------------------------------------------
670 721
671export { 722export {
672 API_VERSION, 723 API_VERSION,
673 VIDEO_CAPTIONS_MIMETYPE_EXT, 724 HLS_REDUNDANCY_DIRECTORY,
674 AVATARS_SIZE, 725 AVATARS_SIZE,
675 ACCEPT_HEADERS, 726 ACCEPT_HEADERS,
676 BCRYPT_SALT_SIZE, 727 BCRYPT_SALT_SIZE,
@@ -695,10 +746,10 @@ export {
695 PRIVATE_RSA_KEY_SIZE, 746 PRIVATE_RSA_KEY_SIZE,
696 ROUTE_CACHE_LIFETIME, 747 ROUTE_CACHE_LIFETIME,
697 SORTABLE_COLUMNS, 748 SORTABLE_COLUMNS,
749 HLS_PLAYLIST_DIRECTORY,
698 FEEDS, 750 FEEDS,
699 JOB_TTL, 751 JOB_TTL,
700 NSFW_POLICY_TYPES, 752 NSFW_POLICY_TYPES,
701 TORRENT_MIMETYPE_EXT,
702 STATIC_MAX_AGE, 753 STATIC_MAX_AGE,
703 STATIC_PATHS, 754 STATIC_PATHS,
704 VIDEO_IMPORT_TIMEOUT, 755 VIDEO_IMPORT_TIMEOUT,
@@ -711,7 +762,6 @@ export {
711 VIDEO_LICENCES, 762 VIDEO_LICENCES,
712 VIDEO_STATES, 763 VIDEO_STATES,
713 VIDEO_RATE_TYPES, 764 VIDEO_RATE_TYPES,
714 VIDEO_MIMETYPE_EXT,
715 VIDEO_TRANSCODING_FPS, 765 VIDEO_TRANSCODING_FPS,
716 FFMPEG_NICE, 766 FFMPEG_NICE,
717 VIDEO_ABUSE_STATES, 767 VIDEO_ABUSE_STATES,
@@ -719,17 +769,18 @@ export {
719 USER_PASSWORD_RESET_LIFETIME, 769 USER_PASSWORD_RESET_LIFETIME,
720 MEMOIZE_TTL, 770 MEMOIZE_TTL,
721 USER_EMAIL_VERIFY_LIFETIME, 771 USER_EMAIL_VERIFY_LIFETIME,
722 IMAGE_MIMETYPE_EXT,
723 OVERVIEWS, 772 OVERVIEWS,
724 SCHEDULER_INTERVALS_MS, 773 SCHEDULER_INTERVALS_MS,
725 REPEAT_JOBS, 774 REPEAT_JOBS,
726 STATIC_DOWNLOAD_PATHS, 775 STATIC_DOWNLOAD_PATHS,
727 RATES_LIMIT, 776 RATES_LIMIT,
728 VIDEO_EXT_MIMETYPE, 777 MIMETYPES,
729 CRAWL_REQUEST_CONCURRENCY, 778 CRAWL_REQUEST_CONCURRENCY,
730 JOB_COMPLETED_LIFETIME, 779 JOB_COMPLETED_LIFETIME,
780 HTTP_SIGNATURE,
731 VIDEO_IMPORT_STATES, 781 VIDEO_IMPORT_STATES,
732 VIDEO_VIEW_LIFETIME, 782 VIDEO_VIEW_LIFETIME,
783 CONTACT_FORM_LIFETIME,
733 buildLanguages 784 buildLanguages
734} 785}
735 786
@@ -746,16 +797,50 @@ function getLocalConfigFilePath () {
746 return join(dirname(configSources[ 0 ].name), filename + '.json') 797 return join(dirname(configSources[ 0 ].name), filename + '.json')
747} 798}
748 799
749function updateWebserverConfig () { 800function buildVideoMimetypeExt () {
801 const data = {
802 'video/webm': '.webm',
803 'video/ogg': '.ogv',
804 'video/mp4': '.mp4'
805 }
806
807 if (CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) {
808 Object.assign(data, {
809 'video/quicktime': '.mov',
810 'video/x-msvideo': '.avi',
811 'video/x-flv': '.flv',
812 'video/x-matroska': '.mkv',
813 'application/octet-stream': '.mkv',
814 'video/avi': '.avi'
815 })
816 }
817
818 return data
819}
820
821function updateWebserverUrls () {
750 CONFIG.WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT) 822 CONFIG.WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT)
751 CONFIG.WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP) 823 CONFIG.WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
752} 824}
753 825
826function updateWebserverConfig () {
827 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname()
828
829 MIMETYPES.VIDEO.MIMETYPE_EXT = buildVideoMimetypeExt()
830 MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
831}
832
833function buildVideosExtname () {
834 return CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS
835 ? [ '.mp4', '.ogv', '.webm', '.mkv', '.mov', '.avi', '.flv' ]
836 : [ '.mp4', '.ogv', '.webm' ]
837}
838
754function buildVideosRedundancy (objs: any[]): VideosRedundancy[] { 839function buildVideosRedundancy (objs: any[]): VideosRedundancy[] {
755 if (!objs) return [] 840 if (!objs) return []
756 841
757 return objs.map(obj => { 842 return objs.map(obj => {
758 return Object.assign(obj, { 843 return Object.assign({}, obj, {
759 minLifetime: parseDuration(obj.min_lifetime), 844 minLifetime: parseDuration(obj.min_lifetime),
760 size: bytes.parse(obj.size), 845 size: bytes.parse(obj.size),
761 minViews: obj.min_views 846 minViews: obj.min_views
@@ -832,4 +917,5 @@ export function reloadConfig () {
832 config = require('config') 917 config = require('config')
833 918
834 updateWebserverConfig() 919 updateWebserverConfig()
920 updateWebserverUrls()
835} 921}
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 482c03b31..fe296142d 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -29,6 +29,11 @@ import { VideoViewModel } from '../models/video/video-views'
29import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' 29import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
30import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' 30import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
31import { UserVideoHistoryModel } from '../models/account/user-video-history' 31import { UserVideoHistoryModel } from '../models/account/user-video-history'
32import { AccountBlocklistModel } from '../models/account/account-blocklist'
33import { ServerBlocklistModel } from '../models/server/server-blocklist'
34import { UserNotificationModel } from '../models/account/user-notification'
35import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
32 37
33require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 38require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
34 39
@@ -91,7 +96,12 @@ async function initDatabaseModels (silent: boolean) {
91 VideoImportModel, 96 VideoImportModel,
92 VideoViewModel, 97 VideoViewModel,
93 VideoRedundancyModel, 98 VideoRedundancyModel,
94 UserVideoHistoryModel 99 UserVideoHistoryModel,
100 AccountBlocklistModel,
101 ServerBlocklistModel,
102 UserNotificationModel,
103 UserNotificationSettingModel,
104 VideoStreamingPlaylistModel
95 ]) 105 ])
96 106
97 // Check extensions exist in the database 107 // Check extensions exist in the database
@@ -115,25 +125,27 @@ export {
115// --------------------------------------------------------------------------- 125// ---------------------------------------------------------------------------
116 126
117async function checkPostgresExtensions () { 127async function checkPostgresExtensions () {
118 const extensions = [ 128 const promises = [
119 'pg_trgm', 129 checkPostgresExtension('pg_trgm'),
120 'unaccent' 130 checkPostgresExtension('unaccent')
121 ] 131 ]
122 132
123 for (const extension of extensions) { 133 return Promise.all(promises)
124 const query = `SELECT true AS enabled FROM pg_available_extensions WHERE name = '${extension}' AND installed_version IS NOT NULL;` 134}
125 const [ res ] = await sequelizeTypescript.query(query, { raw: true }) 135
136async function checkPostgresExtension (extension: string) {
137 const query = `SELECT true AS enabled FROM pg_available_extensions WHERE name = '${extension}' AND installed_version IS NOT NULL;`
138 const [ res ] = await sequelizeTypescript.query(query, { raw: true })
126 139
127 if (!res || res.length === 0 || res[ 0 ][ 'enabled' ] !== true) { 140 if (!res || res.length === 0 || res[ 0 ][ 'enabled' ] !== true) {
128 // Try to create the extension ourself 141 // Try to create the extension ourself
129 try { 142 try {
130 await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true }) 143 await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true })
131 144
132 } catch { 145 } catch {
133 const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` + 146 const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` +
134 `You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.` 147 `You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.`
135 throw new Error(errorMessage) 148 throw new Error(errorMessage)
136 }
137 } 149 }
138 } 150 }
139} 151}
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index c952ad46c..2b22e16fe 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -6,18 +6,27 @@ import { UserModel } from '../models/account/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'
9import { CACHE, CONFIG, LAST_MIGRATION_VERSION } from './constants' 9import { CACHE, CONFIG, HLS_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION } from './constants'
10import { sequelizeTypescript } from './database' 10import { sequelizeTypescript } from './database'
11import { remove, ensureDir } from 'fs-extra' 11import { remove, ensureDir } from 'fs-extra'
12 12
13async function installApplication () { 13async function installApplication () {
14 try { 14 try {
15 await sequelizeTypescript.sync() 15 await Promise.all([
16 await removeCacheDirectories() 16 // Database related
17 await createDirectoriesIfNotExist() 17 sequelizeTypescript.sync()
18 await createApplicationIfNotExist() 18 .then(() => {
19 await createOAuthClientIfNotExist() 19 return Promise.all([
20 await createOAuthAdminIfNotExist() 20 createApplicationIfNotExist(),
21 createOAuthClientIfNotExist(),
22 createOAuthAdminIfNotExist()
23 ])
24 }),
25
26 // Directories
27 removeCacheDirectories()
28 .then(() => createDirectoriesIfNotExist())
29 ])
21 } catch (err) { 30 } catch (err) {
22 logger.error('Cannot install application.', { err }) 31 logger.error('Cannot install application.', { err })
23 process.exit(-1) 32 process.exit(-1)
@@ -64,6 +73,9 @@ function createDirectoriesIfNotExist () {
64 tasks.push(ensureDir(dir)) 73 tasks.push(ensureDir(dir))
65 } 74 }
66 75
76 // Playlist directories
77 tasks.push(ensureDir(HLS_PLAYLIST_DIRECTORY))
78
67 return Promise.all(tasks) 79 return Promise.all(tasks)
68} 80}
69 81
diff --git a/server/initializers/migrations/0120-video-null.ts b/server/initializers/migrations/0120-video-null.ts
index 63f3984dd..6d253f04f 100644
--- a/server/initializers/migrations/0120-video-null.ts
+++ b/server/initializers/migrations/0120-video-null.ts
@@ -1,5 +1,4 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { CONSTRAINTS_FIELDS } from '../constants'
3 2
4async function up (utils: { 3async function up (utils: {
5 transaction: Sequelize.Transaction, 4 transaction: Sequelize.Transaction,
@@ -28,7 +27,7 @@ async function up (utils: {
28 27
29 { 28 {
30 const data = { 29 const data = {
31 type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max), 30 type: Sequelize.STRING(10000),
32 allowNull: true, 31 allowNull: true,
33 defaultValue: null 32 defaultValue: null
34 } 33 }
diff --git a/server/initializers/migrations/0195-support.ts b/server/initializers/migrations/0195-support.ts
index 8722a5f22..3b9eabe79 100644
--- a/server/initializers/migrations/0195-support.ts
+++ b/server/initializers/migrations/0195-support.ts
@@ -1,5 +1,4 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { CONSTRAINTS_FIELDS } from '../index'
3 2
4async function up (utils: { 3async function up (utils: {
5 transaction: Sequelize.Transaction, 4 transaction: Sequelize.Transaction,
@@ -8,7 +7,7 @@ async function up (utils: {
8}): Promise<void> { 7}): Promise<void> {
9 { 8 {
10 const data = { 9 const data = {
11 type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max), 10 type: Sequelize.STRING(500),
12 allowNull: true, 11 allowNull: true,
13 defaultValue: null 12 defaultValue: null
14 } 13 }
@@ -17,7 +16,7 @@ async function up (utils: {
17 16
18 { 17 {
19 const data = { 18 const data = {
20 type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max), 19 type: Sequelize.STRING(500),
21 allowNull: true, 20 allowNull: true,
22 defaultValue: null 21 defaultValue: null
23 } 22 }
@@ -26,7 +25,7 @@ async function up (utils: {
26 25
27 { 26 {
28 const data = { 27 const data = {
29 type: Sequelize.STRING(CONSTRAINTS_FIELDS.USERS.DESCRIPTION.max), 28 type: Sequelize.STRING(250),
30 allowNull: true, 29 allowNull: true,
31 defaultValue: null 30 defaultValue: null
32 } 31 }
@@ -35,7 +34,7 @@ async function up (utils: {
35 34
36 { 35 {
37 const data = { 36 const data = {
38 type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max), 37 type: Sequelize.STRING(10000),
39 allowNull: true, 38 allowNull: true,
40 defaultValue: null 39 defaultValue: null
41 } 40 }
diff --git a/server/initializers/migrations/0245-user-blocked.ts b/server/initializers/migrations/0245-user-blocked.ts
index 5a04ecd2b..19c7d5b9c 100644
--- a/server/initializers/migrations/0245-user-blocked.ts
+++ b/server/initializers/migrations/0245-user-blocked.ts
@@ -1,5 +1,4 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { CONSTRAINTS_FIELDS } from '../constants'
3 2
4async function up (utils: { 3async function up (utils: {
5 transaction: Sequelize.Transaction 4 transaction: Sequelize.Transaction
@@ -31,7 +30,7 @@ async function up (utils: {
31 30
32 { 31 {
33 const data = { 32 const data = {
34 type: Sequelize.STRING(CONSTRAINTS_FIELDS.USERS.BLOCKED_REASON.max), 33 type: Sequelize.STRING(250),
35 allowNull: true, 34 allowNull: true,
36 defaultValue: null 35 defaultValue: null
37 } 36 }
diff --git a/server/initializers/migrations/0250-video-abuse-state.ts b/server/initializers/migrations/0250-video-abuse-state.ts
index acb668ae1..50de25182 100644
--- a/server/initializers/migrations/0250-video-abuse-state.ts
+++ b/server/initializers/migrations/0250-video-abuse-state.ts
@@ -1,5 +1,4 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { CONSTRAINTS_FIELDS } from '../constants'
3import { VideoAbuseState } from '../../../shared/models/videos' 2import { VideoAbuseState } from '../../../shared/models/videos'
4 3
5async function up (utils: { 4async function up (utils: {
@@ -32,7 +31,7 @@ async function up (utils: {
32 31
33 { 32 {
34 const data = { 33 const data = {
35 type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max), 34 type: Sequelize.STRING(300),
36 allowNull: true, 35 allowNull: true,
37 defaultValue: null 36 defaultValue: null
38 } 37 }
diff --git a/server/initializers/migrations/0255-video-blacklist-reason.ts b/server/initializers/migrations/0255-video-blacklist-reason.ts
index a380e620e..69d6efb9e 100644
--- a/server/initializers/migrations/0255-video-blacklist-reason.ts
+++ b/server/initializers/migrations/0255-video-blacklist-reason.ts
@@ -1,5 +1,4 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { CONSTRAINTS_FIELDS } from '../constants'
3import { VideoAbuseState } from '../../../shared/models/videos' 2import { VideoAbuseState } from '../../../shared/models/videos'
4 3
5async function up (utils: { 4async function up (utils: {
@@ -10,7 +9,7 @@ async function up (utils: {
10 9
11 { 10 {
12 const data = { 11 const data = {
13 type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max), 12 type: Sequelize.STRING(300),
14 allowNull: true, 13 allowNull: true,
15 defaultValue: null 14 defaultValue: null
16 } 15 }
diff --git a/server/initializers/migrations/0260-upload-quota-daily.ts b/server/initializers/migrations/0260-upload-quota-daily.ts
index d25154ba6..cbbe391ef 100644
--- a/server/initializers/migrations/0260-upload-quota-daily.ts
+++ b/server/initializers/migrations/0260-upload-quota-daily.ts
@@ -1,5 +1,4 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { CONSTRAINTS_FIELDS } from '../constants'
3 2
4async function up (utils: { 3async function up (utils: {
5 transaction: Sequelize.Transaction 4 transaction: Sequelize.Transaction
diff --git a/server/initializers/migrations/0275-video-file-unique.ts b/server/initializers/migrations/0275-video-file-unique.ts
index fd89188c0..e321ecb04 100644
--- a/server/initializers/migrations/0275-video-file-unique.ts
+++ b/server/initializers/migrations/0275-video-file-unique.ts
@@ -5,6 +5,12 @@ async function up (utils: {
5 queryInterface: Sequelize.QueryInterface 5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize 6 sequelize: Sequelize.Sequelize
7}): Promise<any> { 7}): Promise<any> {
8 // Delete duplicated keys
9 {
10 const query = 'DELETE FROM "server" s1 USING "server" s2 WHERE s1.id < s2.id AND s1."host" = s2."host"'
11 await utils.sequelize.query(query)
12 }
13
8 { 14 {
9 const query = 'DELETE FROM "videoFile" vf1 USING "videoFile" vf2 WHERE vf1.id < vf2.id ' + 15 const query = 'DELETE FROM "videoFile" vf1 USING "videoFile" vf2 WHERE vf1.id < vf2.id ' +
10 'AND vf1."videoId" = vf2."videoId" AND vf1.resolution = vf2.resolution AND vf1.fps IS NULL' 16 'AND vf1."videoId" = vf2."videoId" AND vf1.resolution = vf2.resolution AND vf1.fps IS NULL'
diff --git a/server/initializers/migrations/0280-webtorrent-policy-user.ts b/server/initializers/migrations/0280-webtorrent-policy-user.ts
new file mode 100644
index 000000000..e6488356a
--- /dev/null
+++ b/server/initializers/migrations/0280-webtorrent-policy-user.ts
@@ -0,0 +1,28 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<any> {
8 {
9 const data = {
10 type: Sequelize.BOOLEAN,
11 allowNull: false,
12 defaultValue: true
13 }
14
15 await utils.queryInterface.addColumn('user', 'webTorrentEnabled', data)
16 }
17
18}
19
20async function down (utils: {
21 transaction: Sequelize.Transaction
22 queryInterface: Sequelize.QueryInterface
23 sequelize: Sequelize.Sequelize
24}): Promise<any> {
25 await utils.queryInterface.removeColumn('user', 'webTorrentEnabled')
26}
27
28export { up, down }
diff --git a/server/initializers/migrations/0285-description-support.ts b/server/initializers/migrations/0285-description-support.ts
new file mode 100644
index 000000000..85ef4ef39
--- /dev/null
+++ b/server/initializers/migrations/0285-description-support.ts
@@ -0,0 +1,53 @@
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 data = {
11 type: Sequelize.STRING(1000),
12 allowNull: true,
13 defaultValue: null
14 }
15 await utils.queryInterface.changeColumn('video', 'support', data)
16 }
17
18 {
19 const data = {
20 type: Sequelize.STRING(1000),
21 allowNull: true,
22 defaultValue: null
23 }
24 await utils.queryInterface.changeColumn('videoChannel', 'support', data)
25 }
26
27 {
28 const data = {
29 type: Sequelize.STRING(1000),
30 allowNull: true,
31 defaultValue: null
32 }
33 await utils.queryInterface.changeColumn('videoChannel', 'description', data)
34 }
35
36 {
37 const data = {
38 type: Sequelize.STRING(1000),
39 allowNull: true,
40 defaultValue: null
41 }
42 await utils.queryInterface.changeColumn('account', 'description', data)
43 }
44}
45
46function down (options) {
47 throw new Error('Not implemented.')
48}
49
50export {
51 up,
52 down
53}
diff --git a/server/initializers/migrations/0290-account-video-rate-url.ts b/server/initializers/migrations/0290-account-video-rate-url.ts
new file mode 100644
index 000000000..bdabf2929
--- /dev/null
+++ b/server/initializers/migrations/0290-account-video-rate-url.ts
@@ -0,0 +1,46 @@
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 data = {
11 type: Sequelize.STRING(2000),
12 allowNull: true
13 }
14
15 await utils.queryInterface.addColumn('accountVideoRate', 'url', data)
16 }
17
18 {
19 const builtUrlQuery = `SELECT "actor"."url" || '/' || "accountVideoRate"."type" || 's/' || "videoId" ` +
20 'FROM "accountVideoRate" ' +
21 'INNER JOIN account ON account.id = "accountVideoRate"."accountId" ' +
22 'INNER JOIN actor ON actor.id = account."actorId" ' +
23 'WHERE "base".id = "accountVideoRate".id'
24
25 const query = 'UPDATE "accountVideoRate" base SET "url" = (' + builtUrlQuery + ') WHERE "url" IS NULL'
26 await utils.sequelize.query(query)
27 }
28
29 {
30 const data = {
31 type: Sequelize.STRING(2000),
32 allowNull: false,
33 defaultValue: null
34 }
35 await utils.queryInterface.changeColumn('accountVideoRate', 'url', data)
36 }
37}
38
39function down (options) {
40 throw new Error('Not implemented.')
41}
42
43export {
44 up,
45 down
46}
diff --git a/server/initializers/migrations/0295-video-file-extname.ts b/server/initializers/migrations/0295-video-file-extname.ts
new file mode 100644
index 000000000..dbf249f66
--- /dev/null
+++ b/server/initializers/migrations/0295-video-file-extname.ts
@@ -0,0 +1,49 @@
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 await utils.queryInterface.renameColumn('videoFile', 'extname', 'extname_old')
11 }
12
13 {
14 const data = {
15 type: Sequelize.STRING,
16 defaultValue: null,
17 allowNull: true
18 }
19
20 await utils.queryInterface.addColumn('videoFile', 'extname', data)
21 }
22
23 {
24 const query = 'UPDATE "videoFile" SET "extname" = "extname_old"::text'
25 await utils.sequelize.query(query)
26 }
27
28 {
29 const data = {
30 type: Sequelize.STRING,
31 defaultValue: null,
32 allowNull: false
33 }
34 await utils.queryInterface.changeColumn('videoFile', 'extname', data)
35 }
36
37 {
38 await utils.queryInterface.removeColumn('videoFile', 'extname_old')
39 }
40}
41
42function down (options) {
43 throw new Error('Not implemented.')
44}
45
46export {
47 up,
48 down
49}
diff --git a/server/initializers/migrations/0300-user-videos-history-enabled.ts b/server/initializers/migrations/0300-user-videos-history-enabled.ts
new file mode 100644
index 000000000..aa5fc21fb
--- /dev/null
+++ b/server/initializers/migrations/0300-user-videos-history-enabled.ts
@@ -0,0 +1,27 @@
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 data = {
11 type: Sequelize.BOOLEAN,
12 allowNull: false,
13 defaultValue: true
14 }
15
16 await utils.queryInterface.addColumn('user', 'videosHistoryEnabled', data)
17 }
18}
19
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/initializers/migrations/0305-fix-unfederated-videos.ts b/server/initializers/migrations/0305-fix-unfederated-videos.ts
new file mode 100644
index 000000000..be206601f
--- /dev/null
+++ b/server/initializers/migrations/0305-fix-unfederated-videos.ts
@@ -0,0 +1,52 @@
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 = `INSERT INTO "videoShare" (url, "actorId", "videoId", "createdAt", "updatedAt") ` +
11 `(` +
12 `SELECT ` +
13 `video.url || '/announces/' || "videoChannel"."actorId" as url, ` +
14 `"videoChannel"."actorId" AS "actorId", ` +
15 `"video"."id" AS "videoId", ` +
16 `NOW() AS "createdAt", ` +
17 `NOW() AS "updatedAt" ` +
18 `FROM video ` +
19 `INNER JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
20 `WHERE "video"."remote" = false AND "video"."privacy" != 3 AND "video"."state" = 1` +
21 `) ` +
22 `ON CONFLICT DO NOTHING`
23
24 await utils.sequelize.query(query)
25 }
26
27 {
28 const query = `INSERT INTO "videoShare" (url, "actorId", "videoId", "createdAt", "updatedAt") ` +
29 `(` +
30 `SELECT ` +
31 `video.url || '/announces/' || (SELECT id FROM actor WHERE "preferredUsername" = 'peertube' ORDER BY id ASC LIMIT 1) as url, ` +
32 `(SELECT id FROM actor WHERE "preferredUsername" = 'peertube' ORDER BY id ASC LIMIT 1) AS "actorId", ` +
33 `"video"."id" AS "videoId", ` +
34 `NOW() AS "createdAt", ` +
35 `NOW() AS "updatedAt" ` +
36 `FROM video ` +
37 `WHERE "video"."remote" = false AND "video"."privacy" != 3 AND "video"."state" = 1` +
38 `) ` +
39 `ON CONFLICT DO NOTHING`
40
41 await utils.sequelize.query(query)
42 }
43}
44
45function down (options) {
46 throw new Error('Not implemented.')
47}
48
49export {
50 up,
51 down
52}
diff --git a/server/initializers/migrations/0310-drop-unused-video-indexes.ts b/server/initializers/migrations/0310-drop-unused-video-indexes.ts
new file mode 100644
index 000000000..d51f430c0
--- /dev/null
+++ b/server/initializers/migrations/0310-drop-unused-video-indexes.ts
@@ -0,0 +1,32 @@
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 const indexNames = [
10 'video_category',
11 'video_licence',
12 'video_nsfw',
13 'video_language',
14 'video_wait_transcoding',
15 'video_state',
16 'video_remote',
17 'video_likes'
18 ]
19
20 for (const indexName of indexNames) {
21 await utils.sequelize.query('DROP INDEX IF EXISTS "' + indexName + '";')
22 }
23}
24
25function down (options) {
26 throw new Error('Not implemented.')
27}
28
29export {
30 up,
31 down
32}
diff --git a/server/initializers/migrations/0315-user-notifications.ts b/server/initializers/migrations/0315-user-notifications.ts
new file mode 100644
index 000000000..8284c58a0
--- /dev/null
+++ b/server/initializers/migrations/0315-user-notifications.ts
@@ -0,0 +1,47 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const query = `
11CREATE TABLE IF NOT EXISTS "userNotificationSetting" ("id" SERIAL,
12"newVideoFromSubscription" INTEGER NOT NULL DEFAULT NULL,
13"newCommentOnMyVideo" INTEGER NOT NULL DEFAULT NULL,
14"videoAbuseAsModerator" INTEGER NOT NULL DEFAULT NULL,
15"blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL,
16"myVideoPublished" INTEGER NOT NULL DEFAULT NULL,
17"myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL,
18"newUserRegistration" INTEGER NOT NULL DEFAULT NULL,
19"newFollow" INTEGER NOT NULL DEFAULT NULL,
20"commentMention" INTEGER NOT NULL DEFAULT NULL,
21"userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
22"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
23"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
24PRIMARY KEY ("id"))
25`
26 await utils.sequelize.query(query)
27 }
28
29 {
30 const query = 'INSERT INTO "userNotificationSetting" ' +
31 '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
32 '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
33 '"userId", "createdAt", "updatedAt") ' +
34 '(SELECT 1, 1, 3, 3, 1, 1, 1, 1, 1, id, NOW(), NOW() FROM "user")'
35
36 await utils.sequelize.query(query)
37 }
38}
39
40function down (options) {
41 throw new Error('Not implemented.')
42}
43
44export {
45 up,
46 down
47}
diff --git a/server/initializers/migrations/0320-blacklist-unfederate.ts b/server/initializers/migrations/0320-blacklist-unfederate.ts
new file mode 100644
index 000000000..6fb7bbb90
--- /dev/null
+++ b/server/initializers/migrations/0320-blacklist-unfederate.ts
@@ -0,0 +1,27 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const data = {
11 type: Sequelize.BOOLEAN,
12 allowNull: false,
13 defaultValue: false
14 }
15
16 await utils.queryInterface.addColumn('videoBlacklist', 'unfederated', data)
17 }
18}
19
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/initializers/migrations/0325-video-abuse-fields.ts b/server/initializers/migrations/0325-video-abuse-fields.ts
new file mode 100644
index 000000000..fca6d666f
--- /dev/null
+++ b/server/initializers/migrations/0325-video-abuse-fields.ts
@@ -0,0 +1,37 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const data = {
11 type: Sequelize.STRING(3000),
12 allowNull: false,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.changeColumn('videoAbuse', 'reason', data)
17 }
18
19 {
20 const data = {
21 type: Sequelize.STRING(3000),
22 allowNull: true,
23 defaultValue: null
24 }
25
26 await utils.queryInterface.changeColumn('videoAbuse', 'moderationComment', data)
27 }
28}
29
30function down (options) {
31 throw new Error('Not implemented.')
32}
33
34export {
35 up,
36 down
37}
diff --git a/server/initializers/migrations/0330-video-streaming-playlist.ts b/server/initializers/migrations/0330-video-streaming-playlist.ts
new file mode 100644
index 000000000..c85a762ab
--- /dev/null
+++ b/server/initializers/migrations/0330-video-streaming-playlist.ts
@@ -0,0 +1,51 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const query = `
11 CREATE TABLE IF NOT EXISTS "videoStreamingPlaylist"
12(
13 "id" SERIAL,
14 "type" INTEGER NOT NULL,
15 "playlistUrl" VARCHAR(2000) NOT NULL,
16 "p2pMediaLoaderInfohashes" VARCHAR(255)[] NOT NULL,
17 "segmentsSha256Url" VARCHAR(255) NOT NULL,
18 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
19 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
20 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
21 PRIMARY KEY ("id")
22);`
23 await utils.sequelize.query(query)
24 }
25
26 {
27 const data = {
28 type: Sequelize.INTEGER,
29 allowNull: true,
30 defaultValue: null
31 }
32
33 await utils.queryInterface.changeColumn('videoRedundancy', 'videoFileId', data)
34 }
35
36 {
37 const query = 'ALTER TABLE "videoRedundancy" ADD COLUMN "videoStreamingPlaylistId" INTEGER NULL ' +
38 'REFERENCES "videoStreamingPlaylist" ("id") ON DELETE CASCADE ON UPDATE CASCADE'
39
40 await utils.sequelize.query(query)
41 }
42}
43
44function down (options) {
45 throw new Error('Not implemented.')
46}
47
48export {
49 up,
50 down
51}
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts
index 45dd4443d..a3f379b76 100644
--- a/server/lib/activitypub/actor.ts
+++ b/server/lib/activitypub/actor.ts
@@ -1,19 +1,18 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { join } from 'path'
3import { Transaction } from 'sequelize' 2import { Transaction } from 'sequelize'
4import * as url from 'url' 3import * as url from 'url'
5import * as uuidv4 from 'uuid/v4' 4import * as uuidv4 from 'uuid/v4'
6import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' 5import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub'
7import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' 6import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
8import { getActorUrl } from '../../helpers/activitypub' 7import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
9import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor' 8import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor'
10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
11import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils' 10import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
12import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
13import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto' 12import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
14import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' 13import { doRequest, downloadImage } from '../../helpers/requests'
15import { getUrlFromWebfinger } from '../../helpers/webfinger' 14import { getUrlFromWebfinger } from '../../helpers/webfinger'
16import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../initializers' 15import { AVATARS_SIZE, CONFIG, MIMETYPES, sequelizeTypescript } from '../../initializers'
17import { AccountModel } from '../../models/account/account' 16import { AccountModel } from '../../models/account/account'
18import { ActorModel } from '../../models/activitypub/actor' 17import { ActorModel } from '../../models/activitypub/actor'
19import { AvatarModel } from '../../models/avatar/avatar' 18import { AvatarModel } from '../../models/avatar/avatar'
@@ -43,7 +42,7 @@ async function getOrCreateActorAndServerAndModel (
43 recurseIfNeeded = true, 42 recurseIfNeeded = true,
44 updateCollections = false 43 updateCollections = false
45) { 44) {
46 const actorUrl = getActorUrl(activityActor) 45 const actorUrl = getAPId(activityActor)
47 let created = false 46 let created = false
48 47
49 let actor = await fetchActorByUrl(actorUrl, fetchType) 48 let actor = await fetchActorByUrl(actorUrl, fetchType)
@@ -65,8 +64,12 @@ async function getOrCreateActorAndServerAndModel (
65 const accountAttributedTo = result.attributedTo.find(a => a.type === 'Person') 64 const accountAttributedTo = result.attributedTo.find(a => a.type === 'Person')
66 if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actor.url) 65 if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actor.url)
67 66
67 if (checkUrlsSameHost(accountAttributedTo.id, actorUrl) !== true) {
68 throw new Error(`Account attributed to ${accountAttributedTo.id} does not have the same host than actor url ${actorUrl}`)
69 }
70
68 try { 71 try {
69 // Assert we don't recurse another time 72 // Don't recurse another time
70 ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false) 73 ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false)
71 } catch (err) { 74 } catch (err) {
72 logger.error('Cannot get or create account attributed to video channel ' + actor.url) 75 logger.error('Cannot get or create account attributed to video channel ' + actor.url)
@@ -168,18 +171,13 @@ async function fetchActorTotalItems (url: string) {
168 171
169async function fetchAvatarIfExists (actorJSON: ActivityPubActor) { 172async function fetchAvatarIfExists (actorJSON: ActivityPubActor) {
170 if ( 173 if (
171 actorJSON.icon && actorJSON.icon.type === 'Image' && IMAGE_MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined && 174 actorJSON.icon && actorJSON.icon.type === 'Image' && MIMETYPES.IMAGE.MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined &&
172 isActivityPubUrlValid(actorJSON.icon.url) 175 isActivityPubUrlValid(actorJSON.icon.url)
173 ) { 176 ) {
174 const extension = IMAGE_MIMETYPE_EXT[actorJSON.icon.mediaType] 177 const extension = MIMETYPES.IMAGE.MIMETYPE_EXT[actorJSON.icon.mediaType]
175 178
176 const avatarName = uuidv4() + extension 179 const avatarName = uuidv4() + extension
177 const destPath = join(CONFIG.STORAGE.AVATARS_DIR, avatarName) 180 await downloadImage(actorJSON.icon.url, CONFIG.STORAGE.AVATARS_DIR, avatarName, AVATARS_SIZE)
178
179 await doRequestAndSaveToFile({
180 method: 'GET',
181 uri: actorJSON.icon.url
182 }, destPath)
183 181
184 return avatarName 182 return avatarName
185 } 183 }
@@ -203,6 +201,69 @@ async function addFetchOutboxJob (actor: ActorModel) {
203 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) 201 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
204} 202}
205 203
204async function refreshActorIfNeeded (
205 actorArg: ActorModel,
206 fetchedType: ActorFetchByUrlType
207): Promise<{ actor: ActorModel, refreshed: boolean }> {
208 if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
209
210 // We need more attributes
211 const actor = fetchedType === 'all' ? actorArg : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
212
213 try {
214 let actorUrl: string
215 try {
216 actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
217 } catch (err) {
218 logger.warn('Cannot get actor URL from webfinger, keeping the old one.', err)
219 actorUrl = actor.url
220 }
221
222 const { result, statusCode } = await fetchRemoteActor(actorUrl)
223
224 if (statusCode === 404) {
225 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
226 actor.Account ? actor.Account.destroy() : actor.VideoChannel.destroy()
227 return { actor: undefined, refreshed: false }
228 }
229
230 if (result === undefined) {
231 logger.warn('Cannot fetch remote actor in refresh actor.')
232 return { actor, refreshed: false }
233 }
234
235 return sequelizeTypescript.transaction(async t => {
236 updateInstanceWithAnother(actor, result.actor)
237
238 if (result.avatarName !== undefined) {
239 await updateActorAvatarInstance(actor, result.avatarName, t)
240 }
241
242 // Force update
243 actor.setDataValue('updatedAt', new Date())
244 await actor.save({ transaction: t })
245
246 if (actor.Account) {
247 actor.Account.set('name', result.name)
248 actor.Account.set('description', result.summary)
249
250 await actor.Account.save({ transaction: t })
251 } else if (actor.VideoChannel) {
252 actor.VideoChannel.set('name', result.name)
253 actor.VideoChannel.set('description', result.summary)
254 actor.VideoChannel.set('support', result.support)
255
256 await actor.VideoChannel.save({ transaction: t })
257 }
258
259 return { refreshed: true, actor }
260 })
261 } catch (err) {
262 logger.warn('Cannot refresh actor.', { err })
263 return { actor, refreshed: false }
264 }
265}
266
206export { 267export {
207 getOrCreateActorAndServerAndModel, 268 getOrCreateActorAndServerAndModel,
208 buildActorInstance, 269 buildActorInstance,
@@ -210,6 +271,7 @@ export {
210 fetchActorTotalItems, 271 fetchActorTotalItems,
211 fetchAvatarIfExists, 272 fetchAvatarIfExists,
212 updateActorInstance, 273 updateActorInstance,
274 refreshActorIfNeeded,
213 updateActorAvatarInstance, 275 updateActorAvatarInstance,
214 addFetchOutboxJob 276 addFetchOutboxJob
215} 277}
@@ -293,16 +355,19 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
293 355
294 logger.info('Fetching remote actor %s.', actorUrl) 356 logger.info('Fetching remote actor %s.', actorUrl)
295 357
296 const requestResult = await doRequest(options) 358 const requestResult = await doRequest<ActivityPubActor>(options)
297 normalizeActor(requestResult.body) 359 normalizeActor(requestResult.body)
298 360
299 const actorJSON: ActivityPubActor = requestResult.body 361 const actorJSON = requestResult.body
300
301 if (isActorObjectValid(actorJSON) === false) { 362 if (isActorObjectValid(actorJSON) === false) {
302 logger.debug('Remote actor JSON is not valid.', { actorJSON: actorJSON }) 363 logger.debug('Remote actor JSON is not valid.', { actorJSON })
303 return { result: undefined, statusCode: requestResult.response.statusCode } 364 return { result: undefined, statusCode: requestResult.response.statusCode }
304 } 365 }
305 366
367 if (checkUrlsSameHost(actorJSON.id, actorUrl) !== true) {
368 throw new Error('Actor url ' + actorUrl + ' has not the same host than its AP id ' + actorJSON.id)
369 }
370
306 const followersCount = await fetchActorTotalItems(actorJSON.followers) 371 const followersCount = await fetchActorTotalItems(actorJSON.followers)
307 const followingCount = await fetchActorTotalItems(actorJSON.following) 372 const followingCount = await fetchActorTotalItems(actorJSON.following)
308 373
@@ -371,59 +436,3 @@ async function saveVideoChannel (actor: ActorModel, result: FetchRemoteActorResu
371 436
372 return videoChannelCreated 437 return videoChannelCreated
373} 438}
374
375async function refreshActorIfNeeded (
376 actorArg: ActorModel,
377 fetchedType: ActorFetchByUrlType
378): Promise<{ actor: ActorModel, refreshed: boolean }> {
379 if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
380
381 // We need more attributes
382 const actor = fetchedType === 'all' ? actorArg : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
383
384 try {
385 const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
386 const { result, statusCode } = await fetchRemoteActor(actorUrl)
387
388 if (statusCode === 404) {
389 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
390 actor.Account ? actor.Account.destroy() : actor.VideoChannel.destroy()
391 return { actor: undefined, refreshed: false }
392 }
393
394 if (result === undefined) {
395 logger.warn('Cannot fetch remote actor in refresh actor.')
396 return { actor, refreshed: false }
397 }
398
399 return sequelizeTypescript.transaction(async t => {
400 updateInstanceWithAnother(actor, result.actor)
401
402 if (result.avatarName !== undefined) {
403 await updateActorAvatarInstance(actor, result.avatarName, t)
404 }
405
406 // Force update
407 actor.setDataValue('updatedAt', new Date())
408 await actor.save({ transaction: t })
409
410 if (actor.Account) {
411 actor.Account.set('name', result.name)
412 actor.Account.set('description', result.summary)
413
414 await actor.Account.save({ transaction: t })
415 } else if (actor.VideoChannel) {
416 actor.VideoChannel.set('name', result.name)
417 actor.VideoChannel.set('description', result.summary)
418 actor.VideoChannel.set('support', result.support)
419
420 await actor.VideoChannel.save({ transaction: t })
421 }
422
423 return { refreshed: true, actor }
424 })
425 } catch (err) {
426 logger.warn('Cannot refresh actor.', { err })
427 return { actor, refreshed: false }
428 }
429}
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts
index f6f068b45..9a40414bb 100644
--- a/server/lib/activitypub/cache-file.ts
+++ b/server/lib/activitypub/cache-file.ts
@@ -1,11 +1,28 @@
1import { CacheFileObject } from '../../../shared/index' 1import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index'
2import { VideoModel } from '../../models/video/video' 2import { VideoModel } from '../../models/video/video'
3import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 3import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
4import { Transaction } from 'sequelize' 4import { Transaction } from 'sequelize'
5import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
5 6
6function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) { 7function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
7 const url = cacheFileObject.url
8 8
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
15 return {
16 expiresOn: new Date(cacheFileObject.expires),
17 url: cacheFileObject.id,
18 fileUrl: url.href,
19 strategy: null,
20 videoStreamingPlaylistId: playlist.id,
21 actorId: byActor.id
22 }
23 }
24
25 const url = cacheFileObject.url
9 const videoFile = video.VideoFiles.find(f => { 26 const videoFile = video.VideoFiles.find(f => {
10 return f.resolution === url.height && f.fps === url.fps 27 return f.resolution === url.height && f.fps === url.fps
11 }) 28 })
@@ -15,7 +32,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
15 return { 32 return {
16 expiresOn: new Date(cacheFileObject.expires), 33 expiresOn: new Date(cacheFileObject.expires),
17 url: cacheFileObject.id, 34 url: cacheFileObject.id,
18 fileUrl: cacheFileObject.url.href, 35 fileUrl: url.href,
19 strategy: null, 36 strategy: null,
20 videoFileId: videoFile.id, 37 videoFileId: videoFile.id,
21 actorId: byActor.id 38 actorId: byActor.id
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts
index 55912341c..1b9b14c2e 100644
--- a/server/lib/activitypub/crawl.ts
+++ b/server/lib/activitypub/crawl.ts
@@ -1,7 +1,8 @@
1import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers' 1import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers'
2import { doRequest } from '../../helpers/requests' 2import { doRequest } from '../../helpers/requests'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import Bluebird = require('bluebird') 4import * as Bluebird from 'bluebird'
5import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
5 6
6async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) { 7async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) {
7 logger.info('Crawling ActivityPub data on %s.', uri) 8 logger.info('Crawling ActivityPub data on %s.', uri)
@@ -14,7 +15,7 @@ async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Pr
14 timeout: JOB_REQUEST_TIMEOUT 15 timeout: JOB_REQUEST_TIMEOUT
15 } 16 }
16 17
17 const response = await doRequest(options) 18 const response = await doRequest<ActivityPubOrderedCollection<T>>(options)
18 const firstBody = response.body 19 const firstBody = response.body
19 20
20 let limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT 21 let limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT
@@ -23,7 +24,7 @@ async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Pr
23 while (nextLink && i < limit) { 24 while (nextLink && i < limit) {
24 options.uri = nextLink 25 options.uri = nextLink
25 26
26 const { body } = await doRequest(options) 27 const { body } = await doRequest<ActivityPubOrderedCollection<T>>(options)
27 nextLink = body.next 28 nextLink = body.next
28 i++ 29 i++
29 30
diff --git a/server/lib/activitypub/process/index.ts b/server/lib/activitypub/process/index.ts
index db4980a72..5466739c1 100644
--- a/server/lib/activitypub/process/index.ts
+++ b/server/lib/activitypub/process/index.ts
@@ -1,9 +1 @@
1export * from './process' export * from './process'
2export * from './process-accept'
3export * from './process-announce'
4export * from './process-create'
5export * from './process-delete'
6export * from './process-follow'
7export * from './process-like'
8export * from './process-undo'
9export * from './process-update'
diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts
index 89bda9c32..ebb275e34 100644
--- a/server/lib/activitypub/process/process-accept.ts
+++ b/server/lib/activitypub/process/process-accept.ts
@@ -24,6 +24,7 @@ async function processAccept (actor: ActorModel, targetActor: ActorModel) {
24 if (follow.state !== 'accepted') { 24 if (follow.state !== 'accepted') {
25 follow.set('state', 'accepted') 25 follow.set('state', 'accepted')
26 await follow.save() 26 await follow.save()
27
27 await addFetchOutboxJob(targetActor) 28 await addFetchOutboxJob(targetActor)
28 } 29 }
29} 30}
diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts
index cc88b5423..23310b41e 100644
--- a/server/lib/activitypub/process/process-announce.ts
+++ b/server/lib/activitypub/process/process-announce.ts
@@ -5,6 +5,8 @@ import { ActorModel } from '../../../models/activitypub/actor'
5import { VideoShareModel } from '../../../models/video/video-share' 5import { VideoShareModel } from '../../../models/video/video-share'
6import { forwardVideoRelatedActivity } from '../send/utils' 6import { forwardVideoRelatedActivity } from '../send/utils'
7import { getOrCreateVideoAndAccountAndChannel } from '../videos' 7import { getOrCreateVideoAndAccountAndChannel } from '../videos'
8import { VideoPrivacy } from '../../../../shared/models/videos'
9import { Notifier } from '../../notifier'
8 10
9async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) { 11async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) {
10 return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity) 12 return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity)
@@ -21,9 +23,9 @@ export {
21async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) { 23async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) {
22 const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id 24 const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id
23 25
24 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri }) 26 const { video, created: videoCreated } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
25 27
26 return sequelizeTypescript.transaction(async t => { 28 await sequelizeTypescript.transaction(async t => {
27 // Add share entry 29 // Add share entry
28 30
29 const share = { 31 const share = {
@@ -49,4 +51,6 @@ async function processVideoShare (actorAnnouncer: ActorModel, activity: Activity
49 51
50 return undefined 52 return undefined
51 }) 53 })
54
55 if (videoCreated) Notifier.Instance.notifyOnNewVideo(video)
52} 56}
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index cefe89db0..5f4d793a5 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -1,34 +1,44 @@
1import { ActivityCreate, CacheFileObject, VideoAbuseState, VideoTorrentObject } from '../../../../shared' 1import { ActivityCreate, CacheFileObject, VideoTorrentObject } from '../../../../shared'
2import { DislikeObject, VideoAbuseObject, ViewObject } from '../../../../shared/models/activitypub/objects'
3import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object' 2import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object'
4import { retryTransactionWrapper } from '../../../helpers/database-utils' 3import { retryTransactionWrapper } from '../../../helpers/database-utils'
5import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
6import { sequelizeTypescript } from '../../../initializers' 5import { sequelizeTypescript } from '../../../initializers'
7import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
8import { ActorModel } from '../../../models/activitypub/actor' 6import { ActorModel } from '../../../models/activitypub/actor'
9import { VideoAbuseModel } from '../../../models/video/video-abuse'
10import { addVideoComment, resolveThread } from '../video-comments' 7import { addVideoComment, resolveThread } from '../video-comments'
11import { getOrCreateVideoAndAccountAndChannel } from '../videos' 8import { getOrCreateVideoAndAccountAndChannel } from '../videos'
12import { forwardVideoRelatedActivity } from '../send/utils' 9import { forwardVideoRelatedActivity } from '../send/utils'
13import { Redis } from '../../redis'
14import { createOrUpdateCacheFile } from '../cache-file' 10import { createOrUpdateCacheFile } from '../cache-file'
11import { Notifier } from '../../notifier'
12import { processViewActivity } from './process-view'
13import { processDislikeActivity } from './process-dislike'
14import { processFlagActivity } from './process-flag'
15 15
16async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { 16async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
17 const activityObject = activity.object 17 const activityObject = activity.object
18 const activityType = activityObject.type 18 const activityType = activityObject.type
19 19
20 if (activityType === 'View') { 20 if (activityType === 'View') {
21 return processCreateView(byActor, activity) 21 return processViewActivity(activity, byActor)
22 } else if (activityType === 'Dislike') { 22 }
23 return retryTransactionWrapper(processCreateDislike, byActor, activity) 23
24 } else if (activityType === 'Video') { 24 if (activityType === 'Dislike') {
25 return retryTransactionWrapper(processDislikeActivity, activity, byActor)
26 }
27
28 if (activityType === 'Flag') {
29 return retryTransactionWrapper(processFlagActivity, activity, byActor)
30 }
31
32 if (activityType === 'Video') {
25 return processCreateVideo(activity) 33 return processCreateVideo(activity)
26 } else if (activityType === 'Flag') { 34 }
27 return retryTransactionWrapper(processCreateVideoAbuse, byActor, activityObject as VideoAbuseObject) 35
28 } else if (activityType === 'Note') { 36 if (activityType === 'Note') {
29 return retryTransactionWrapper(processCreateVideoComment, byActor, activity) 37 return retryTransactionWrapper(processCreateVideoComment, activity, byActor)
30 } else if (activityType === 'CacheFile') { 38 }
31 return retryTransactionWrapper(processCacheFile, byActor, activity) 39
40 if (activityType === 'CacheFile') {
41 return retryTransactionWrapper(processCacheFile, activity, byActor)
32 } 42 }
33 43
34 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) 44 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
@@ -46,60 +56,14 @@ export {
46async function processCreateVideo (activity: ActivityCreate) { 56async function processCreateVideo (activity: ActivityCreate) {
47 const videoToCreateData = activity.object as VideoTorrentObject 57 const videoToCreateData = activity.object as VideoTorrentObject
48 58
49 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData }) 59 const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
50
51 return video
52}
53
54async function processCreateDislike (byActor: ActorModel, activity: ActivityCreate) {
55 const dislike = activity.object as DislikeObject
56 const byAccount = byActor.Account
57
58 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
59
60 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
61
62 return sequelizeTypescript.transaction(async t => {
63 const rate = {
64 type: 'dislike' as 'dislike',
65 videoId: video.id,
66 accountId: byAccount.id
67 }
68 const [ , created ] = await AccountVideoRateModel.findOrCreate({
69 where: rate,
70 defaults: rate,
71 transaction: t
72 })
73 if (created === true) await video.increment('dislikes', { transaction: t })
74
75 if (video.isOwned() && created === true) {
76 // Don't resend the activity to the sender
77 const exceptions = [ byActor ]
78
79 await forwardVideoRelatedActivity(activity, t, exceptions, video)
80 }
81 })
82}
83
84async function processCreateView (byActor: ActorModel, activity: ActivityCreate) {
85 const view = activity.object as ViewObject
86
87 const options = {
88 videoObject: view.object,
89 fetchType: 'only-video' as 'only-video'
90 }
91 const { video } = await getOrCreateVideoAndAccountAndChannel(options)
92 60
93 await Redis.Instance.addVideoView(video.id) 61 if (created) Notifier.Instance.notifyOnNewVideo(video)
94 62
95 if (video.isOwned()) { 63 return video
96 // Don't resend the activity to the sender
97 const exceptions = [ byActor ]
98 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
99 }
100} 64}
101 65
102async function processCacheFile (byActor: ActorModel, activity: ActivityCreate) { 66async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) {
103 const cacheFile = activity.object as CacheFileObject 67 const cacheFile = activity.object as CacheFileObject
104 68
105 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object }) 69 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
@@ -115,29 +79,7 @@ async function processCacheFile (byActor: ActorModel, activity: ActivityCreate)
115 } 79 }
116} 80}
117 81
118async function processCreateVideoAbuse (byActor: ActorModel, videoAbuseToCreateData: VideoAbuseObject) { 82async function processCreateVideoComment (activity: ActivityCreate, byActor: ActorModel) {
119 logger.debug('Reporting remote abuse for video %s.', videoAbuseToCreateData.object)
120
121 const account = byActor.Account
122 if (!account) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
123
124 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoAbuseToCreateData.object })
125
126 return sequelizeTypescript.transaction(async t => {
127 const videoAbuseData = {
128 reporterAccountId: account.id,
129 reason: videoAbuseToCreateData.content,
130 videoId: video.id,
131 state: VideoAbuseState.PENDING
132 }
133
134 await VideoAbuseModel.create(videoAbuseData, { transaction: t })
135
136 logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object)
137 })
138}
139
140async function processCreateVideoComment (byActor: ActorModel, activity: ActivityCreate) {
141 const commentObject = activity.object as VideoCommentObject 83 const commentObject = activity.object as VideoCommentObject
142 const byAccount = byActor.Account 84 const byAccount = byActor.Account
143 85
@@ -145,7 +87,7 @@ async function processCreateVideoComment (byActor: ActorModel, activity: Activit
145 87
146 const { video } = await resolveThread(commentObject.inReplyTo) 88 const { video } = await resolveThread(commentObject.inReplyTo)
147 89
148 const { created } = await addVideoComment(video, commentObject.id) 90 const { comment, created } = await addVideoComment(video, commentObject.id)
149 91
150 if (video.isOwned() && created === true) { 92 if (video.isOwned() && created === true) {
151 // Don't resend the activity to the sender 93 // Don't resend the activity to the sender
@@ -153,4 +95,6 @@ async function processCreateVideoComment (byActor: ActorModel, activity: Activit
153 95
154 await forwardVideoRelatedActivity(activity, undefined, exceptions, video) 96 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
155 } 97 }
98
99 if (created === true) Notifier.Instance.notifyOnNewComment(comment)
156} 100}
diff --git a/server/lib/activitypub/process/process-dislike.ts b/server/lib/activitypub/process/process-dislike.ts
new file mode 100644
index 000000000..bfd69e07a
--- /dev/null
+++ b/server/lib/activitypub/process/process-dislike.ts
@@ -0,0 +1,52 @@
1import { ActivityCreate, ActivityDislike } from '../../../../shared'
2import { DislikeObject } from '../../../../shared/models/activitypub/objects'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { sequelizeTypescript } from '../../../initializers'
5import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
6import { ActorModel } from '../../../models/activitypub/actor'
7import { getOrCreateVideoAndAccountAndChannel } from '../videos'
8import { forwardVideoRelatedActivity } from '../send/utils'
9import { getVideoDislikeActivityPubUrl } from '../url'
10
11async function processDislikeActivity (activity: ActivityCreate | ActivityDislike, byActor: ActorModel) {
12 return retryTransactionWrapper(processDislike, activity, byActor)
13}
14
15// ---------------------------------------------------------------------------
16
17export {
18 processDislikeActivity
19}
20
21// ---------------------------------------------------------------------------
22
23async function processDislike (activity: ActivityCreate | ActivityDislike, byActor: ActorModel) {
24 const dislikeObject = activity.type === 'Dislike' ? activity.object : (activity.object as DislikeObject).object
25 const byAccount = byActor.Account
26
27 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
28
29 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislikeObject })
30
31 return sequelizeTypescript.transaction(async t => {
32 const rate = {
33 type: 'dislike' as 'dislike',
34 videoId: video.id,
35 accountId: byAccount.id
36 }
37
38 const [ , created ] = await AccountVideoRateModel.findOrCreate({
39 where: rate,
40 defaults: Object.assign({}, rate, { url: getVideoDislikeActivityPubUrl(byActor, video) }),
41 transaction: t
42 })
43 if (created === true) await video.increment('dislikes', { transaction: t })
44
45 if (video.isOwned() && created === true) {
46 // Don't resend the activity to the sender
47 const exceptions = [ byActor ]
48
49 await forwardVideoRelatedActivity(activity, t, exceptions, video)
50 }
51 })
52}
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts
new file mode 100644
index 000000000..79ce6fb41
--- /dev/null
+++ b/server/lib/activitypub/process/process-flag.ts
@@ -0,0 +1,49 @@
1import { ActivityCreate, ActivityFlag, VideoAbuseState } from '../../../../shared'
2import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { logger } from '../../../helpers/logger'
5import { sequelizeTypescript } from '../../../initializers'
6import { ActorModel } from '../../../models/activitypub/actor'
7import { VideoAbuseModel } from '../../../models/video/video-abuse'
8import { getOrCreateVideoAndAccountAndChannel } from '../videos'
9import { Notifier } from '../../notifier'
10import { getAPId } from '../../../helpers/activitypub'
11
12async function processFlagActivity (activity: ActivityCreate | ActivityFlag, byActor: ActorModel) {
13 return retryTransactionWrapper(processCreateVideoAbuse, activity, byActor)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 processFlagActivity
20}
21
22// ---------------------------------------------------------------------------
23
24async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, byActor: ActorModel) {
25 const flag = activity.type === 'Flag' ? activity : (activity.object as VideoAbuseObject)
26
27 logger.debug('Reporting remote abuse for video %s.', getAPId(flag.object))
28
29 const account = byActor.Account
30 if (!account) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
31
32 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: flag.object })
33
34 return sequelizeTypescript.transaction(async t => {
35 const videoAbuseData = {
36 reporterAccountId: account.id,
37 reason: flag.content,
38 videoId: video.id,
39 state: VideoAbuseState.PENDING
40 }
41
42 const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
43 videoAbuseInstance.Video = video
44
45 Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
46
47 logger.info('Remote abuse for video uuid %s created', flag.object)
48 })
49}
diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts
index 24c9085f7..0cd537187 100644
--- a/server/lib/activitypub/process/process-follow.ts
+++ b/server/lib/activitypub/process/process-follow.ts
@@ -5,9 +5,11 @@ import { sequelizeTypescript } from '../../../initializers'
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/activitypub/actor'
6import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 6import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
7import { sendAccept } from '../send' 7import { sendAccept } from '../send'
8import { Notifier } from '../../notifier'
9import { getAPId } from '../../../helpers/activitypub'
8 10
9async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) { 11async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
10 const activityObject = activity.object 12 const activityObject = getAPId(activity.object)
11 13
12 return retryTransactionWrapper(processFollow, byActor, activityObject) 14 return retryTransactionWrapper(processFollow, byActor, activityObject)
13} 15}
@@ -21,13 +23,13 @@ export {
21// --------------------------------------------------------------------------- 23// ---------------------------------------------------------------------------
22 24
23async function processFollow (actor: ActorModel, targetActorURL: string) { 25async function processFollow (actor: ActorModel, targetActorURL: string) {
24 await sequelizeTypescript.transaction(async t => { 26 const { actorFollow, created } = await sequelizeTypescript.transaction(async t => {
25 const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t) 27 const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
26 28
27 if (!targetActor) throw new Error('Unknown actor') 29 if (!targetActor) throw new Error('Unknown actor')
28 if (targetActor.isOwned() === false) throw new Error('This is not a local actor.') 30 if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
29 31
30 const [ actorFollow ] = await ActorFollowModel.findOrCreate({ 32 const [ actorFollow, created ] = await ActorFollowModel.findOrCreate({
31 where: { 33 where: {
32 actorId: actor.id, 34 actorId: actor.id,
33 targetActorId: targetActor.id 35 targetActorId: targetActor.id
@@ -52,8 +54,12 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
52 actorFollow.ActorFollowing = targetActor 54 actorFollow.ActorFollowing = targetActor
53 55
54 // Target sends to actor he accepted the follow request 56 // Target sends to actor he accepted the follow request
55 return sendAccept(actorFollow) 57 await sendAccept(actorFollow)
58
59 return { actorFollow, created }
56 }) 60 })
57 61
62 if (created) Notifier.Instance.notifyOfNewFollow(actorFollow)
63
58 logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url) 64 logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url)
59} 65}
diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts
index f7200db61..2a04167d7 100644
--- a/server/lib/activitypub/process/process-like.ts
+++ b/server/lib/activitypub/process/process-like.ts
@@ -5,6 +5,8 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/activitypub/actor'
6import { forwardVideoRelatedActivity } from '../send/utils' 6import { forwardVideoRelatedActivity } from '../send/utils'
7import { getOrCreateVideoAndAccountAndChannel } from '../videos' 7import { getOrCreateVideoAndAccountAndChannel } from '../videos'
8import { getVideoLikeActivityPubUrl } from '../url'
9import { getAPId } from '../../../helpers/activitypub'
8 10
9async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) { 11async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) {
10 return retryTransactionWrapper(processLikeVideo, byActor, activity) 12 return retryTransactionWrapper(processLikeVideo, byActor, activity)
@@ -19,7 +21,7 @@ export {
19// --------------------------------------------------------------------------- 21// ---------------------------------------------------------------------------
20 22
21async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) { 23async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) {
22 const videoUrl = activity.object 24 const videoUrl = getAPId(activity.object)
23 25
24 const byAccount = byActor.Account 26 const byAccount = byActor.Account
25 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url) 27 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
@@ -34,7 +36,7 @@ async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) {
34 } 36 }
35 const [ , created ] = await AccountVideoRateModel.findOrCreate({ 37 const [ , created ] = await AccountVideoRateModel.findOrCreate({
36 where: rate, 38 where: rate,
37 defaults: rate, 39 defaults: Object.assign({}, rate, { url: getVideoLikeActivityPubUrl(byActor, video) }),
38 transaction: t 40 transaction: t
39 }) 41 })
40 if (created === true) await video.increment('likes', { transaction: t }) 42 if (created === true) await video.increment('likes', { transaction: t })
diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts
index ff019cd8c..ed0177a67 100644
--- a/server/lib/activitypub/process/process-undo.ts
+++ b/server/lib/activitypub/process/process-undo.ts
@@ -26,6 +26,10 @@ async function processUndoActivity (activity: ActivityUndo, byActor: ActorModel)
26 } 26 }
27 } 27 }
28 28
29 if (activityToUndo.type === 'Dislike') {
30 return retryTransactionWrapper(processUndoDislike, byActor, activity)
31 }
32
29 if (activityToUndo.type === 'Follow') { 33 if (activityToUndo.type === 'Follow') {
30 return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo) 34 return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
31 } 35 }
@@ -55,7 +59,8 @@ async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) {
55 return sequelizeTypescript.transaction(async t => { 59 return sequelizeTypescript.transaction(async t => {
56 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) 60 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
57 61
58 const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t) 62 let rate = await AccountVideoRateModel.loadByUrl(likeActivity.id, t)
63 if (!rate) rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
59 if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`) 64 if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
60 65
61 await rate.destroy({ transaction: t }) 66 await rate.destroy({ transaction: t })
@@ -71,14 +76,17 @@ async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) {
71} 76}
72 77
73async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) { 78async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) {
74 const dislike = activity.object.object as DislikeObject 79 const dislike = activity.object.type === 'Dislike'
80 ? activity.object
81 : activity.object.object as DislikeObject
75 82
76 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object }) 83 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
77 84
78 return sequelizeTypescript.transaction(async t => { 85 return sequelizeTypescript.transaction(async t => {
79 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) 86 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
80 87
81 const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t) 88 let rate = await AccountVideoRateModel.loadByUrl(dislike.id, t)
89 if (!rate) rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
82 if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`) 90 if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
83 91
84 await rate.destroy({ transaction: t }) 92 await rate.destroy({ transaction: t })
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index bd4013555..c6b42d846 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -51,7 +51,7 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
51 return undefined 51 return undefined
52 } 52 }
53 53
54 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoObject.id }) 54 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoObject.id, allowRefresh: false })
55 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject) 55 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
56 56
57 const updateOptions = { 57 const updateOptions = {
@@ -59,7 +59,6 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
59 videoObject, 59 videoObject,
60 account: actor.Account, 60 account: actor.Account,
61 channel: channelActor.VideoChannel, 61 channel: channelActor.VideoChannel,
62 updateViews: true,
63 overrideTo: activity.to 62 overrideTo: activity.to
64 } 63 }
65 return updateVideoFromAP(updateOptions) 64 return updateVideoFromAP(updateOptions)
diff --git a/server/lib/activitypub/process/process-view.ts b/server/lib/activitypub/process/process-view.ts
new file mode 100644
index 000000000..8f66d3630
--- /dev/null
+++ b/server/lib/activitypub/process/process-view.ts
@@ -0,0 +1,35 @@
1import { ActorModel } from '../../../models/activitypub/actor'
2import { getOrCreateVideoAndAccountAndChannel } from '../videos'
3import { forwardVideoRelatedActivity } from '../send/utils'
4import { Redis } from '../../redis'
5import { ActivityCreate, ActivityView, ViewObject } from '../../../../shared/models/activitypub'
6
7async function processViewActivity (activity: ActivityView | ActivityCreate, byActor: ActorModel) {
8 return processCreateView(activity, byActor)
9}
10
11// ---------------------------------------------------------------------------
12
13export {
14 processViewActivity
15}
16
17// ---------------------------------------------------------------------------
18
19async function processCreateView (activity: ActivityView | ActivityCreate, byActor: ActorModel) {
20 const videoObject = activity.type === 'View' ? activity.object : (activity.object as ViewObject).object
21
22 const options = {
23 videoObject: videoObject,
24 fetchType: 'only-video' as 'only-video'
25 }
26 const { video } = await getOrCreateVideoAndAccountAndChannel(options)
27
28 await Redis.Instance.addVideoView(video.id)
29
30 if (video.isOwned()) {
31 // Don't resend the activity to the sender
32 const exceptions = [ byActor ]
33 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
34 }
35}
diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts
index b263f1ea2..9dd241402 100644
--- a/server/lib/activitypub/process/process.ts
+++ b/server/lib/activitypub/process/process.ts
@@ -1,5 +1,5 @@
1import { Activity, ActivityType } from '../../../../shared/models/activitypub' 1import { Activity, ActivityType } from '../../../../shared/models/activitypub'
2import { getActorUrl } from '../../../helpers/activitypub' 2import { checkUrlsSameHost, getAPId } from '../../../helpers/activitypub'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { ActorModel } from '../../../models/activitypub/actor' 4import { ActorModel } from '../../../models/activitypub/actor'
5import { processAcceptActivity } from './process-accept' 5import { processAcceptActivity } from './process-accept'
@@ -12,6 +12,9 @@ import { processRejectActivity } from './process-reject'
12import { processUndoActivity } from './process-undo' 12import { processUndoActivity } from './process-undo'
13import { processUpdateActivity } from './process-update' 13import { processUpdateActivity } from './process-update'
14import { getOrCreateActorAndServerAndModel } from '../actor' 14import { getOrCreateActorAndServerAndModel } from '../actor'
15import { processDislikeActivity } from './process-dislike'
16import { processFlagActivity } from './process-flag'
17import { processViewActivity } from './process-view'
15 18
16const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: ActorModel, inboxActor?: ActorModel) => Promise<any> } = { 19const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: ActorModel, inboxActor?: ActorModel) => Promise<any> } = {
17 Create: processCreateActivity, 20 Create: processCreateActivity,
@@ -22,27 +25,41 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: Ac
22 Reject: processRejectActivity, 25 Reject: processRejectActivity,
23 Announce: processAnnounceActivity, 26 Announce: processAnnounceActivity,
24 Undo: processUndoActivity, 27 Undo: processUndoActivity,
25 Like: processLikeActivity 28 Like: processLikeActivity,
29 Dislike: processDislikeActivity,
30 Flag: processFlagActivity,
31 View: processViewActivity
26} 32}
27 33
28async function processActivities (activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel) { 34async function processActivities (
35 activities: Activity[],
36 options: {
37 signatureActor?: ActorModel
38 inboxActor?: ActorModel
39 outboxUrl?: string
40 } = {}) {
29 const actorsCache: { [ url: string ]: ActorModel } = {} 41 const actorsCache: { [ url: string ]: ActorModel } = {}
30 42
31 for (const activity of activities) { 43 for (const activity of activities) {
32 if (!signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) { 44 if (!options.signatureActor && [ 'Create', 'Announce', 'Like' ].includes(activity.type) === false) {
33 logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type) 45 logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
34 continue 46 continue
35 } 47 }
36 48
37 const actorUrl = getActorUrl(activity.actor) 49 const actorUrl = getAPId(activity.actor)
38 50
39 // When we fetch remote data, we don't have signature 51 // When we fetch remote data, we don't have signature
40 if (signatureActor && actorUrl !== signatureActor.url) { 52 if (options.signatureActor && actorUrl !== options.signatureActor.url) {
41 logger.warn('Signature mismatch between %s and %s.', actorUrl, signatureActor.url) 53 logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, options.signatureActor.url)
42 continue 54 continue
43 } 55 }
44 56
45 const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl) 57 if (options.outboxUrl && checkUrlsSameHost(options.outboxUrl, actorUrl) !== true) {
58 logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', options.outboxUrl, actorUrl)
59 continue
60 }
61
62 const byActor = options.signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl)
46 actorsCache[actorUrl] = byActor 63 actorsCache[actorUrl] = byActor
47 64
48 const activityProcessor = processActivity[activity.type] 65 const activityProcessor = processActivity[activity.type]
@@ -52,7 +69,7 @@ async function processActivities (activities: Activity[], signatureActor?: Actor
52 } 69 }
53 70
54 try { 71 try {
55 await activityProcessor(activity, byActor, inboxActor) 72 await activityProcessor(activity, byActor, options.inboxActor)
56 } catch (err) { 73 } catch (err) {
57 logger.warn('Cannot process activity %s.', activity.type, { err }) 74 logger.warn('Cannot process activity %s.', activity.type, { err })
58 } 75 }
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts
index 285edba3b..ef20e404c 100644
--- a/server/lib/activitypub/send/send-create.ts
+++ b/server/lib/activitypub/send/send-create.ts
@@ -3,9 +3,7 @@ import { ActivityAudience, ActivityCreate } from '../../../../shared/models/acti
3import { VideoPrivacy } from '../../../../shared/models/videos' 3import { VideoPrivacy } from '../../../../shared/models/videos'
4import { ActorModel } from '../../../models/activitypub/actor' 4import { ActorModel } from '../../../models/activitypub/actor'
5import { VideoModel } from '../../../models/video/video' 5import { VideoModel } from '../../../models/video/video'
6import { VideoAbuseModel } from '../../../models/video/video-abuse'
7import { VideoCommentModel } from '../../../models/video/video-comment' 6import { VideoCommentModel } from '../../../models/video/video-comment'
8import { getVideoAbuseActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoViewActivityPubUrl } from '../url'
9import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' 7import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
10import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience' 8import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
11import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
@@ -25,31 +23,14 @@ async function sendCreateVideo (video: VideoModel, t: Transaction) {
25 return broadcastToFollowers(createActivity, byActor, [ byActor ], t) 23 return broadcastToFollowers(createActivity, byActor, [ byActor ], t)
26} 24}
27 25
28async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel) { 26async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, fileRedundancy: VideoRedundancyModel) {
29 if (!video.VideoChannel.Account.Actor.serverId) return // Local
30
31 const url = getVideoAbuseActivityPubUrl(videoAbuse)
32
33 logger.info('Creating job to send video abuse %s.', url)
34
35 // Custom audience, we only send the abuse to the origin instance
36 const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
37 const createActivity = buildCreateActivity(url, byActor, videoAbuse.toActivityPubObject(), audience)
38
39 return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
40}
41
42async function sendCreateCacheFile (byActor: ActorModel, fileRedundancy: VideoRedundancyModel) {
43 logger.info('Creating job to send file cache of %s.', fileRedundancy.url) 27 logger.info('Creating job to send file cache of %s.', fileRedundancy.url)
44 28
45 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(fileRedundancy.VideoFile.Video.id)
46 const redundancyObject = fileRedundancy.toActivityPubObject()
47
48 return sendVideoRelatedCreateActivity({ 29 return sendVideoRelatedCreateActivity({
49 byActor, 30 byActor,
50 video, 31 video,
51 url: fileRedundancy.url, 32 url: fileRedundancy.url,
52 object: redundancyObject 33 object: fileRedundancy.toActivityPubObject()
53 }) 34 })
54} 35}
55 36
@@ -91,37 +72,6 @@ async function sendCreateVideoComment (comment: VideoCommentModel, t: Transactio
91 return unicastTo(createActivity, byActor, comment.Video.VideoChannel.Account.Actor.sharedInboxUrl) 72 return unicastTo(createActivity, byActor, comment.Video.VideoChannel.Account.Actor.sharedInboxUrl)
92} 73}
93 74
94async function sendCreateView (byActor: ActorModel, video: VideoModel, t: Transaction) {
95 logger.info('Creating job to send view of %s.', video.url)
96
97 const url = getVideoViewActivityPubUrl(byActor, video)
98 const viewActivity = buildViewActivity(byActor, video)
99
100 return sendVideoRelatedCreateActivity({
101 // Use the server actor to send the view
102 byActor,
103 video,
104 url,
105 object: viewActivity,
106 transaction: t
107 })
108}
109
110async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
111 logger.info('Creating job to dislike %s.', video.url)
112
113 const url = getVideoDislikeActivityPubUrl(byActor, video)
114 const dislikeActivity = buildDislikeActivity(byActor, video)
115
116 return sendVideoRelatedCreateActivity({
117 byActor,
118 video,
119 url,
120 object: dislikeActivity,
121 transaction: t
122 })
123}
124
125function buildCreateActivity (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate { 75function buildCreateActivity (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate {
126 if (!audience) audience = getAudience(byActor) 76 if (!audience) audience = getAudience(byActor)
127 77
@@ -136,31 +86,11 @@ function buildCreateActivity (url: string, byActor: ActorModel, object: any, aud
136 ) 86 )
137} 87}
138 88
139function buildDislikeActivity (byActor: ActorModel, video: VideoModel) {
140 return {
141 type: 'Dislike',
142 actor: byActor.url,
143 object: video.url
144 }
145}
146
147function buildViewActivity (byActor: ActorModel, video: VideoModel) {
148 return {
149 type: 'View',
150 actor: byActor.url,
151 object: video.url
152 }
153}
154
155// --------------------------------------------------------------------------- 89// ---------------------------------------------------------------------------
156 90
157export { 91export {
158 sendCreateVideo, 92 sendCreateVideo,
159 sendVideoAbuse,
160 buildCreateActivity, 93 buildCreateActivity,
161 sendCreateView,
162 sendCreateDislike,
163 buildDislikeActivity,
164 sendCreateVideoComment, 94 sendCreateVideoComment,
165 sendCreateCacheFile 95 sendCreateCacheFile
166} 96}
diff --git a/server/lib/activitypub/send/send-dislike.ts b/server/lib/activitypub/send/send-dislike.ts
new file mode 100644
index 000000000..a88436f2c
--- /dev/null
+++ b/server/lib/activitypub/send/send-dislike.ts
@@ -0,0 +1,41 @@
1import { Transaction } from 'sequelize'
2import { ActorModel } from '../../../models/activitypub/actor'
3import { VideoModel } from '../../../models/video/video'
4import { getVideoDislikeActivityPubUrl } from '../url'
5import { logger } from '../../../helpers/logger'
6import { ActivityAudience, ActivityDislike } from '../../../../shared/models/activitypub'
7import { sendVideoRelatedActivity } from './utils'
8import { audiencify, getAudience } from '../audience'
9
10async function sendDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
11 logger.info('Creating job to dislike %s.', video.url)
12
13 const activityBuilder = (audience: ActivityAudience) => {
14 const url = getVideoDislikeActivityPubUrl(byActor, video)
15
16 return buildDislikeActivity(url, byActor, video, audience)
17 }
18
19 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t })
20}
21
22function buildDislikeActivity (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityDislike {
23 if (!audience) audience = getAudience(byActor)
24
25 return audiencify(
26 {
27 id: url,
28 type: 'Dislike' as 'Dislike',
29 actor: byActor.url,
30 object: video.url
31 },
32 audience
33 )
34}
35
36// ---------------------------------------------------------------------------
37
38export {
39 sendDislike,
40 buildDislikeActivity
41}
diff --git a/server/lib/activitypub/send/send-flag.ts b/server/lib/activitypub/send/send-flag.ts
new file mode 100644
index 000000000..96a7311b9
--- /dev/null
+++ b/server/lib/activitypub/send/send-flag.ts
@@ -0,0 +1,39 @@
1import { ActorModel } from '../../../models/activitypub/actor'
2import { VideoModel } from '../../../models/video/video'
3import { VideoAbuseModel } from '../../../models/video/video-abuse'
4import { getVideoAbuseActivityPubUrl } from '../url'
5import { unicastTo } from './utils'
6import { logger } from '../../../helpers/logger'
7import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub'
8import { audiencify, getAudience } from '../audience'
9
10async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel) {
11 if (!video.VideoChannel.Account.Actor.serverId) return // Local user
12
13 const url = getVideoAbuseActivityPubUrl(videoAbuse)
14
15 logger.info('Creating job to send video abuse %s.', url)
16
17 // Custom audience, we only send the abuse to the origin instance
18 const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
19 const flagActivity = buildFlagActivity(url, byActor, videoAbuse, audience)
20
21 return unicastTo(flagActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
22}
23
24function buildFlagActivity (url: string, byActor: ActorModel, videoAbuse: VideoAbuseModel, audience: ActivityAudience): ActivityFlag {
25 if (!audience) audience = getAudience(byActor)
26
27 const activity = Object.assign(
28 { id: url, actor: byActor.url },
29 videoAbuse.toActivityPubObject()
30 )
31
32 return audiencify(activity, audience)
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 sendVideoAbuse
39}
diff --git a/server/lib/activitypub/send/send-like.ts b/server/lib/activitypub/send/send-like.ts
index 89307acc6..35227887a 100644
--- a/server/lib/activitypub/send/send-like.ts
+++ b/server/lib/activitypub/send/send-like.ts
@@ -24,8 +24,8 @@ function buildLikeActivity (url: string, byActor: ActorModel, video: VideoModel,
24 24
25 return audiencify( 25 return audiencify(
26 { 26 {
27 type: 'Like' as 'Like',
28 id: url, 27 id: url,
28 type: 'Like' as 'Like',
29 actor: byActor.url, 29 actor: byActor.url,
30 object: video.url 30 object: video.url
31 }, 31 },
diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts
index 5236d2cb3..ecbf605d6 100644
--- a/server/lib/activitypub/send/send-undo.ts
+++ b/server/lib/activitypub/send/send-undo.ts
@@ -2,7 +2,7 @@ import { Transaction } from 'sequelize'
2import { 2import {
3 ActivityAnnounce, 3 ActivityAnnounce,
4 ActivityAudience, 4 ActivityAudience,
5 ActivityCreate, 5 ActivityCreate, ActivityDislike,
6 ActivityFollow, 6 ActivityFollow,
7 ActivityLike, 7 ActivityLike,
8 ActivityUndo 8 ActivityUndo
@@ -13,13 +13,14 @@ import { VideoModel } from '../../../models/video/video'
13import { getActorFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url' 13import { getActorFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url'
14import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' 14import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
15import { audiencify, getAudience } from '../audience' 15import { audiencify, getAudience } from '../audience'
16import { buildCreateActivity, buildDislikeActivity } from './send-create' 16import { buildCreateActivity } from './send-create'
17import { buildFollowActivity } from './send-follow' 17import { buildFollowActivity } from './send-follow'
18import { buildLikeActivity } from './send-like' 18import { buildLikeActivity } from './send-like'
19import { VideoShareModel } from '../../../models/video/video-share' 19import { VideoShareModel } from '../../../models/video/video-share'
20import { buildAnnounceWithVideoAudience } from './send-announce' 20import { buildAnnounceWithVideoAudience } from './send-announce'
21import { logger } from '../../../helpers/logger' 21import { logger } from '../../../helpers/logger'
22import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 22import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
23import { buildDislikeActivity } from './send-dislike'
23 24
24async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) { 25async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) {
25 const me = actorFollow.ActorFollower 26 const me = actorFollow.ActorFollower
@@ -64,16 +65,16 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans
64 logger.info('Creating job to undo a dislike of video %s.', video.url) 65 logger.info('Creating job to undo a dislike of video %s.', video.url)
65 66
66 const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video) 67 const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video)
67 const dislikeActivity = buildDislikeActivity(byActor, video) 68 const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
68 const createDislikeActivity = buildCreateActivity(dislikeUrl, byActor, dislikeActivity)
69 69
70 return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: createDislikeActivity, transaction: t }) 70 return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t })
71} 71}
72 72
73async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) { 73async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) {
74 logger.info('Creating job to undo cache file %s.', redundancyModel.url) 74 logger.info('Creating job to undo cache file %s.', redundancyModel.url)
75 75
76 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id) 76 const videoId = redundancyModel.getVideo().id
77 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
77 const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject()) 78 const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
78 79
79 return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t }) 80 return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t })
@@ -94,7 +95,7 @@ export {
94function undoActivityData ( 95function undoActivityData (
95 url: string, 96 url: string,
96 byActor: ActorModel, 97 byActor: ActorModel,
97 object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce, 98 object: ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce,
98 audience?: ActivityAudience 99 audience?: ActivityAudience
99): ActivityUndo { 100): ActivityUndo {
100 if (!audience) audience = getAudience(byActor) 101 if (!audience) audience = getAudience(byActor)
@@ -114,7 +115,7 @@ async function sendUndoVideoRelatedActivity (options: {
114 byActor: ActorModel, 115 byActor: ActorModel,
115 video: VideoModel, 116 video: VideoModel,
116 url: string, 117 url: string,
117 activity: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce, 118 activity: ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce,
118 transaction: Transaction 119 transaction: Transaction
119}) { 120}) {
120 const activityBuilder = (audience: ActivityAudience) => { 121 const activityBuilder = (audience: ActivityAudience) => {
diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts
index a68f03edf..839f66470 100644
--- a/server/lib/activitypub/send/send-update.ts
+++ b/server/lib/activitypub/send/send-update.ts
@@ -61,7 +61,7 @@ async function sendUpdateActor (accountOrChannel: AccountModel | VideoChannelMod
61async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) { 61async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) {
62 logger.info('Creating job to update cache file %s.', redundancyModel.url) 62 logger.info('Creating job to update cache file %s.', redundancyModel.url)
63 63
64 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id) 64 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.getVideo().id)
65 65
66 const activityBuilder = (audience: ActivityAudience) => { 66 const activityBuilder = (audience: ActivityAudience) => {
67 const redundancyObject = redundancyModel.toActivityPubObject() 67 const redundancyObject = redundancyModel.toActivityPubObject()
diff --git a/server/lib/activitypub/send/send-view.ts b/server/lib/activitypub/send/send-view.ts
new file mode 100644
index 000000000..8ad126be0
--- /dev/null
+++ b/server/lib/activitypub/send/send-view.ts
@@ -0,0 +1,40 @@
1import { Transaction } from 'sequelize'
2import { ActivityAudience, ActivityView } from '../../../../shared/models/activitypub'
3import { ActorModel } from '../../../models/activitypub/actor'
4import { VideoModel } from '../../../models/video/video'
5import { getVideoLikeActivityPubUrl } from '../url'
6import { sendVideoRelatedActivity } from './utils'
7import { audiencify, getAudience } from '../audience'
8import { logger } from '../../../helpers/logger'
9
10async function sendView (byActor: ActorModel, video: VideoModel, t: Transaction) {
11 logger.info('Creating job to send view of %s.', video.url)
12
13 const activityBuilder = (audience: ActivityAudience) => {
14 const url = getVideoLikeActivityPubUrl(byActor, video)
15
16 return buildViewActivity(url, byActor, video, audience)
17 }
18
19 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t })
20}
21
22function buildViewActivity (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityView {
23 if (!audience) audience = getAudience(byActor)
24
25 return audiencify(
26 {
27 id: url,
28 type: 'View' as 'View',
29 actor: byActor.url,
30 object: video.url
31 },
32 audience
33 )
34}
35
36// ---------------------------------------------------------------------------
37
38export {
39 sendView
40}
diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts
index 3ff60a97c..1767df0ae 100644
--- a/server/lib/activitypub/share.ts
+++ b/server/lib/activitypub/share.ts
@@ -4,13 +4,14 @@ import { getServerActor } from '../../helpers/utils'
4import { VideoModel } from '../../models/video/video' 4import { VideoModel } from '../../models/video/video'
5import { VideoShareModel } from '../../models/video/video-share' 5import { VideoShareModel } from '../../models/video/video-share'
6import { sendUndoAnnounce, sendVideoAnnounce } from './send' 6import { sendUndoAnnounce, sendVideoAnnounce } from './send'
7import { getAnnounceActivityPubUrl } from './url' 7import { getVideoAnnounceActivityPubUrl } from './url'
8import { VideoChannelModel } from '../../models/video/video-channel' 8import { VideoChannelModel } from '../../models/video/video-channel'
9import * as Bluebird from 'bluebird' 9import * as Bluebird from 'bluebird'
10import { doRequest } from '../../helpers/requests' 10import { doRequest } from '../../helpers/requests'
11import { getOrCreateActorAndServerAndModel } from './actor' 11import { getOrCreateActorAndServerAndModel } from './actor'
12import { logger } from '../../helpers/logger' 12import { logger } from '../../helpers/logger'
13import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers' 13import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers'
14import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
14 15
15async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) { 16async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) {
16 if (video.privacy === VideoPrivacy.PRIVATE) return undefined 17 if (video.privacy === VideoPrivacy.PRIVATE) return undefined
@@ -38,9 +39,13 @@ async function addVideoShares (shareUrls: string[], instance: VideoModel) {
38 json: true, 39 json: true,
39 activityPub: true 40 activityPub: true
40 }) 41 })
41 if (!body || !body.actor) throw new Error('Body of body actor is invalid') 42 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
43
44 const actorUrl = getAPId(body.actor)
45 if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
46 throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
47 }
42 48
43 const actorUrl = body.actor
44 const actor = await getOrCreateActorAndServerAndModel(actorUrl) 49 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
45 50
46 const entry = { 51 const entry = {
@@ -72,8 +77,8 @@ export {
72async function shareByServer (video: VideoModel, t: Transaction) { 77async function shareByServer (video: VideoModel, t: Transaction) {
73 const serverActor = await getServerActor() 78 const serverActor = await getServerActor()
74 79
75 const serverShareUrl = getAnnounceActivityPubUrl(video.url, serverActor) 80 const serverShareUrl = getVideoAnnounceActivityPubUrl(serverActor, video)
76 return VideoShareModel.findOrCreate({ 81 const [ serverShare ] = await VideoShareModel.findOrCreate({
77 defaults: { 82 defaults: {
78 actorId: serverActor.id, 83 actorId: serverActor.id,
79 videoId: video.id, 84 videoId: video.id,
@@ -83,16 +88,14 @@ async function shareByServer (video: VideoModel, t: Transaction) {
83 url: serverShareUrl 88 url: serverShareUrl
84 }, 89 },
85 transaction: t 90 transaction: t
86 }).then(([ serverShare, created ]) => {
87 if (created) return sendVideoAnnounce(serverActor, serverShare, video, t)
88
89 return undefined
90 }) 91 })
92
93 return sendVideoAnnounce(serverActor, serverShare, video, t)
91} 94}
92 95
93async function shareByVideoChannel (video: VideoModel, t: Transaction) { 96async function shareByVideoChannel (video: VideoModel, t: Transaction) {
94 const videoChannelShareUrl = getAnnounceActivityPubUrl(video.url, video.VideoChannel.Actor) 97 const videoChannelShareUrl = getVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
95 return VideoShareModel.findOrCreate({ 98 const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
96 defaults: { 99 defaults: {
97 actorId: video.VideoChannel.actorId, 100 actorId: video.VideoChannel.actorId,
98 videoId: video.id, 101 videoId: video.id,
@@ -102,11 +105,9 @@ async function shareByVideoChannel (video: VideoModel, t: Transaction) {
102 url: videoChannelShareUrl 105 url: videoChannelShareUrl
103 }, 106 },
104 transaction: t 107 transaction: t
105 }).then(([ videoChannelShare, created ]) => {
106 if (created) return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
107
108 return undefined
109 }) 108 })
109
110 return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
110} 111}
111 112
112async function undoShareByVideoChannel (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) { 113async function undoShareByVideoChannel (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) {
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts
index e792be698..4229fe094 100644
--- a/server/lib/activitypub/url.ts
+++ b/server/lib/activitypub/url.ts
@@ -5,6 +5,8 @@ import { VideoModel } from '../../models/video/video'
5import { VideoAbuseModel } from '../../models/video/video-abuse' 5import { VideoAbuseModel } from '../../models/video/video-abuse'
6import { VideoCommentModel } from '../../models/video/video-comment' 6import { VideoCommentModel } from '../../models/video/video-comment'
7import { VideoFileModel } from '../../models/video/video-file' 7import { VideoFileModel } from '../../models/video/video-file'
8import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
9import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
8 10
9function getVideoActivityPubUrl (video: VideoModel) { 11function getVideoActivityPubUrl (video: VideoModel) {
10 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 12 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
@@ -16,6 +18,10 @@ function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) {
16 return `${CONFIG.WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}` 18 return `${CONFIG.WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
17} 19}
18 20
21function getVideoCacheStreamingPlaylistActivityPubUrl (video: VideoModel, playlist: VideoStreamingPlaylistModel) {
22 return `${CONFIG.WEBSERVER.URL}/redundancy/video-playlists/${playlist.getStringType()}/${video.uuid}`
23}
24
19function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) { 25function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) {
20 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id 26 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
21} 27}
@@ -33,14 +39,14 @@ function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) {
33} 39}
34 40
35function getVideoViewActivityPubUrl (byActor: ActorModel, video: VideoModel) { 41function getVideoViewActivityPubUrl (byActor: ActorModel, video: VideoModel) {
36 return video.url + '/views/' + byActor.uuid + '/' + new Date().toISOString() 42 return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString()
37} 43}
38 44
39function getVideoLikeActivityPubUrl (byActor: ActorModel, video: VideoModel) { 45function getVideoLikeActivityPubUrl (byActor: ActorModel, video: VideoModel | { id: number }) {
40 return byActor.url + '/likes/' + video.id 46 return byActor.url + '/likes/' + video.id
41} 47}
42 48
43function getVideoDislikeActivityPubUrl (byActor: ActorModel, video: VideoModel) { 49function getVideoDislikeActivityPubUrl (byActor: ActorModel, video: VideoModel | { id: number }) {
44 return byActor.url + '/dislikes/' + video.id 50 return byActor.url + '/dislikes/' + video.id
45} 51}
46 52
@@ -74,8 +80,8 @@ function getActorFollowAcceptActivityPubUrl (actorFollow: ActorFollowModel) {
74 return follower.url + '/accepts/follows/' + me.id 80 return follower.url + '/accepts/follows/' + me.id
75} 81}
76 82
77function getAnnounceActivityPubUrl (originalUrl: string, byActor: ActorModel) { 83function getVideoAnnounceActivityPubUrl (byActor: ActorModel, video: VideoModel) {
78 return originalUrl + '/announces/' + byActor.id 84 return video.url + '/announces/' + byActor.id
79} 85}
80 86
81function getDeleteActivityPubUrl (originalUrl: string) { 87function getDeleteActivityPubUrl (originalUrl: string) {
@@ -92,12 +98,13 @@ function getUndoActivityPubUrl (originalUrl: string) {
92 98
93export { 99export {
94 getVideoActivityPubUrl, 100 getVideoActivityPubUrl,
101 getVideoCacheStreamingPlaylistActivityPubUrl,
95 getVideoChannelActivityPubUrl, 102 getVideoChannelActivityPubUrl,
96 getAccountActivityPubUrl, 103 getAccountActivityPubUrl,
97 getVideoAbuseActivityPubUrl, 104 getVideoAbuseActivityPubUrl,
98 getActorFollowActivityPubUrl, 105 getActorFollowActivityPubUrl,
99 getActorFollowAcceptActivityPubUrl, 106 getActorFollowAcceptActivityPubUrl,
100 getAnnounceActivityPubUrl, 107 getVideoAnnounceActivityPubUrl,
101 getUpdateActivityPubUrl, 108 getUpdateActivityPubUrl,
102 getUndoActivityPubUrl, 109 getUndoActivityPubUrl,
103 getVideoViewActivityPubUrl, 110 getVideoViewActivityPubUrl,
diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts
index c8c17f4c4..e87301fe7 100644
--- a/server/lib/activitypub/video-comments.ts
+++ b/server/lib/activitypub/video-comments.ts
@@ -9,6 +9,7 @@ import { VideoCommentModel } from '../../models/video/video-comment'
9import { getOrCreateActorAndServerAndModel } from './actor' 9import { getOrCreateActorAndServerAndModel } from './actor'
10import { getOrCreateVideoAndAccountAndChannel } from './videos' 10import { getOrCreateVideoAndAccountAndChannel } from './videos'
11import * as Bluebird from 'bluebird' 11import * as Bluebird from 'bluebird'
12import { checkUrlsSameHost } from '../../helpers/activitypub'
12 13
13async function videoCommentActivityObjectToDBAttributes (video: VideoModel, actor: ActorModel, comment: VideoCommentObject) { 14async function videoCommentActivityObjectToDBAttributes (video: VideoModel, actor: ActorModel, comment: VideoCommentObject) {
14 let originCommentId: number = null 15 let originCommentId: number = null
@@ -61,7 +62,15 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
61 const actorUrl = body.attributedTo 62 const actorUrl = body.attributedTo
62 if (!actorUrl) return { created: false } 63 if (!actorUrl) return { created: false }
63 64
64 const actor = await getOrCreateActorAndServerAndModel(actorUrl) 65 if (checkUrlsSameHost(commentUrl, actorUrl) !== true) {
66 throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${commentUrl}`)
67 }
68
69 if (checkUrlsSameHost(body.id, commentUrl) !== true) {
70 throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`)
71 }
72
73 const actor = await getOrCreateActorAndServerAndModel(actorUrl, 'all')
65 const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body) 74 const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body)
66 if (!entry) return { created: false } 75 if (!entry) return { created: false }
67 76
@@ -71,6 +80,8 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
71 }, 80 },
72 defaults: entry 81 defaults: entry
73 }) 82 })
83 comment.Account = actor.Account
84 comment.Video = videoInstance
74 85
75 return { comment, created } 86 return { comment, created }
76} 87}
@@ -134,6 +145,14 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []) {
134 const actorUrl = body.attributedTo 145 const actorUrl = body.attributedTo
135 if (!actorUrl) throw new Error('Miss attributed to in comment') 146 if (!actorUrl) throw new Error('Miss attributed to in comment')
136 147
148 if (checkUrlsSameHost(url, actorUrl) !== true) {
149 throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`)
150 }
151
152 if (checkUrlsSameHost(body.id, url) !== true) {
153 throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`)
154 }
155
137 const actor = await getOrCreateActorAndServerAndModel(actorUrl) 156 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
138 const comment = new VideoCommentModel({ 157 const comment = new VideoCommentModel({
139 url: body.id, 158 url: body.id,
diff --git a/server/lib/activitypub/video-rates.ts b/server/lib/activitypub/video-rates.ts
index 1619251c3..7aac79118 100644
--- a/server/lib/activitypub/video-rates.ts
+++ b/server/lib/activitypub/video-rates.ts
@@ -1,20 +1,43 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { AccountModel } from '../../models/account/account' 2import { AccountModel } from '../../models/account/account'
3import { VideoModel } from '../../models/video/video' 3import { VideoModel } from '../../models/video/video'
4import { sendCreateDislike, sendLike, sendUndoDislike, sendUndoLike } from './send' 4import { sendLike, sendUndoDislike, sendUndoLike } from './send'
5import { VideoRateType } from '../../../shared/models/videos' 5import { VideoRateType } from '../../../shared/models/videos'
6import * as Bluebird from 'bluebird' 6import * as Bluebird from 'bluebird'
7import { getOrCreateActorAndServerAndModel } from './actor' 7import { getOrCreateActorAndServerAndModel } from './actor'
8import { AccountVideoRateModel } from '../../models/account/account-video-rate' 8import { AccountVideoRateModel } from '../../models/account/account-video-rate'
9import { logger } from '../../helpers/logger' 9import { logger } from '../../helpers/logger'
10import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers' 10import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers'
11import { doRequest } from '../../helpers/requests'
12import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
13import { ActorModel } from '../../models/activitypub/actor'
14import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from './url'
15import { sendDislike } from './send/send-dislike'
11 16
12async function createRates (actorUrls: string[], video: VideoModel, rate: VideoRateType) { 17async function createRates (ratesUrl: string[], video: VideoModel, rate: VideoRateType) {
13 let rateCounts = 0 18 let rateCounts = 0
14 19
15 await Bluebird.map(actorUrls, async actorUrl => { 20 await Bluebird.map(ratesUrl, async rateUrl => {
16 try { 21 try {
22 // Fetch url
23 const { body } = await doRequest({
24 uri: rateUrl,
25 json: true,
26 activityPub: true
27 })
28 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
29
30 const actorUrl = getAPId(body.actor)
31 if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
32 throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
33 }
34
35 if (checkUrlsSameHost(body.id, rateUrl) !== true) {
36 throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`)
37 }
38
17 const actor = await getOrCreateActorAndServerAndModel(actorUrl) 39 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
40
18 const [ , created ] = await AccountVideoRateModel 41 const [ , created ] = await AccountVideoRateModel
19 .findOrCreate({ 42 .findOrCreate({
20 where: { 43 where: {
@@ -24,13 +47,14 @@ async function createRates (actorUrls: string[], video: VideoModel, rate: VideoR
24 defaults: { 47 defaults: {
25 videoId: video.id, 48 videoId: video.id,
26 accountId: actor.Account.id, 49 accountId: actor.Account.id,
27 type: rate 50 type: rate,
51 url: body.id
28 } 52 }
29 }) 53 })
30 54
31 if (created) rateCounts += 1 55 if (created) rateCounts += 1
32 } catch (err) { 56 } catch (err) {
33 logger.warn('Cannot add rate %s for actor %s.', rate, actorUrl, { err }) 57 logger.warn('Cannot add rate %s.', rateUrl, { err })
34 } 58 }
35 }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) 59 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
36 60
@@ -59,10 +83,15 @@ async function sendVideoRateChange (account: AccountModel,
59 // Like 83 // Like
60 if (likes > 0) await sendLike(actor, video, t) 84 if (likes > 0) await sendLike(actor, video, t)
61 // Dislike 85 // Dislike
62 if (dislikes > 0) await sendCreateDislike(actor, video, t) 86 if (dislikes > 0) await sendDislike(actor, video, t)
87}
88
89function getRateUrl (rateType: VideoRateType, actor: ActorModel, video: VideoModel) {
90 return rateType === 'like' ? getVideoLikeActivityPubUrl(actor, video) : getVideoDislikeActivityPubUrl(actor, video)
63} 91}
64 92
65export { 93export {
94 getRateUrl,
66 createRates, 95 createRates,
67 sendVideoRateChange 96 sendVideoRateChange
68} 97}
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 8521572a1..710929aac 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -1,17 +1,23 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import * as sequelize from 'sequelize' 2import * as sequelize from 'sequelize'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import { join } from 'path'
5import * as request from 'request' 4import * as request from 'request'
6import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index' 5import {
6 ActivityIconObject,
7 ActivityPlaylistSegmentHashesObject,
8 ActivityPlaylistUrlObject,
9 ActivityUrlObject,
10 ActivityVideoUrlObject,
11 VideoState
12} from '../../../shared/index'
7import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 13import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
8import { VideoPrivacy } from '../../../shared/models/videos' 14import { VideoPrivacy } from '../../../shared/models/videos'
9import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos' 15import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
10import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' 16import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
11import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils' 17import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
12import { logger } from '../../helpers/logger' 18import { logger } from '../../helpers/logger'
13import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' 19import { doRequest, downloadImage } from '../../helpers/requests'
14import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, VIDEO_MIMETYPE_EXT } from '../../initializers' 20import { ACTIVITY_PUB, CONFIG, MIMETYPES, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
15import { ActorModel } from '../../models/activitypub/actor' 21import { ActorModel } from '../../models/activitypub/actor'
16import { TagModel } from '../../models/video/tag' 22import { TagModel } from '../../models/video/tag'
17import { VideoModel } from '../../models/video/video' 23import { VideoModel } from '../../models/video/video'
@@ -29,6 +35,11 @@ import { createRates } from './video-rates'
29import { addVideoShares, shareVideoByServerAndChannel } from './share' 35import { addVideoShares, shareVideoByServerAndChannel } from './share'
30import { AccountModel } from '../../models/account/account' 36import { AccountModel } from '../../models/account/account'
31import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video' 37import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
38import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
39import { Notifier } from '../notifier'
40import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
41import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
42import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
32 43
33async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { 44async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
34 // If the video is not private and published, we federate it 45 // If the video is not private and published, we federate it
@@ -63,7 +74,7 @@ async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.
63 74
64 const { response, body } = await doRequest(options) 75 const { response, body } = await doRequest(options)
65 76
66 if (sanitizeAndCheckVideoTorrentObject(body) === false) { 77 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
67 logger.debug('Remote video JSON is not valid.', { body }) 78 logger.debug('Remote video JSON is not valid.', { body })
68 return { response, videoObject: undefined } 79 return { response, videoObject: undefined }
69 } 80 }
@@ -94,19 +105,18 @@ function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Fu
94 105
95function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) { 106function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
96 const thumbnailName = video.getThumbnailName() 107 const thumbnailName = video.getThumbnailName()
97 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
98 108
99 const options = { 109 return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
100 method: 'GET',
101 uri: icon.url
102 }
103 return doRequestAndSaveToFile(options, thumbnailPath)
104} 110}
105 111
106function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) { 112function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
107 const channel = videoObject.attributedTo.find(a => a.type === 'Group') 113 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
108 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) 114 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
109 115
116 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
117 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
118 }
119
110 return getOrCreateActorAndServerAndModel(channel.id, 'all') 120 return getOrCreateActorAndServerAndModel(channel.id, 'all')
111} 121}
112 122
@@ -116,7 +126,7 @@ type SyncParam = {
116 shares: boolean 126 shares: boolean
117 comments: boolean 127 comments: boolean
118 thumbnail: boolean 128 thumbnail: boolean
119 refreshVideo: boolean 129 refreshVideo?: boolean
120} 130}
121async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) { 131async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
122 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid) 132 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
@@ -155,31 +165,34 @@ async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: Vid
155} 165}
156 166
157async function getOrCreateVideoAndAccountAndChannel (options: { 167async function getOrCreateVideoAndAccountAndChannel (options: {
158 videoObject: VideoTorrentObject | string, 168 videoObject: { id: string } | string,
159 syncParam?: SyncParam, 169 syncParam?: SyncParam,
160 fetchType?: VideoFetchByUrlType, 170 fetchType?: VideoFetchByUrlType,
161 refreshViews?: boolean 171 allowRefresh?: boolean // true by default
162}) { 172}) {
163 // Default params 173 // Default params
164 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false } 174 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
165 const fetchType = options.fetchType || 'all' 175 const fetchType = options.fetchType || 'all'
166 const refreshViews = options.refreshViews || false 176 const allowRefresh = options.allowRefresh !== false
167 177
168 // Get video url 178 // Get video url
169 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id 179 const videoUrl = getAPId(options.videoObject)
170 180
171 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType) 181 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172 if (videoFromDatabase) { 182 if (videoFromDatabase) {
173 const refreshOptions = { 183
174 video: videoFromDatabase, 184 if (allowRefresh === true) {
175 fetchedType: fetchType, 185 const refreshOptions = {
176 syncParam, 186 video: videoFromDatabase,
177 refreshViews 187 fetchedType: fetchType,
188 syncParam
189 }
190
191 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
192 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
178 } 193 }
179 const p = refreshVideoIfNeeded(refreshOptions)
180 if (syncParam.refreshVideo === true) videoFromDatabase = await p
181 194
182 return { video: videoFromDatabase } 195 return { video: videoFromDatabase, created: false }
183 } 196 }
184 197
185 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl) 198 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
@@ -190,7 +203,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
190 203
191 await syncVideoExternalAttributes(video, fetchedVideo, syncParam) 204 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
192 205
193 return { video } 206 return { video, created: true }
194} 207}
195 208
196async function updateVideoFromAP (options: { 209async function updateVideoFromAP (options: {
@@ -198,17 +211,17 @@ async function updateVideoFromAP (options: {
198 videoObject: VideoTorrentObject, 211 videoObject: VideoTorrentObject,
199 account: AccountModel, 212 account: AccountModel,
200 channel: VideoChannelModel, 213 channel: VideoChannelModel,
201 updateViews: boolean,
202 overrideTo?: string[] 214 overrideTo?: string[]
203}) { 215}) {
204 logger.debug('Updating remote video "%s".', options.videoObject.uuid) 216 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
217
205 let videoFieldsSave: any 218 let videoFieldsSave: any
219 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
220 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
206 221
207 try { 222 try {
208 await sequelizeTypescript.transaction(async t => { 223 await sequelizeTypescript.transaction(async t => {
209 const sequelizeOptions = { 224 const sequelizeOptions = { transaction: t }
210 transaction: t
211 }
212 225
213 videoFieldsSave = options.video.toJSON() 226 videoFieldsSave = options.video.toJSON()
214 227
@@ -238,14 +251,10 @@ async function updateVideoFromAP (options: {
238 options.video.set('publishedAt', videoData.publishedAt) 251 options.video.set('publishedAt', videoData.publishedAt)
239 options.video.set('privacy', videoData.privacy) 252 options.video.set('privacy', videoData.privacy)
240 options.video.set('channelId', videoData.channelId) 253 options.video.set('channelId', videoData.channelId)
254 options.video.set('views', videoData.views)
241 255
242 if (options.updateViews === true) options.video.set('views', videoData.views)
243 await options.video.save(sequelizeOptions) 256 await options.video.save(sequelizeOptions)
244 257
245 // Don't block on request
246 generateThumbnailFromUrl(options.video, options.videoObject.icon)
247 .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
248
249 { 258 {
250 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject) 259 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
251 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) 260 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
@@ -266,6 +275,25 @@ async function updateVideoFromAP (options: {
266 } 275 }
267 276
268 { 277 {
278 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(options.video, options.videoObject)
279 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
280
281 // Remove video files that do not exist anymore
282 const destroyTasks = options.video.VideoStreamingPlaylists
283 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
284 .map(f => f.destroy(sequelizeOptions))
285 await Promise.all(destroyTasks)
286
287 // Update or add other one
288 const upsertTasks = streamingPlaylistAttributes.map(a => {
289 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
290 .then(([ streamingPlaylist ]) => streamingPlaylist)
291 })
292
293 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
294 }
295
296 {
269 // Update Tags 297 // Update Tags
270 const tags = options.videoObject.tag.map(tag => tag.name) 298 const tags = options.videoObject.tag.map(tag => tag.name)
271 const tagInstances = await TagModel.findOrCreateTags(tags, t) 299 const tagInstances = await TagModel.findOrCreateTags(tags, t)
@@ -283,6 +311,11 @@ async function updateVideoFromAP (options: {
283 } 311 }
284 }) 312 })
285 313
314 // Notify our users?
315 if (wasPrivateVideo || wasUnlistedVideo) {
316 Notifier.Instance.notifyOnNewVideo(options.video)
317 }
318
286 logger.info('Remote video with uuid %s updated', options.videoObject.uuid) 319 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
287 } catch (err) { 320 } catch (err) {
288 if (options.video !== undefined && videoFieldsSave !== undefined) { 321 if (options.video !== undefined && videoFieldsSave !== undefined) {
@@ -293,10 +326,66 @@ async function updateVideoFromAP (options: {
293 logger.debug('Cannot update the remote video.', { err }) 326 logger.debug('Cannot update the remote video.', { err })
294 throw err 327 throw err
295 } 328 }
329
330 try {
331 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
332 } catch (err) {
333 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
334 }
335}
336
337async function refreshVideoIfNeeded (options: {
338 video: VideoModel,
339 fetchedType: VideoFetchByUrlType,
340 syncParam: SyncParam
341}): Promise<VideoModel> {
342 if (!options.video.isOutdated()) return options.video
343
344 // We need more attributes if the argument video was fetched with not enough joints
345 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
346
347 try {
348 const { response, videoObject } = await fetchRemoteVideo(video.url)
349 if (response.statusCode === 404) {
350 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
351
352 // Video does not exist anymore
353 await video.destroy()
354 return undefined
355 }
356
357 if (videoObject === undefined) {
358 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
359
360 await video.setAsRefreshed()
361 return video
362 }
363
364 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
365 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
366
367 const updateOptions = {
368 video,
369 videoObject,
370 account,
371 channel: channelActor.VideoChannel
372 }
373 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
374 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
375
376 return video
377 } catch (err) {
378 logger.warn('Cannot refresh video %s.', options.video.url, { err })
379
380 // Don't refresh in loop
381 await video.setAsRefreshed()
382 return video
383 }
296} 384}
297 385
298export { 386export {
299 updateVideoFromAP, 387 updateVideoFromAP,
388 refreshVideoIfNeeded,
300 federateVideoIfNeeded, 389 federateVideoIfNeeded,
301 fetchRemoteVideo, 390 fetchRemoteVideo,
302 getOrCreateVideoAndAccountAndChannel, 391 getOrCreateVideoAndAccountAndChannel,
@@ -308,10 +397,23 @@ export {
308 397
309// --------------------------------------------------------------------------- 398// ---------------------------------------------------------------------------
310 399
311function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject { 400function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
312 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT) 401 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
313 402
314 return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/') 403 const urlMediaType = url.mediaType || url.mimeType
404 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
405}
406
407function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
408 const urlMediaType = url.mediaType || url.mimeType
409
410 return urlMediaType === 'application/x-mpegURL'
411}
412
413function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
414 const urlMediaType = tag.mediaType || tag.mimeType
415
416 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
315} 417}
316 418
317async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) { 419async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
@@ -334,8 +436,14 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
334 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) 436 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
335 await Promise.all(videoFilePromises) 437 await Promise.all(videoFilePromises)
336 438
439 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject)
440 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
441 await Promise.all(playlistPromises)
442
337 // Process tags 443 // Process tags
338 const tags = videoObject.tag.map(t => t.name) 444 const tags = videoObject.tag
445 .filter(t => t.type === 'Hashtag')
446 .map(t => t.name)
339 const tagInstances = await TagModel.findOrCreateTags(tags, t) 447 const tagInstances = await TagModel.findOrCreateTags(tags, t)
340 await videoCreated.$set('Tags', tagInstances, sequelizeOptions) 448 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
341 449
@@ -359,52 +467,6 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
359 return videoCreated 467 return videoCreated
360} 468}
361 469
362async function refreshVideoIfNeeded (options: {
363 video: VideoModel,
364 fetchedType: VideoFetchByUrlType,
365 syncParam: SyncParam,
366 refreshViews: boolean
367}): Promise<VideoModel> {
368 if (!options.video.isOutdated()) return options.video
369
370 // We need more attributes if the argument video was fetched with not enough joints
371 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
372
373 try {
374 const { response, videoObject } = await fetchRemoteVideo(video.url)
375 if (response.statusCode === 404) {
376 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
377
378 // Video does not exist anymore
379 await video.destroy()
380 return undefined
381 }
382
383 if (videoObject === undefined) {
384 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
385 return video
386 }
387
388 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
389 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
390
391 const updateOptions = {
392 video,
393 videoObject,
394 account,
395 channel: channelActor.VideoChannel,
396 updateViews: options.refreshViews
397 }
398 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
399 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
400
401 return video
402 } catch (err) {
403 logger.warn('Cannot refresh video %s.', options.video.url, { err })
404 return video
405 }
406}
407
408async function videoActivityObjectToDBAttributes ( 470async function videoActivityObjectToDBAttributes (
409 videoChannel: VideoChannelModel, 471 videoChannel: VideoChannelModel,
410 videoObject: VideoTorrentObject, 472 videoObject: VideoTorrentObject,
@@ -460,17 +522,18 @@ async function videoActivityObjectToDBAttributes (
460} 522}
461 523
462function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) { 524function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
463 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[] 525 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
464 526
465 if (fileUrls.length === 0) { 527 if (fileUrls.length === 0) {
466 throw new Error('Cannot find video files for ' + video.url) 528 throw new Error('Cannot find video files for ' + video.url)
467 } 529 }
468 530
469 const attributes: VideoFileModel[] = [] 531 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
470 for (const fileUrl of fileUrls) { 532 for (const fileUrl of fileUrls) {
471 // Fetch associated magnet uri 533 // Fetch associated magnet uri
472 const magnet = videoObject.url.find(u => { 534 const magnet = videoObject.url.find(u => {
473 return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height 535 const mediaType = u.mediaType || u.mimeType
536 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
474 }) 537 })
475 538
476 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href) 539 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
@@ -480,14 +543,53 @@ function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: Vid
480 throw new Error('Cannot parse magnet URI ' + magnet.href) 543 throw new Error('Cannot parse magnet URI ' + magnet.href)
481 } 544 }
482 545
546 const mediaType = fileUrl.mediaType || fileUrl.mimeType
483 const attribute = { 547 const attribute = {
484 extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ], 548 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
485 infoHash: parsed.infoHash, 549 infoHash: parsed.infoHash,
486 resolution: fileUrl.height, 550 resolution: fileUrl.height,
487 size: fileUrl.size, 551 size: fileUrl.size,
488 videoId: video.id, 552 videoId: video.id,
489 fps: fileUrl.fps || -1 553 fps: fileUrl.fps || -1
490 } as VideoFileModel 554 }
555
556 attributes.push(attribute)
557 }
558
559 return attributes
560}
561
562function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
563 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
564 if (playlistUrls.length === 0) return []
565
566 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
567 for (const playlistUrlObject of playlistUrls) {
568 const p2pMediaLoaderInfohashes = playlistUrlObject.tag
569 .filter(t => t.type === 'Infohash')
570 .map(t => t.name)
571 if (p2pMediaLoaderInfohashes.length === 0) {
572 logger.warn('No infohashes found in AP playlist object.', { playlistUrl: playlistUrlObject })
573 continue
574 }
575
576 const segmentsSha256UrlObject = playlistUrlObject.tag
577 .find(t => {
578 return isAPPlaylistSegmentHashesUrlObject(t)
579 }) as ActivityPlaylistSegmentHashesObject
580 if (!segmentsSha256UrlObject) {
581 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
582 continue
583 }
584
585 const attribute = {
586 type: VideoStreamingPlaylistType.HLS,
587 playlistUrl: playlistUrlObject.href,
588 segmentsSha256Url: segmentsSha256UrlObject.href,
589 p2pMediaLoaderInfohashes,
590 videoId: video.id
591 }
592
491 attributes.push(attribute) 593 attributes.push(attribute)
492 } 594 }
493 595
diff --git a/server/lib/avatar.ts b/server/lib/avatar.ts
index 4b6bc3185..021426a1a 100644
--- a/server/lib/avatar.ts
+++ b/server/lib/avatar.ts
@@ -7,10 +7,11 @@ import { AccountModel } from '../models/account/account'
7import { VideoChannelModel } from '../models/video/video-channel' 7import { VideoChannelModel } from '../models/video/video-channel'
8import { extname, join } from 'path' 8import { extname, join } from 'path'
9import { retryTransactionWrapper } from '../helpers/database-utils' 9import { retryTransactionWrapper } from '../helpers/database-utils'
10import * as uuidv4 from 'uuid/v4'
10 11
11async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, accountOrChannel: AccountModel | VideoChannelModel) { 12async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, accountOrChannel: AccountModel | VideoChannelModel) {
12 const extension = extname(avatarPhysicalFile.filename) 13 const extension = extname(avatarPhysicalFile.filename)
13 const avatarName = accountOrChannel.Actor.uuid + extension 14 const avatarName = uuidv4() + extension
14 const destination = join(CONFIG.STORAGE.AVATARS_DIR, avatarName) 15 const destination = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
15 await processImage(avatarPhysicalFile, destination, AVATARS_SIZE) 16 await processImage(avatarPhysicalFile, destination, AVATARS_SIZE)
16 17
diff --git a/server/lib/blocklist.ts b/server/lib/blocklist.ts
new file mode 100644
index 000000000..1633e500c
--- /dev/null
+++ b/server/lib/blocklist.ts
@@ -0,0 +1,40 @@
1import { sequelizeTypescript } from '../initializers'
2import { AccountBlocklistModel } from '../models/account/account-blocklist'
3import { ServerBlocklistModel } from '../models/server/server-blocklist'
4
5function addAccountInBlocklist (byAccountId: number, targetAccountId: number) {
6 return sequelizeTypescript.transaction(async t => {
7 return AccountBlocklistModel.upsert({
8 accountId: byAccountId,
9 targetAccountId: targetAccountId
10 }, { transaction: t })
11 })
12}
13
14function addServerInBlocklist (byAccountId: number, targetServerId: number) {
15 return sequelizeTypescript.transaction(async t => {
16 return ServerBlocklistModel.upsert({
17 accountId: byAccountId,
18 targetServerId
19 }, { transaction: t })
20 })
21}
22
23function removeAccountFromBlocklist (accountBlock: AccountBlocklistModel) {
24 return sequelizeTypescript.transaction(async t => {
25 return accountBlock.destroy({ transaction: t })
26 })
27}
28
29function removeServerFromBlocklist (serverBlock: ServerBlocklistModel) {
30 return sequelizeTypescript.transaction(async t => {
31 return serverBlock.destroy({ transaction: t })
32 })
33}
34
35export {
36 addAccountInBlocklist,
37 addServerInBlocklist,
38 removeAccountFromBlocklist,
39 removeServerFromBlocklist
40}
diff --git a/server/lib/cache/actor-follow-score-cache.ts b/server/lib/cache/actor-follow-score-cache.ts
new file mode 100644
index 000000000..d070bde09
--- /dev/null
+++ b/server/lib/cache/actor-follow-score-cache.ts
@@ -0,0 +1,46 @@
1import { ACTOR_FOLLOW_SCORE } from '../../initializers'
2import { logger } from '../../helpers/logger'
3
4// Cache follows scores, instead of writing them too often in database
5// Keep data in memory, we don't really need Redis here as we don't really care to loose some scores
6class ActorFollowScoreCache {
7
8 private static instance: ActorFollowScoreCache
9 private pendingFollowsScore: { [ url: string ]: number } = {}
10
11 private constructor () {}
12
13 static get Instance () {
14 return this.instance || (this.instance = new this())
15 }
16
17 updateActorFollowsScore (goodInboxes: string[], badInboxes: string[]) {
18 if (goodInboxes.length === 0 && badInboxes.length === 0) return
19
20 logger.info('Updating %d good actor follows and %d bad actor follows scores in cache.', goodInboxes.length, badInboxes.length)
21
22 for (const goodInbox of goodInboxes) {
23 if (this.pendingFollowsScore[goodInbox] === undefined) this.pendingFollowsScore[goodInbox] = 0
24
25 this.pendingFollowsScore[goodInbox] += ACTOR_FOLLOW_SCORE.BONUS
26 }
27
28 for (const badInbox of badInboxes) {
29 if (this.pendingFollowsScore[badInbox] === undefined) this.pendingFollowsScore[badInbox] = 0
30
31 this.pendingFollowsScore[badInbox] += ACTOR_FOLLOW_SCORE.PENALTY
32 }
33 }
34
35 getPendingFollowsScoreCopy () {
36 return this.pendingFollowsScore
37 }
38
39 clearPendingFollowsScore () {
40 this.pendingFollowsScore = {}
41 }
42}
43
44export {
45 ActorFollowScoreCache
46}
diff --git a/server/lib/cache/index.ts b/server/lib/cache/index.ts
index 54eb983fa..e921d04a7 100644
--- a/server/lib/cache/index.ts
+++ b/server/lib/cache/index.ts
@@ -1,2 +1,3 @@
1export * from './actor-follow-score-cache'
1export * from './videos-preview-cache' 2export * from './videos-preview-cache'
2export * from './videos-caption-cache' 3export * from './videos-caption-cache'
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index fc013e0c3..b2c376e20 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import * as Bluebird from 'bluebird' 2import * as Bluebird from 'bluebird'
3import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n' 3import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n'
4import { CONFIG, CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, STATIC_PATHS } from '../initializers' 4import { CONFIG, CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE } from '../initializers'
5import { join } from 'path' 5import { join } from 'path'
6import { escapeHTML } from '../helpers/core-utils' 6import { escapeHTML } from '../helpers/core-utils'
7import { VideoModel } from '../models/video/video' 7import { VideoModel } from '../models/video/video'
@@ -18,21 +18,13 @@ export class ClientHtml {
18 ClientHtml.htmlCache = {} 18 ClientHtml.htmlCache = {}
19 } 19 }
20 20
21 static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) { 21 static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
22 const path = ClientHtml.getIndexPath(req, res, paramLang) 22 const html = await ClientHtml.getIndexHTML(req, res, paramLang)
23 if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
24
25 const buffer = await readFile(path)
26 23
27 let html = buffer.toString() 24 let customHtml = ClientHtml.addTitleTag(html)
28 25 customHtml = ClientHtml.addDescriptionTag(customHtml)
29 html = ClientHtml.addTitleTag(html)
30 html = ClientHtml.addDescriptionTag(html)
31 html = ClientHtml.addCustomCSS(html)
32 26
33 ClientHtml.htmlCache[path] = html 27 return customHtml
34
35 return html
36 } 28 }
37 29
38 static async getWatchHTMLPage (videoId: string, req: express.Request, res: express.Response) { 30 static async getWatchHTMLPage (videoId: string, req: express.Request, res: express.Response) {
@@ -55,7 +47,26 @@ export class ClientHtml {
55 return ClientHtml.getIndexHTML(req, res) 47 return ClientHtml.getIndexHTML(req, res)
56 } 48 }
57 49
58 return ClientHtml.addOpenGraphAndOEmbedTags(html, video) 50 let customHtml = ClientHtml.addTitleTag(html, escapeHTML(video.name))
51 customHtml = ClientHtml.addDescriptionTag(customHtml, escapeHTML(video.description))
52 customHtml = ClientHtml.addOpenGraphAndOEmbedTags(customHtml, video)
53
54 return customHtml
55 }
56
57 private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
58 const path = ClientHtml.getIndexPath(req, res, paramLang)
59 if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
60
61 const buffer = await readFile(path)
62
63 let html = buffer.toString()
64
65 html = ClientHtml.addCustomCSS(html)
66
67 ClientHtml.htmlCache[path] = html
68
69 return html
59 } 70 }
60 71
61 private static getIndexPath (req: express.Request, res: express.Response, paramLang?: string) { 72 private static getIndexPath (req: express.Request, res: express.Response, paramLang?: string) {
@@ -81,14 +92,18 @@ export class ClientHtml {
81 return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html') 92 return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html')
82 } 93 }
83 94
84 private static addTitleTag (htmlStringPage: string) { 95 private static addTitleTag (htmlStringPage: string, title?: string) {
85 const titleTag = '<title>' + CONFIG.INSTANCE.NAME + '</title>' 96 let text = title || CONFIG.INSTANCE.NAME
97 if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
98
99 const titleTag = `<title>${text}</title>`
86 100
87 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag) 101 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
88 } 102 }
89 103
90 private static addDescriptionTag (htmlStringPage: string) { 104 private static addDescriptionTag (htmlStringPage: string, description?: string) {
91 const descriptionTag = `<meta name="description" content="${CONFIG.INSTANCE.SHORT_DESCRIPTION}" />` 105 const content = description || CONFIG.INSTANCE.SHORT_DESCRIPTION
106 const descriptionTag = `<meta name="description" content="${content}" />`
92 107
93 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag) 108 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
94 } 109 }
@@ -100,8 +115,8 @@ export class ClientHtml {
100 } 115 }
101 116
102 private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) { 117 private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
103 const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName() 118 const previewUrl = CONFIG.WEBSERVER.URL + video.getPreviewStaticPath()
104 const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 119 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
105 120
106 const videoNameEscaped = escapeHTML(video.name) 121 const videoNameEscaped = escapeHTML(video.name)
107 const videoDescriptionEscaped = escapeHTML(video.description) 122 const videoDescriptionEscaped = escapeHTML(video.description)
@@ -172,8 +187,8 @@ export class ClientHtml {
172 // Schema.org 187 // Schema.org
173 tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` 188 tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
174 189
175 // SEO 190 // SEO, use origin video url so Google does not index remote videos
176 tagsString += `<link rel="canonical" href="${videoUrl}" />` 191 tagsString += `<link rel="canonical" href="${video.url}" />`
177 192
178 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.OPENGRAPH_AND_OEMBED, tagsString) 193 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.OPENGRAPH_AND_OEMBED, tagsString)
179 } 194 }
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 9327792fb..672414cc0 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -1,5 +1,4 @@
1import { createTransport, Transporter } from 'nodemailer' 1import { createTransport, Transporter } from 'nodemailer'
2import { UserRight } from '../../shared/models/users'
3import { isTestInstance } from '../helpers/core-utils' 2import { isTestInstance } from '../helpers/core-utils'
4import { bunyanLogger, logger } from '../helpers/logger' 3import { bunyanLogger, logger } from '../helpers/logger'
5import { CONFIG } from '../initializers' 4import { CONFIG } from '../initializers'
@@ -8,6 +7,11 @@ import { VideoModel } from '../models/video/video'
8import { JobQueue } from './job-queue' 7import { JobQueue } from './job-queue'
9import { EmailPayload } from './job-queue/handlers/email' 8import { EmailPayload } from './job-queue/handlers/email'
10import { readFileSync } from 'fs-extra' 9import { readFileSync } from 'fs-extra'
10import { VideoCommentModel } from '../models/video/video-comment'
11import { VideoAbuseModel } from '../models/video/video-abuse'
12import { VideoBlacklistModel } from '../models/video/video-blacklist'
13import { VideoImportModel } from '../models/video/video-import'
14import { ActorFollowModel } from '../models/activitypub/actor-follow'
11 15
12class Emailer { 16class Emailer {
13 17
@@ -22,7 +26,7 @@ class Emailer {
22 if (this.initialized === true) return 26 if (this.initialized === true) return
23 this.initialized = true 27 this.initialized = true
24 28
25 if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) { 29 if (Emailer.isEnabled()) {
26 logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT) 30 logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
27 31
28 let tls 32 let tls
@@ -57,6 +61,10 @@ class Emailer {
57 } 61 }
58 } 62 }
59 63
64 static isEnabled () {
65 return !!CONFIG.SMTP.HOSTNAME && !!CONFIG.SMTP.PORT
66 }
67
60 async checkConnectionOrDie () { 68 async checkConnectionOrDie () {
61 if (!this.transporter) return 69 if (!this.transporter) return
62 70
@@ -72,50 +80,158 @@ class Emailer {
72 } 80 }
73 } 81 }
74 82
75 addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { 83 addNewVideoFromSubscriberNotification (to: string[], video: VideoModel) {
84 const channelName = video.VideoChannel.getDisplayName()
85 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
86
76 const text = `Hi dear user,\n\n` + 87 const text = `Hi dear user,\n\n` +
77 `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + 88 `Your subscription ${channelName} just published a new video: ${video.name}` +
78 `Please follow this link to reset it: ${resetPasswordUrl}\n\n` + 89 `\n\n` +
79 `If you are not the person who initiated this request, please ignore this email.\n\n` + 90 `You can view it on ${videoUrl} ` +
91 `\n\n` +
80 `Cheers,\n` + 92 `Cheers,\n` +
81 `PeerTube.` 93 `PeerTube.`
82 94
83 const emailPayload: EmailPayload = { 95 const emailPayload: EmailPayload = {
84 to: [ to ], 96 to,
85 subject: 'Reset your PeerTube password', 97 subject: channelName + ' just published a new video',
86 text 98 text
87 } 99 }
88 100
89 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 101 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
90 } 102 }
91 103
92 addVerifyEmailJob (to: string, verifyEmailUrl: string) { 104 addNewFollowNotification (to: string[], actorFollow: ActorFollowModel, followType: 'account' | 'channel') {
93 const text = `Welcome to PeerTube,\n\n` + 105 const followerName = actorFollow.ActorFollower.Account.getDisplayName()
94 `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` + 106 const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
95 `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` + 107
96 `If you are not the person who initiated this request, please ignore this email.\n\n` + 108 const text = `Hi dear user,\n\n` +
109 `Your ${followType} ${followingName} has a new subscriber: ${followerName}` +
110 `\n\n` +
97 `Cheers,\n` + 111 `Cheers,\n` +
98 `PeerTube.` 112 `PeerTube.`
99 113
100 const emailPayload: EmailPayload = { 114 const emailPayload: EmailPayload = {
101 to: [ to ], 115 to,
102 subject: 'Verify your PeerTube email', 116 subject: 'New follower on your channel ' + followingName,
117 text
118 }
119
120 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
121 }
122
123 myVideoPublishedNotification (to: string[], video: VideoModel) {
124 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
125
126 const text = `Hi dear user,\n\n` +
127 `Your video ${video.name} has been published.` +
128 `\n\n` +
129 `You can view it on ${videoUrl} ` +
130 `\n\n` +
131 `Cheers,\n` +
132 `PeerTube.`
133
134 const emailPayload: EmailPayload = {
135 to,
136 subject: `Your video ${video.name} is published`,
137 text
138 }
139
140 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
141 }
142
143 myVideoImportSuccessNotification (to: string[], videoImport: VideoImportModel) {
144 const videoUrl = CONFIG.WEBSERVER.URL + videoImport.Video.getWatchStaticPath()
145
146 const text = `Hi dear user,\n\n` +
147 `Your video import ${videoImport.getTargetIdentifier()} is finished.` +
148 `\n\n` +
149 `You can view the imported video on ${videoUrl} ` +
150 `\n\n` +
151 `Cheers,\n` +
152 `PeerTube.`
153
154 const emailPayload: EmailPayload = {
155 to,
156 subject: `Your video import ${videoImport.getTargetIdentifier()} is finished`,
157 text
158 }
159
160 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
161 }
162
163 myVideoImportErrorNotification (to: string[], videoImport: VideoImportModel) {
164 const importUrl = CONFIG.WEBSERVER.URL + '/my-account/video-imports'
165
166 const text = `Hi dear user,\n\n` +
167 `Your video import ${videoImport.getTargetIdentifier()} encountered an error.` +
168 `\n\n` +
169 `See your videos import dashboard for more information: ${importUrl}` +
170 `\n\n` +
171 `Cheers,\n` +
172 `PeerTube.`
173
174 const emailPayload: EmailPayload = {
175 to,
176 subject: `Your video import ${videoImport.getTargetIdentifier()} encountered an error`,
177 text
178 }
179
180 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
181 }
182
183 addNewCommentOnMyVideoNotification (to: string[], comment: VideoCommentModel) {
184 const accountName = comment.Account.getDisplayName()
185 const video = comment.Video
186 const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
187
188 const text = `Hi dear user,\n\n` +
189 `A new comment has been posted by ${accountName} on your video ${video.name}` +
190 `\n\n` +
191 `You can view it on ${commentUrl} ` +
192 `\n\n` +
193 `Cheers,\n` +
194 `PeerTube.`
195
196 const emailPayload: EmailPayload = {
197 to,
198 subject: 'New comment on your video ' + video.name,
103 text 199 text
104 } 200 }
105 201
106 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 202 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
107 } 203 }
108 204
109 async addVideoAbuseReportJob (videoId: number) { 205 addNewCommentMentionNotification (to: string[], comment: VideoCommentModel) {
110 const video = await VideoModel.load(videoId) 206 const accountName = comment.Account.getDisplayName()
111 if (!video) throw new Error('Unknown Video id during Abuse report.') 207 const video = comment.Video
208 const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
209
210 const text = `Hi dear user,\n\n` +
211 `${accountName} mentioned you on video ${video.name}` +
212 `\n\n` +
213 `You can view the comment on ${commentUrl} ` +
214 `\n\n` +
215 `Cheers,\n` +
216 `PeerTube.`
217
218 const emailPayload: EmailPayload = {
219 to,
220 subject: 'Mention on video ' + video.name,
221 text
222 }
223
224 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
225 }
226
227 addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
228 const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
112 229
113 const text = `Hi,\n\n` + 230 const text = `Hi,\n\n` +
114 `Your instance received an abuse for the following video ${video.url}\n\n` + 231 `${CONFIG.WEBSERVER.HOST} received an abuse for the following video ${videoUrl}\n\n` +
115 `Cheers,\n` + 232 `Cheers,\n` +
116 `PeerTube.` 233 `PeerTube.`
117 234
118 const to = await UserModel.listEmailsWithRight(UserRight.MANAGE_VIDEO_ABUSES)
119 const emailPayload: EmailPayload = { 235 const emailPayload: EmailPayload = {
120 to, 236 to,
121 subject: '[PeerTube] Received a video abuse', 237 subject: '[PeerTube] Received a video abuse',
@@ -125,16 +241,27 @@ class Emailer {
125 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 241 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
126 } 242 }
127 243
128 async addVideoBlacklistReportJob (videoId: number, reason?: string) { 244 addNewUserRegistrationNotification (to: string[], user: UserModel) {
129 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 245 const text = `Hi,\n\n` +
130 if (!video) throw new Error('Unknown Video id during Blacklist report.') 246 `User ${user.username} just registered on ${CONFIG.WEBSERVER.HOST} PeerTube instance.\n\n` +
131 // It's not our user 247 `Cheers,\n` +
132 if (video.remote === true) return 248 `PeerTube.`
249
250 const emailPayload: EmailPayload = {
251 to,
252 subject: '[PeerTube] New user registration on ' + CONFIG.WEBSERVER.HOST,
253 text
254 }
255
256 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
257 }
133 258
134 const user = await UserModel.loadById(video.VideoChannel.Account.userId) 259 addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
260 const videoName = videoBlacklist.Video.name
261 const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
135 262
136 const reasonString = reason ? ` for the following reason: ${reason}` : '' 263 const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
137 const blockedString = `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.` 264 const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
138 265
139 const text = 'Hi,\n\n' + 266 const text = 'Hi,\n\n' +
140 blockedString + 267 blockedString +
@@ -142,33 +269,26 @@ class Emailer {
142 'Cheers,\n' + 269 'Cheers,\n' +
143 `PeerTube.` 270 `PeerTube.`
144 271
145 const to = user.email
146 const emailPayload: EmailPayload = { 272 const emailPayload: EmailPayload = {
147 to: [ to ], 273 to,
148 subject: `[PeerTube] Video ${video.name} blacklisted`, 274 subject: `[PeerTube] Video ${videoName} blacklisted`,
149 text 275 text
150 } 276 }
151 277
152 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 278 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
153 } 279 }
154 280
155 async addVideoUnblacklistReportJob (videoId: number) { 281 addVideoUnblacklistNotification (to: string[], video: VideoModel) {
156 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 282 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
157 if (!video) throw new Error('Unknown Video id during Blacklist report.')
158 // It's not our user
159 if (video.remote === true) return
160
161 const user = await UserModel.loadById(video.VideoChannel.Account.userId)
162 283
163 const text = 'Hi,\n\n' + 284 const text = 'Hi,\n\n' +
164 `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` + 285 `Your video ${video.name} (${videoUrl}) on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` +
165 '\n\n' + 286 '\n\n' +
166 'Cheers,\n' + 287 'Cheers,\n' +
167 `PeerTube.` 288 `PeerTube.`
168 289
169 const to = user.email
170 const emailPayload: EmailPayload = { 290 const emailPayload: EmailPayload = {
171 to: [ to ], 291 to,
172 subject: `[PeerTube] Video ${video.name} unblacklisted`, 292 subject: `[PeerTube] Video ${video.name} unblacklisted`,
173 text 293 text
174 } 294 }
@@ -176,6 +296,40 @@ class Emailer {
176 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 296 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
177 } 297 }
178 298
299 addPasswordResetEmailJob (to: string, resetPasswordUrl: string) {
300 const text = `Hi dear user,\n\n` +
301 `A reset password procedure for your account ${to} has been requested on ${CONFIG.WEBSERVER.HOST} ` +
302 `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
303 `If you are not the person who initiated this request, please ignore this email.\n\n` +
304 `Cheers,\n` +
305 `PeerTube.`
306
307 const emailPayload: EmailPayload = {
308 to: [ to ],
309 subject: 'Reset your PeerTube password',
310 text
311 }
312
313 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
314 }
315
316 addVerifyEmailJob (to: string, verifyEmailUrl: string) {
317 const text = `Welcome to PeerTube,\n\n` +
318 `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` +
319 `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
320 `If you are not the person who initiated this request, please ignore this email.\n\n` +
321 `Cheers,\n` +
322 `PeerTube.`
323
324 const emailPayload: EmailPayload = {
325 to: [ to ],
326 subject: 'Verify your PeerTube email',
327 text
328 }
329
330 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
331 }
332
179 addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) { 333 addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) {
180 const reasonString = reason ? ` for the following reason: ${reason}` : '' 334 const reasonString = reason ? ` for the following reason: ${reason}` : ''
181 const blockedWord = blocked ? 'blocked' : 'unblocked' 335 const blockedWord = blocked ? 'blocked' : 'unblocked'
@@ -197,13 +351,32 @@ class Emailer {
197 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 351 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
198 } 352 }
199 353
200 sendMail (to: string[], subject: string, text: string) { 354 addContactFormJob (fromEmail: string, fromName: string, body: string) {
201 if (!this.transporter) { 355 const text = 'Hello dear admin,\n\n' +
356 fromName + ' sent you a message' +
357 '\n\n---------------------------------------\n\n' +
358 body +
359 '\n\n---------------------------------------\n\n' +
360 'Cheers,\n' +
361 'PeerTube.'
362
363 const emailPayload: EmailPayload = {
364 from: fromEmail,
365 to: [ CONFIG.ADMIN.EMAIL ],
366 subject: '[PeerTube] Contact form submitted',
367 text
368 }
369
370 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
371 }
372
373 sendMail (to: string[], subject: string, text: string, from?: string) {
374 if (!Emailer.isEnabled()) {
202 throw new Error('Cannot send mail because SMTP is not configured.') 375 throw new Error('Cannot send mail because SMTP is not configured.')
203 } 376 }
204 377
205 return this.transporter.sendMail({ 378 return this.transporter.sendMail({
206 from: CONFIG.SMTP.FROM_ADDRESS, 379 from: from || CONFIG.SMTP.FROM_ADDRESS,
207 to: to.join(','), 380 to: to.join(','),
208 subject, 381 subject,
209 text 382 text
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
new file mode 100644
index 000000000..3575981f4
--- /dev/null
+++ b/server/lib/hls.ts
@@ -0,0 +1,164 @@
1import { VideoModel } from '../models/video/video'
2import { basename, join, dirname } from 'path'
3import { CONFIG, HLS_PLAYLIST_DIRECTORY } from '../initializers'
4import { close, ensureDir, move, open, outputJSON, pathExists, read, readFile, remove, writeFile } from 'fs-extra'
5import { getVideoFileSize } from '../helpers/ffmpeg-utils'
6import { sha256 } from '../helpers/core-utils'
7import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
8import { logger } from '../helpers/logger'
9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
10import { generateRandomString } from '../helpers/utils'
11import { flatten, uniq } from 'lodash'
12
13async function updateMasterHLSPlaylist (video: VideoModel) {
14 const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
15 const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
16 const masterPlaylistPath = join(directory, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
17
18 for (const file of video.VideoFiles) {
19 // If we did not generated a playlist for this resolution, skip
20 const filePlaylistPath = join(directory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
21 if (await pathExists(filePlaylistPath) === false) continue
22
23 const videoFilePath = video.getVideoFilePath(file)
24
25 const size = await getVideoFileSize(videoFilePath)
26
27 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
28 const resolution = `RESOLUTION=${size.width}x${size.height}`
29
30 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
31 if (file.fps) line += ',FRAME-RATE=' + file.fps
32
33 masterPlaylists.push(line)
34 masterPlaylists.push(VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
35 }
36
37 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
38}
39
40async function updateSha256Segments (video: VideoModel) {
41 const json: { [filename: string]: { [range: string]: string } } = {}
42
43 const playlistDirectory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
44
45 // For all the resolutions available for this video
46 for (const file of video.VideoFiles) {
47 const rangeHashes: { [range: string]: string } = {}
48
49 const videoPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution))
50 const playlistPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
51
52 // Maybe the playlist is not generated for this resolution yet
53 if (!await pathExists(playlistPath)) continue
54
55 const playlistContent = await readFile(playlistPath)
56 const ranges = getRangesFromPlaylist(playlistContent.toString())
57
58 const fd = await open(videoPath, 'r')
59 for (const range of ranges) {
60 const buf = Buffer.alloc(range.length)
61 await read(fd, buf, 0, range.length, range.offset)
62
63 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
64 }
65 await close(fd)
66
67 const videoFilename = VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution)
68 json[videoFilename] = rangeHashes
69 }
70
71 const outputPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
72 await outputJSON(outputPath, json)
73}
74
75function getRangesFromPlaylist (playlistContent: string) {
76 const ranges: { offset: number, length: number }[] = []
77 const lines = playlistContent.split('\n')
78 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
79
80 for (const line of lines) {
81 const captured = regex.exec(line)
82
83 if (captured) {
84 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
85 }
86 }
87
88 return ranges
89}
90
91function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) {
92 let timer
93
94 logger.info('Importing HLS playlist %s', playlistUrl)
95
96 return new Promise<string>(async (res, rej) => {
97 const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
98
99 await ensureDir(tmpDirectory)
100
101 timer = setTimeout(() => {
102 deleteTmpDirectory(tmpDirectory)
103
104 return rej(new Error('HLS download timeout.'))
105 }, timeout)
106
107 try {
108 // Fetch master playlist
109 const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
110
111 const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
112 const fileUrls = uniq(flatten(await Promise.all(subRequests)))
113
114 logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
115
116 for (const fileUrl of fileUrls) {
117 const destPath = join(tmpDirectory, basename(fileUrl))
118
119 await doRequestAndSaveToFile({ uri: fileUrl }, destPath)
120 }
121
122 clearTimeout(timer)
123
124 await move(tmpDirectory, destinationDir, { overwrite: true })
125
126 return res()
127 } catch (err) {
128 deleteTmpDirectory(tmpDirectory)
129
130 return rej(err)
131 }
132 })
133
134 function deleteTmpDirectory (directory: string) {
135 remove(directory)
136 .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
137 }
138
139 async function fetchUniqUrls (playlistUrl: string) {
140 const { body } = await doRequest<string>({ uri: playlistUrl })
141
142 if (!body) return []
143
144 const urls = body.split('\n')
145 .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
146 .map(url => {
147 if (url.startsWith('http://') || url.startsWith('https://')) return url
148
149 return `${dirname(playlistUrl)}/${url}`
150 })
151
152 return uniq(urls)
153 }
154}
155
156// ---------------------------------------------------------------------------
157
158export {
159 updateMasterHLSPlaylist,
160 updateSha256Segments,
161 downloadPlaylistSegments
162}
163
164// ---------------------------------------------------------------------------
diff --git a/server/lib/job-queue/handlers/activitypub-follow.ts b/server/lib/job-queue/handlers/activitypub-follow.ts
index 36d0f237b..b4d381062 100644
--- a/server/lib/job-queue/handlers/activitypub-follow.ts
+++ b/server/lib/job-queue/handlers/activitypub-follow.ts
@@ -8,6 +8,7 @@ import { getOrCreateActorAndServerAndModel } from '../../activitypub/actor'
8import { retryTransactionWrapper } from '../../../helpers/database-utils' 8import { retryTransactionWrapper } from '../../../helpers/database-utils'
9import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 9import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
10import { ActorModel } from '../../../models/activitypub/actor' 10import { ActorModel } from '../../../models/activitypub/actor'
11import { Notifier } from '../../notifier'
11 12
12export type ActivitypubFollowPayload = { 13export type ActivitypubFollowPayload = {
13 followerActorId: number 14 followerActorId: number
@@ -42,7 +43,7 @@ export {
42 43
43// --------------------------------------------------------------------------- 44// ---------------------------------------------------------------------------
44 45
45function follow (fromActor: ActorModel, targetActor: ActorModel) { 46async function follow (fromActor: ActorModel, targetActor: ActorModel) {
46 if (fromActor.id === targetActor.id) { 47 if (fromActor.id === targetActor.id) {
47 throw new Error('Follower is the same than target actor.') 48 throw new Error('Follower is the same than target actor.')
48 } 49 }
@@ -50,7 +51,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
50 // Same server, direct accept 51 // Same server, direct accept
51 const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending' 52 const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending'
52 53
53 return sequelizeTypescript.transaction(async t => { 54 const actorFollow = await sequelizeTypescript.transaction(async t => {
54 const [ actorFollow ] = await ActorFollowModel.findOrCreate({ 55 const [ actorFollow ] = await ActorFollowModel.findOrCreate({
55 where: { 56 where: {
56 actorId: fromActor.id, 57 actorId: fromActor.id,
@@ -68,5 +69,9 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
68 69
69 // Send a notification to remote server if our follow is not already accepted 70 // Send a notification to remote server if our follow is not already accepted
70 if (actorFollow.state !== 'accepted') await sendFollow(actorFollow) 71 if (actorFollow.state !== 'accepted') await sendFollow(actorFollow)
72
73 return actorFollow
71 }) 74 })
75
76 if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewFollow(actorFollow)
72} 77}
diff --git a/server/lib/job-queue/handlers/activitypub-http-broadcast.ts b/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
index 03a9e12a4..9493945ff 100644
--- a/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
@@ -3,8 +3,9 @@ import * as Bluebird from 'bluebird'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { doRequest } from '../../../helpers/requests' 4import { doRequest } from '../../../helpers/requests'
5import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 5import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
6import { buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils' 6import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils'
7import { BROADCAST_CONCURRENCY, JOB_REQUEST_TIMEOUT } from '../../../initializers' 7import { BROADCAST_CONCURRENCY, JOB_REQUEST_TIMEOUT } from '../../../initializers'
8import { ActorFollowScoreCache } from '../../cache'
8 9
9export type ActivitypubHttpBroadcastPayload = { 10export type ActivitypubHttpBroadcastPayload = {
10 uris: string[] 11 uris: string[]
@@ -25,7 +26,8 @@ async function processActivityPubHttpBroadcast (job: Bull.Job) {
25 uri: '', 26 uri: '',
26 json: body, 27 json: body,
27 httpSignature: httpSignatureOptions, 28 httpSignature: httpSignatureOptions,
28 timeout: JOB_REQUEST_TIMEOUT 29 timeout: JOB_REQUEST_TIMEOUT,
30 headers: buildGlobalHeaders(body)
29 } 31 }
30 32
31 const badUrls: string[] = [] 33 const badUrls: string[] = []
@@ -37,7 +39,7 @@ async function processActivityPubHttpBroadcast (job: Bull.Job) {
37 .catch(() => badUrls.push(uri)) 39 .catch(() => badUrls.push(uri))
38 }, { concurrency: BROADCAST_CONCURRENCY }) 40 }, { concurrency: BROADCAST_CONCURRENCY })
39 41
40 return ActorFollowModel.updateActorFollowsScore(goodUrls, badUrls, undefined) 42 return ActorFollowScoreCache.Instance.updateActorFollowsScore(goodUrls, badUrls)
41} 43}
42 44
43// --------------------------------------------------------------------------- 45// ---------------------------------------------------------------------------
diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
index 42217c27c..67ccfa995 100644
--- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
@@ -23,7 +23,7 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
23 if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) 23 if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
24 24
25 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = { 25 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
26 'activity': items => processActivities(items), 26 'activity': items => processActivities(items, { outboxUrl: payload.uri }),
27 'video-likes': items => createRates(items, video, 'like'), 27 'video-likes': items => createRates(items, video, 'like'),
28 'video-dislikes': items => createRates(items, video, 'dislike'), 28 'video-dislikes': items => createRates(items, video, 'dislike'),
29 'video-shares': items => addVideoShares(items, video), 29 'video-shares': items => addVideoShares(items, video),
diff --git a/server/lib/job-queue/handlers/activitypub-http-unicast.ts b/server/lib/job-queue/handlers/activitypub-http-unicast.ts
index c90d735f6..3973dcdc8 100644
--- a/server/lib/job-queue/handlers/activitypub-http-unicast.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-unicast.ts
@@ -1,9 +1,9 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger' 2import { logger } from '../../../helpers/logger'
3import { doRequest } from '../../../helpers/requests' 3import { doRequest } from '../../../helpers/requests'
4import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 4import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils'
5import { buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils'
6import { JOB_REQUEST_TIMEOUT } from '../../../initializers' 5import { JOB_REQUEST_TIMEOUT } from '../../../initializers'
6import { ActorFollowScoreCache } from '../../cache'
7 7
8export type ActivitypubHttpUnicastPayload = { 8export type ActivitypubHttpUnicastPayload = {
9 uri: string 9 uri: string
@@ -25,14 +25,15 @@ async function processActivityPubHttpUnicast (job: Bull.Job) {
25 uri, 25 uri,
26 json: body, 26 json: body,
27 httpSignature: httpSignatureOptions, 27 httpSignature: httpSignatureOptions,
28 timeout: JOB_REQUEST_TIMEOUT 28 timeout: JOB_REQUEST_TIMEOUT,
29 headers: buildGlobalHeaders(body)
29 } 30 }
30 31
31 try { 32 try {
32 await doRequest(options) 33 await doRequest(options)
33 ActorFollowModel.updateActorFollowsScore([ uri ], [], undefined) 34 ActorFollowScoreCache.Instance.updateActorFollowsScore([ uri ], [])
34 } catch (err) { 35 } catch (err) {
35 ActorFollowModel.updateActorFollowsScore([], [ uri ], undefined) 36 ActorFollowScoreCache.Instance.updateActorFollowsScore([], [ uri ])
36 37
37 throw err 38 throw err
38 } 39 }
diff --git a/server/lib/job-queue/handlers/activitypub-refresher.ts b/server/lib/job-queue/handlers/activitypub-refresher.ts
new file mode 100644
index 000000000..454b975fe
--- /dev/null
+++ b/server/lib/job-queue/handlers/activitypub-refresher.ts
@@ -0,0 +1,54 @@
1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger'
3import { fetchVideoByUrl } from '../../../helpers/video'
4import { refreshVideoIfNeeded, refreshActorIfNeeded } from '../../activitypub'
5import { ActorModel } from '../../../models/activitypub/actor'
6
7export type RefreshPayload = {
8 type: 'video' | 'actor'
9 url: string
10}
11
12async function refreshAPObject (job: Bull.Job) {
13 const payload = job.data as RefreshPayload
14
15 logger.info('Processing AP refresher in job %d for %s.', job.id, payload.url)
16
17 if (payload.type === 'video') return refreshVideo(payload.url)
18 if (payload.type === 'actor') return refreshActor(payload.url)
19}
20
21// ---------------------------------------------------------------------------
22
23export {
24 refreshActor,
25 refreshAPObject
26}
27
28// ---------------------------------------------------------------------------
29
30async function refreshVideo (videoUrl: string) {
31 const fetchType = 'all' as 'all'
32 const syncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true }
33
34 const videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
35 if (videoFromDatabase) {
36 const refreshOptions = {
37 video: videoFromDatabase,
38 fetchedType: fetchType,
39 syncParam
40 }
41
42 await refreshVideoIfNeeded(refreshOptions)
43 }
44}
45
46async function refreshActor (actorUrl: string) {
47 const fetchType = 'all' as 'all'
48 const actor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorUrl)
49
50 if (actor) {
51 await refreshActorIfNeeded(actor, fetchType)
52 }
53
54}
diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts
index 73d98ae54..220d0af32 100644
--- a/server/lib/job-queue/handlers/email.ts
+++ b/server/lib/job-queue/handlers/email.ts
@@ -6,13 +6,14 @@ export type EmailPayload = {
6 to: string[] 6 to: string[]
7 subject: string 7 subject: string
8 text: string 8 text: string
9 from?: string
9} 10}
10 11
11async function processEmail (job: Bull.Job) { 12async function processEmail (job: Bull.Job) {
12 const payload = job.data as EmailPayload 13 const payload = job.data as EmailPayload
13 logger.info('Processing email in job %d.', job.id) 14 logger.info('Processing email in job %d.', job.id)
14 15
15 return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text) 16 return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text, payload.from)
16} 17}
17 18
18// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
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 36092665e..4961d4502 100644
--- a/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts
+++ b/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts
@@ -1,8 +1,12 @@
1import { buildSignedActivity } from '../../../../helpers/activitypub' 1import { buildSignedActivity } from '../../../../helpers/activitypub'
2import { getServerActor } from '../../../../helpers/utils' 2import { getServerActor } from '../../../../helpers/utils'
3import { ActorModel } from '../../../../models/activitypub/actor' 3import { ActorModel } from '../../../../models/activitypub/actor'
4import { sha256 } from '../../../../helpers/core-utils'
5import { HTTP_SIGNATURE } from '../../../../initializers'
4 6
5async function computeBody (payload: { body: any, signatureActorId?: number }) { 7type Payload = { body: any, signatureActorId?: number }
8
9async function computeBody (payload: Payload) {
6 let body = payload.body 10 let body = payload.body
7 11
8 if (payload.signatureActorId) { 12 if (payload.signatureActorId) {
@@ -14,7 +18,7 @@ async function computeBody (payload: { body: any, signatureActorId?: number }) {
14 return body 18 return body
15} 19}
16 20
17async function buildSignedRequestOptions (payload: { signatureActorId?: number }) { 21async function buildSignedRequestOptions (payload: Payload) {
18 let actor: ActorModel | null 22 let actor: ActorModel | null
19 if (payload.signatureActorId) { 23 if (payload.signatureActorId) {
20 actor = await ActorModel.load(payload.signatureActorId) 24 actor = await ActorModel.load(payload.signatureActorId)
@@ -26,14 +30,29 @@ async function buildSignedRequestOptions (payload: { signatureActorId?: number }
26 30
27 const keyId = actor.getWebfingerUrl() 31 const keyId = actor.getWebfingerUrl()
28 return { 32 return {
29 algorithm: 'rsa-sha256', 33 algorithm: HTTP_SIGNATURE.ALGORITHM,
30 authorizationHeaderName: 'Signature', 34 authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
31 keyId, 35 keyId,
32 key: actor.privateKey 36 key: actor.privateKey,
37 headers: HTTP_SIGNATURE.HEADERS_TO_SIGN
38 }
39}
40
41function buildGlobalHeaders (body: any) {
42 return {
43 'Digest': buildDigest(body)
33 } 44 }
34} 45}
35 46
47function buildDigest (body: any) {
48 const rawBody = typeof body === 'string' ? body : JSON.stringify(body)
49
50 return 'SHA-256=' + sha256(rawBody, 'base64')
51}
52
36export { 53export {
54 buildDigest,
55 buildGlobalHeaders,
37 computeBody, 56 computeBody,
38 buildSignedRequestOptions 57 buildSignedRequestOptions
39} 58}
diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts
index 1463c93fc..04983155c 100644
--- a/server/lib/job-queue/handlers/video-file.ts
+++ b/server/lib/job-queue/handlers/video-file.ts
@@ -5,16 +5,18 @@ import { VideoModel } from '../../../models/video/video'
5import { JobQueue } from '../job-queue' 5import { JobQueue } from '../job-queue'
6import { federateVideoIfNeeded } from '../../activitypub' 6import { federateVideoIfNeeded } from '../../activitypub'
7import { retryTransactionWrapper } from '../../../helpers/database-utils' 7import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { sequelizeTypescript } from '../../../initializers' 8import { sequelizeTypescript, CONFIG } from '../../../initializers'
9import * as Bluebird from 'bluebird' 9import * as Bluebird from 'bluebird'
10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' 10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
11import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding' 11import { generateHlsPlaylist, importVideoFile, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding'
12import { Notifier } from '../../notifier'
12 13
13export type VideoFilePayload = { 14export type VideoFilePayload = {
14 videoUUID: string 15 videoUUID: string
15 isNewVideo?: boolean
16 resolution?: VideoResolution 16 resolution?: VideoResolution
17 isNewVideo?: boolean
17 isPortraitMode?: boolean 18 isPortraitMode?: boolean
19 generateHlsPlaylist?: boolean
18} 20}
19 21
20export type VideoFileImportPayload = { 22export type VideoFileImportPayload = {
@@ -50,34 +52,51 @@ async function processVideoFile (job: Bull.Job) {
50 return undefined 52 return undefined
51 } 53 }
52 54
53 // Transcoding in other resolution 55 if (payload.generateHlsPlaylist) {
54 if (payload.resolution) { 56 await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false)
57
58 await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
59 } else if (payload.resolution) { // Transcoding in other resolution
55 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false) 60 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
56 61
57 await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video) 62 await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video, payload)
58 } else { 63 } else {
59 await optimizeOriginalVideofile(video) 64 await optimizeVideofile(video)
60 65
61 await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo) 66 await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload)
62 } 67 }
63 68
64 return video 69 return video
65} 70}
66 71
67async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) { 72async function onHlsPlaylistGenerationSuccess (video: VideoModel) {
73 if (video === undefined) return undefined
74
75 await sequelizeTypescript.transaction(async t => {
76 // Maybe the video changed in database, refresh it
77 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
78 // Video does not exist anymore
79 if (!videoDatabase) return undefined
80
81 // If the video was not published, we consider it is a new one for other instances
82 await federateVideoIfNeeded(videoDatabase, false, t)
83 })
84}
85
86async function onVideoFileTranscoderOrImportSuccess (video: VideoModel, payload?: VideoFilePayload) {
68 if (video === undefined) return undefined 87 if (video === undefined) return undefined
69 88
70 return sequelizeTypescript.transaction(async t => { 89 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
71 // Maybe the video changed in database, refresh it 90 // Maybe the video changed in database, refresh it
72 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) 91 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
73 // Video does not exist anymore 92 // Video does not exist anymore
74 if (!videoDatabase) return undefined 93 if (!videoDatabase) return undefined
75 94
76 let isNewVideo = false 95 let videoPublished = false
77 96
78 // We transcoded the video file in another format, now we can publish it 97 // We transcoded the video file in another format, now we can publish it
79 if (videoDatabase.state !== VideoState.PUBLISHED) { 98 if (videoDatabase.state !== VideoState.PUBLISHED) {
80 isNewVideo = true 99 videoPublished = true
81 100
82 videoDatabase.state = VideoState.PUBLISHED 101 videoDatabase.state = VideoState.PUBLISHED
83 videoDatabase.publishedAt = new Date() 102 videoDatabase.publishedAt = new Date()
@@ -85,21 +104,29 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
85 } 104 }
86 105
87 // If the video was not published, we consider it is a new one for other instances 106 // If the video was not published, we consider it is a new one for other instances
88 await federateVideoIfNeeded(videoDatabase, isNewVideo, t) 107 await federateVideoIfNeeded(videoDatabase, videoPublished, t)
89 108
90 return undefined 109 return { videoDatabase, videoPublished }
91 }) 110 })
111
112 // don't notify prior to scheduled video update
113 if (videoPublished && !videoDatabase.ScheduleVideoUpdate) {
114 Notifier.Instance.notifyOnNewVideo(videoDatabase)
115 Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
116 }
117
118 await createHlsJobIfEnabled(payload)
92} 119}
93 120
94async function onVideoFileOptimizerSuccess (video: VideoModel, isNewVideo: boolean) { 121async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: VideoFilePayload) {
95 if (video === undefined) return undefined 122 if (videoArg === undefined) return undefined
96 123
97 // Outside the transaction (IO on disk) 124 // Outside the transaction (IO on disk)
98 const { videoFileResolution } = await video.getOriginalFileResolution() 125 const { videoFileResolution } = await videoArg.getOriginalFileResolution()
99 126
100 return sequelizeTypescript.transaction(async t => { 127 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
101 // Maybe the video changed in database, refresh it 128 // Maybe the video changed in database, refresh it
102 const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) 129 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid, t)
103 // Video does not exist anymore 130 // Video does not exist anymore
104 if (!videoDatabase) return undefined 131 if (!videoDatabase) return undefined
105 132
@@ -110,8 +137,10 @@ async function onVideoFileOptimizerSuccess (video: VideoModel, isNewVideo: boole
110 { resolutions: resolutionsEnabled } 137 { resolutions: resolutionsEnabled }
111 ) 138 )
112 139
140 let videoPublished = false
141
113 if (resolutionsEnabled.length !== 0) { 142 if (resolutionsEnabled.length !== 0) {
114 const tasks: Bluebird<any>[] = [] 143 const tasks: Bluebird<Bull.Job<any>>[] = []
115 144
116 for (const resolution of resolutionsEnabled) { 145 for (const resolution of resolutionsEnabled) {
117 const dataInput = { 146 const dataInput = {
@@ -127,15 +156,27 @@ async function onVideoFileOptimizerSuccess (video: VideoModel, isNewVideo: boole
127 156
128 logger.info('Transcoding jobs created for uuid %s.', videoDatabase.uuid, { resolutionsEnabled }) 157 logger.info('Transcoding jobs created for uuid %s.', videoDatabase.uuid, { resolutionsEnabled })
129 } else { 158 } else {
159 videoPublished = true
160
130 // No transcoding to do, it's now published 161 // No transcoding to do, it's now published
131 video.state = VideoState.PUBLISHED 162 videoDatabase.state = VideoState.PUBLISHED
132 video = await video.save({ transaction: t }) 163 videoDatabase = await videoDatabase.save({ transaction: t })
133 164
134 logger.info('No transcoding jobs created for video %s (no resolutions).', video.uuid) 165 logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy })
135 } 166 }
136 167
137 return federateVideoIfNeeded(video, isNewVideo, t) 168 await federateVideoIfNeeded(videoDatabase, payload.isNewVideo, t)
169
170 return { videoDatabase, videoPublished }
138 }) 171 })
172
173 // don't notify prior to scheduled video update
174 if (!videoDatabase.ScheduleVideoUpdate) {
175 if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
176 if (videoPublished) Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
177 }
178
179 await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }))
139} 180}
140 181
141// --------------------------------------------------------------------------- 182// ---------------------------------------------------------------------------
@@ -144,3 +185,20 @@ export {
144 processVideoFile, 185 processVideoFile,
145 processVideoFileImport 186 processVideoFileImport
146} 187}
188
189// ---------------------------------------------------------------------------
190
191function createHlsJobIfEnabled (payload?: VideoFilePayload) {
192 // Generate HLS playlist?
193 if (payload && CONFIG.TRANSCODING.HLS.ENABLED) {
194 const hlsTranscodingPayload = {
195 videoUUID: payload.videoUUID,
196 resolution: payload.resolution,
197 isPortraitMode: payload.isPortraitMode,
198
199 generateHlsPlaylist: true
200 }
201
202 return JobQueue.Instance.createJob({ type: 'video-file', payload: hlsTranscodingPayload })
203 }
204}
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index e3f2a276c..12004dcd7 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -6,15 +6,16 @@ import { VideoImportState } from '../../../../shared/models/videos'
6import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' 6import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
7import { extname, join } from 'path' 7import { extname, join } from 'path'
8import { VideoFileModel } from '../../../models/video/video-file' 8import { VideoFileModel } from '../../../models/video/video-file'
9import { CONFIG, sequelizeTypescript, VIDEO_IMPORT_TIMEOUT } from '../../../initializers' 9import { CONFIG, PREVIEWS_SIZE, sequelizeTypescript, THUMBNAILS_SIZE, VIDEO_IMPORT_TIMEOUT } from '../../../initializers'
10import { doRequestAndSaveToFile } from '../../../helpers/requests' 10import { downloadImage } from '../../../helpers/requests'
11import { VideoState } from '../../../../shared' 11import { VideoState } from '../../../../shared'
12import { JobQueue } from '../index' 12import { JobQueue } from '../index'
13import { federateVideoIfNeeded } from '../../activitypub' 13import { federateVideoIfNeeded } from '../../activitypub'
14import { VideoModel } from '../../../models/video/video' 14import { VideoModel } from '../../../models/video/video'
15import { downloadWebTorrentVideo } from '../../../helpers/webtorrent' 15import { downloadWebTorrentVideo } from '../../../helpers/webtorrent'
16import { getSecureTorrentName } from '../../../helpers/utils' 16import { getSecureTorrentName } from '../../../helpers/utils'
17import { remove, rename, stat } from 'fs-extra' 17import { remove, move, stat } from 'fs-extra'
18import { Notifier } from '../../notifier'
18 19
19type VideoImportYoutubeDLPayload = { 20type VideoImportYoutubeDLPayload = {
20 type: 'youtube-dl' 21 type: 'youtube-dl'
@@ -109,6 +110,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
109 let tempVideoPath: string 110 let tempVideoPath: string
110 let videoDestFile: string 111 let videoDestFile: string
111 let videoFile: VideoFileModel 112 let videoFile: VideoFileModel
113
112 try { 114 try {
113 // Download video from youtubeDL 115 // Download video from youtubeDL
114 tempVideoPath = await downloader() 116 tempVideoPath = await downloader()
@@ -133,19 +135,18 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
133 videoId: videoImport.videoId 135 videoId: videoImport.videoId
134 } 136 }
135 videoFile = new VideoFileModel(videoFileData) 137 videoFile = new VideoFileModel(videoFileData)
136 // Import if the import fails, to clean files 138 // To clean files if the import fails
137 videoImport.Video.VideoFiles = [ videoFile ] 139 videoImport.Video.VideoFiles = [ videoFile ]
138 140
139 // Move file 141 // Move file
140 videoDestFile = join(CONFIG.STORAGE.VIDEOS_DIR, videoImport.Video.getVideoFilename(videoFile)) 142 videoDestFile = join(CONFIG.STORAGE.VIDEOS_DIR, videoImport.Video.getVideoFilename(videoFile))
141 await rename(tempVideoPath, videoDestFile) 143 await move(tempVideoPath, videoDestFile)
142 tempVideoPath = null // This path is not used anymore 144 tempVideoPath = null // This path is not used anymore
143 145
144 // Process thumbnail 146 // Process thumbnail
145 if (options.downloadThumbnail) { 147 if (options.downloadThumbnail) {
146 if (options.thumbnailUrl) { 148 if (options.thumbnailUrl) {
147 const destThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName()) 149 await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName(), THUMBNAILS_SIZE)
148 await doRequestAndSaveToFile({ method: 'GET', uri: options.thumbnailUrl }, destThumbnailPath)
149 } else { 150 } else {
150 await videoImport.Video.createThumbnail(videoFile) 151 await videoImport.Video.createThumbnail(videoFile)
151 } 152 }
@@ -156,8 +157,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
156 // Process preview 157 // Process preview
157 if (options.downloadPreview) { 158 if (options.downloadPreview) {
158 if (options.thumbnailUrl) { 159 if (options.thumbnailUrl) {
159 const destPreviewPath = join(CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName()) 160 await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName(), PREVIEWS_SIZE)
160 await doRequestAndSaveToFile({ method: 'GET', uri: options.thumbnailUrl }, destPreviewPath)
161 } else { 161 } else {
162 await videoImport.Video.createPreview(videoFile) 162 await videoImport.Video.createPreview(videoFile)
163 } 163 }
@@ -180,7 +180,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
180 // Update video DB object 180 // Update video DB object
181 video.duration = duration 181 video.duration = duration
182 video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED 182 video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
183 const videoUpdated = await video.save({ transaction: t }) 183 await video.save({ transaction: t })
184 184
185 // Now we can federate the video (reload from database, we need more attributes) 185 // Now we can federate the video (reload from database, we need more attributes)
186 const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) 186 const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
@@ -192,10 +192,13 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
192 192
193 logger.info('Video %s imported.', video.uuid) 193 logger.info('Video %s imported.', video.uuid)
194 194
195 videoImportUpdated.Video = videoUpdated 195 videoImportUpdated.Video = videoForFederation
196 return videoImportUpdated 196 return videoImportUpdated
197 }) 197 })
198 198
199 Notifier.Instance.notifyOnNewVideo(videoImportUpdated.Video)
200 Notifier.Instance.notifyOnFinishedVideoImport(videoImportUpdated, true)
201
199 // Create transcoding jobs? 202 // Create transcoding jobs?
200 if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) { 203 if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) {
201 // Put uuid because we don't have id auto incremented for now 204 // Put uuid because we don't have id auto incremented for now
@@ -218,6 +221,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
218 videoImport.state = VideoImportState.FAILED 221 videoImport.state = VideoImportState.FAILED
219 await videoImport.save() 222 await videoImport.save()
220 223
224 Notifier.Instance.notifyOnFinishedVideoImport(videoImport, false)
225
221 throw err 226 throw err
222 } 227 }
223} 228}
diff --git a/server/lib/job-queue/handlers/video-views.ts b/server/lib/job-queue/handlers/video-views.ts
index cf180a11a..fa1fd13b3 100644
--- a/server/lib/job-queue/handlers/video-views.ts
+++ b/server/lib/job-queue/handlers/video-views.ts
@@ -3,8 +3,9 @@ import { logger } from '../../../helpers/logger'
3import { VideoModel } from '../../../models/video/video' 3import { VideoModel } from '../../../models/video/video'
4import { VideoViewModel } from '../../../models/video/video-views' 4import { VideoViewModel } from '../../../models/video/video-views'
5import { isTestInstance } from '../../../helpers/core-utils' 5import { isTestInstance } from '../../../helpers/core-utils'
6import { federateVideoIfNeeded } from '../../activitypub'
6 7
7async function processVideosViewsViews () { 8async function processVideosViews () {
8 const lastHour = new Date() 9 const lastHour = new Date()
9 10
10 // In test mode, we run this function multiple times per hour, so we don't want the values of the previous hour 11 // In test mode, we run this function multiple times per hour, so we don't want the values of the previous hour
@@ -22,13 +23,9 @@ async function processVideosViewsViews () {
22 for (const videoId of videoIds) { 23 for (const videoId of videoIds) {
23 try { 24 try {
24 const views = await Redis.Instance.getVideoViews(videoId, hour) 25 const views = await Redis.Instance.getVideoViews(videoId, hour)
25 if (isNaN(views)) { 26 if (views) {
26 logger.error('Cannot process videos views of video %d in hour %d: views number is NaN.', videoId, hour)
27 } else {
28 logger.debug('Adding %d views to video %d in hour %d.', views, videoId, hour) 27 logger.debug('Adding %d views to video %d in hour %d.', views, videoId, hour)
29 28
30 await VideoModel.incrementViews(videoId, views)
31
32 try { 29 try {
33 await VideoViewModel.create({ 30 await VideoViewModel.create({
34 startDate, 31 startDate,
@@ -36,6 +33,16 @@ async function processVideosViewsViews () {
36 views, 33 views,
37 videoId 34 videoId
38 }) 35 })
36
37 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
38 if (video.isOwned()) {
39 // If this is a remote video, the origin instance will send us an update
40 await VideoModel.incrementViews(videoId, views)
41
42 // Send video update
43 video.views += views
44 await federateVideoIfNeeded(video, false)
45 }
39 } catch (err) { 46 } catch (err) {
40 logger.debug('Cannot create video views for video %d in hour %d. Maybe the video does not exist anymore?', videoId, hour) 47 logger.debug('Cannot create video views for video %d in hour %d. Maybe the video does not exist anymore?', videoId, hour)
41 } 48 }
@@ -51,5 +58,5 @@ async function processVideosViewsViews () {
51// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
52 59
53export { 60export {
54 processVideosViewsViews 61 processVideosViews
55} 62}
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 0696ba43c..ba9cbe0d9 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -10,7 +10,8 @@ import { EmailPayload, processEmail } from './handlers/email'
10import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file' 10import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file'
11import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' 11import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow'
12import { processVideoImport, VideoImportPayload } from './handlers/video-import' 12import { processVideoImport, VideoImportPayload } from './handlers/video-import'
13import { processVideosViewsViews } from './handlers/video-views' 13import { processVideosViews } from './handlers/video-views'
14import { refreshAPObject, RefreshPayload } from './handlers/activitypub-refresher'
14 15
15type CreateJobArgument = 16type CreateJobArgument =
16 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | 17 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -21,6 +22,7 @@ type CreateJobArgument =
21 { type: 'video-file', payload: VideoFilePayload } | 22 { type: 'video-file', payload: VideoFilePayload } |
22 { type: 'email', payload: EmailPayload } | 23 { type: 'email', payload: EmailPayload } |
23 { type: 'video-import', payload: VideoImportPayload } | 24 { type: 'video-import', payload: VideoImportPayload } |
25 { type: 'activitypub-refresher', payload: RefreshPayload } |
24 { type: 'videos-views', payload: {} } 26 { type: 'videos-views', payload: {} }
25 27
26const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = { 28const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
@@ -32,7 +34,8 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
32 'video-file': processVideoFile, 34 'video-file': processVideoFile,
33 'email': processEmail, 35 'email': processEmail,
34 'video-import': processVideoImport, 36 'video-import': processVideoImport,
35 'videos-views': processVideosViewsViews 37 'videos-views': processVideosViews,
38 'activitypub-refresher': refreshAPObject
36} 39}
37 40
38const jobTypes: JobType[] = [ 41const jobTypes: JobType[] = [
@@ -44,7 +47,8 @@ const jobTypes: JobType[] = [
44 'video-file', 47 'video-file',
45 'video-file-import', 48 'video-file-import',
46 'video-import', 49 'video-import',
47 'videos-views' 50 'videos-views',
51 'activitypub-refresher'
48] 52]
49 53
50class JobQueue { 54class JobQueue {
@@ -84,7 +88,6 @@ class JobQueue {
84 88
85 queue.on('error', err => { 89 queue.on('error', err => {
86 logger.error('Error in job queue %s.', handlerName, { err }) 90 logger.error('Error in job queue %s.', handlerName, { err })
87 process.exit(-1)
88 }) 91 })
89 92
90 this.queues[handlerName] = queue 93 this.queues[handlerName] = queue
@@ -162,10 +165,10 @@ class JobQueue {
162 return total 165 return total
163 } 166 }
164 167
165 removeOldJobs () { 168 async removeOldJobs () {
166 for (const key of Object.keys(this.queues)) { 169 for (const key of Object.keys(this.queues)) {
167 const queue = this.queues[key] 170 const queue = this.queues[key]
168 queue.clean(JOB_COMPLETED_LIFETIME, 'completed') 171 await queue.clean(JOB_COMPLETED_LIFETIME, 'completed')
169 } 172 }
170 } 173 }
171 174
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
new file mode 100644
index 000000000..d1b331346
--- /dev/null
+++ b/server/lib/notifier.ts
@@ -0,0 +1,455 @@
1import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
2import { logger } from '../helpers/logger'
3import { VideoModel } from '../models/video/video'
4import { Emailer } from './emailer'
5import { UserNotificationModel } from '../models/account/user-notification'
6import { VideoCommentModel } from '../models/video/video-comment'
7import { UserModel } from '../models/account/user'
8import { PeerTubeSocket } from './peertube-socket'
9import { CONFIG } from '../initializers/constants'
10import { VideoPrivacy, VideoState } from '../../shared/models/videos'
11import { VideoAbuseModel } from '../models/video/video-abuse'
12import { VideoBlacklistModel } from '../models/video/video-blacklist'
13import * as Bluebird from 'bluebird'
14import { VideoImportModel } from '../models/video/video-import'
15import { AccountBlocklistModel } from '../models/account/account-blocklist'
16import { ActorFollowModel } from '../models/activitypub/actor-follow'
17import { AccountModel } from '../models/account/account'
18
19class Notifier {
20
21 private static instance: Notifier
22
23 private constructor () {}
24
25 notifyOnNewVideo (video: VideoModel): void {
26 // Only notify on public and published videos
27 if (video.privacy !== VideoPrivacy.PUBLIC || video.state !== VideoState.PUBLISHED) return
28
29 this.notifySubscribersOfNewVideo(video)
30 .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
31 }
32
33 notifyOnPendingVideoPublished (video: VideoModel): void {
34 // Only notify on public videos that has been published while the user waited transcoding/scheduled update
35 if (video.waitTranscoding === false && !video.ScheduleVideoUpdate) return
36
37 this.notifyOwnedVideoHasBeenPublished(video)
38 .catch(err => logger.error('Cannot notify owner that its video %s has been published.', video.url, { err }))
39 }
40
41 notifyOnNewComment (comment: VideoCommentModel): void {
42 this.notifyVideoOwnerOfNewComment(comment)
43 .catch(err => logger.error('Cannot notify video owner of new comment %s.', comment.url, { err }))
44
45 this.notifyOfCommentMention(comment)
46 .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
47 }
48
49 notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
50 this.notifyModeratorsOfNewVideoAbuse(videoAbuse)
51 .catch(err => logger.error('Cannot notify of new video abuse of video %s.', videoAbuse.Video.url, { err }))
52 }
53
54 notifyOnVideoBlacklist (videoBlacklist: VideoBlacklistModel): void {
55 this.notifyVideoOwnerOfBlacklist(videoBlacklist)
56 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
57 }
58
59 notifyOnVideoUnblacklist (video: VideoModel): void {
60 this.notifyVideoOwnerOfUnblacklist(video)
61 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', video.url, { err }))
62 }
63
64 notifyOnFinishedVideoImport (videoImport: VideoImportModel, success: boolean): void {
65 this.notifyOwnerVideoImportIsFinished(videoImport, success)
66 .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err }))
67 }
68
69 notifyOnNewUserRegistration (user: UserModel): void {
70 this.notifyModeratorsOfNewUserRegistration(user)
71 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
72 }
73
74 notifyOfNewFollow (actorFollow: ActorFollowModel): void {
75 this.notifyUserOfNewActorFollow(actorFollow)
76 .catch(err => {
77 logger.error(
78 'Cannot notify owner of channel %s of a new follow by %s.',
79 actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
80 actorFollow.ActorFollower.Account.getDisplayName(),
81 err
82 )
83 })
84 }
85
86 private async notifySubscribersOfNewVideo (video: VideoModel) {
87 // List all followers that are users
88 const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
89
90 logger.info('Notifying %d users of new video %s.', users.length, video.url)
91
92 function settingGetter (user: UserModel) {
93 return user.NotificationSetting.newVideoFromSubscription
94 }
95
96 async function notificationCreator (user: UserModel) {
97 const notification = await UserNotificationModel.create({
98 type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
99 userId: user.id,
100 videoId: video.id
101 })
102 notification.Video = video
103
104 return notification
105 }
106
107 function emailSender (emails: string[]) {
108 return Emailer.Instance.addNewVideoFromSubscriberNotification(emails, video)
109 }
110
111 return this.notify({ users, settingGetter, notificationCreator, emailSender })
112 }
113
114 private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
115 if (comment.Video.isOwned() === false) return
116
117 const user = await UserModel.loadByVideoId(comment.videoId)
118
119 // Not our user or user comments its own video
120 if (!user || comment.Account.userId === user.id) return
121
122 const accountMuted = await AccountBlocklistModel.isAccountMutedBy(user.Account.id, comment.accountId)
123 if (accountMuted) return
124
125 logger.info('Notifying user %s of new comment %s.', user.username, comment.url)
126
127 function settingGetter (user: UserModel) {
128 return user.NotificationSetting.newCommentOnMyVideo
129 }
130
131 async function notificationCreator (user: UserModel) {
132 const notification = await UserNotificationModel.create({
133 type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
134 userId: user.id,
135 commentId: comment.id
136 })
137 notification.Comment = comment
138
139 return notification
140 }
141
142 function emailSender (emails: string[]) {
143 return Emailer.Instance.addNewCommentOnMyVideoNotification(emails, comment)
144 }
145
146 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
147 }
148
149 private async notifyOfCommentMention (comment: VideoCommentModel) {
150 const usernames = comment.extractMentions()
151 let users = await UserModel.listByUsernames(usernames)
152
153 if (comment.Video.isOwned()) {
154 const userException = await UserModel.loadByVideoId(comment.videoId)
155 users = users.filter(u => u.id !== userException.id)
156 }
157
158 // Don't notify if I mentioned myself
159 users = users.filter(u => u.Account.id !== comment.accountId)
160
161 if (users.length === 0) return
162
163 const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(users.map(u => u.Account.id), comment.accountId)
164
165 logger.info('Notifying %d users of new comment %s.', users.length, comment.url)
166
167 function settingGetter (user: UserModel) {
168 if (accountMutedHash[user.Account.id] === true) return UserNotificationSettingValue.NONE
169
170 return user.NotificationSetting.commentMention
171 }
172
173 async function notificationCreator (user: UserModel) {
174 const notification = await UserNotificationModel.create({
175 type: UserNotificationType.COMMENT_MENTION,
176 userId: user.id,
177 commentId: comment.id
178 })
179 notification.Comment = comment
180
181 return notification
182 }
183
184 function emailSender (emails: string[]) {
185 return Emailer.Instance.addNewCommentMentionNotification(emails, comment)
186 }
187
188 return this.notify({ users, settingGetter, notificationCreator, emailSender })
189 }
190
191 private async notifyUserOfNewActorFollow (actorFollow: ActorFollowModel) {
192 if (actorFollow.ActorFollowing.isOwned() === false) return
193
194 // Account follows one of our account?
195 let followType: 'account' | 'channel' = 'channel'
196 let user = await UserModel.loadByChannelActorId(actorFollow.ActorFollowing.id)
197
198 // Account follows one of our channel?
199 if (!user) {
200 user = await UserModel.loadByAccountActorId(actorFollow.ActorFollowing.id)
201 followType = 'account'
202 }
203
204 if (!user) return
205
206 if (!actorFollow.ActorFollower.Account || !actorFollow.ActorFollower.Account.name) {
207 actorFollow.ActorFollower.Account = await actorFollow.ActorFollower.$get('Account') as AccountModel
208 }
209 const followerAccount = actorFollow.ActorFollower.Account
210
211 const accountMuted = await AccountBlocklistModel.isAccountMutedBy(user.Account.id, followerAccount.id)
212 if (accountMuted) return
213
214 logger.info('Notifying user %s of new follower: %s.', user.username, followerAccount.getDisplayName())
215
216 function settingGetter (user: UserModel) {
217 return user.NotificationSetting.newFollow
218 }
219
220 async function notificationCreator (user: UserModel) {
221 const notification = await UserNotificationModel.create({
222 type: UserNotificationType.NEW_FOLLOW,
223 userId: user.id,
224 actorFollowId: actorFollow.id
225 })
226 notification.ActorFollow = actorFollow
227
228 return notification
229 }
230
231 function emailSender (emails: string[]) {
232 return Emailer.Instance.addNewFollowNotification(emails, actorFollow, followType)
233 }
234
235 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
236 }
237
238 private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
239 const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
240 if (moderators.length === 0) return
241
242 logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, videoAbuse.Video.url)
243
244 function settingGetter (user: UserModel) {
245 return user.NotificationSetting.videoAbuseAsModerator
246 }
247
248 async function notificationCreator (user: UserModel) {
249 const notification = await UserNotificationModel.create({
250 type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
251 userId: user.id,
252 videoAbuseId: videoAbuse.id
253 })
254 notification.VideoAbuse = videoAbuse
255
256 return notification
257 }
258
259 function emailSender (emails: string[]) {
260 return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
261 }
262
263 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
264 }
265
266 private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
267 const user = await UserModel.loadByVideoId(videoBlacklist.videoId)
268 if (!user) return
269
270 logger.info('Notifying user %s that its video %s has been blacklisted.', user.username, videoBlacklist.Video.url)
271
272 function settingGetter (user: UserModel) {
273 return user.NotificationSetting.blacklistOnMyVideo
274 }
275
276 async function notificationCreator (user: UserModel) {
277 const notification = await UserNotificationModel.create({
278 type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
279 userId: user.id,
280 videoBlacklistId: videoBlacklist.id
281 })
282 notification.VideoBlacklist = videoBlacklist
283
284 return notification
285 }
286
287 function emailSender (emails: string[]) {
288 return Emailer.Instance.addVideoBlacklistNotification(emails, videoBlacklist)
289 }
290
291 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
292 }
293
294 private async notifyVideoOwnerOfUnblacklist (video: VideoModel) {
295 const user = await UserModel.loadByVideoId(video.id)
296 if (!user) return
297
298 logger.info('Notifying user %s that its video %s has been unblacklisted.', user.username, video.url)
299
300 function settingGetter (user: UserModel) {
301 return user.NotificationSetting.blacklistOnMyVideo
302 }
303
304 async function notificationCreator (user: UserModel) {
305 const notification = await UserNotificationModel.create({
306 type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
307 userId: user.id,
308 videoId: video.id
309 })
310 notification.Video = video
311
312 return notification
313 }
314
315 function emailSender (emails: string[]) {
316 return Emailer.Instance.addVideoUnblacklistNotification(emails, video)
317 }
318
319 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
320 }
321
322 private async notifyOwnedVideoHasBeenPublished (video: VideoModel) {
323 const user = await UserModel.loadByVideoId(video.id)
324 if (!user) return
325
326 logger.info('Notifying user %s of the publication of its video %s.', user.username, video.url)
327
328 function settingGetter (user: UserModel) {
329 return user.NotificationSetting.myVideoPublished
330 }
331
332 async function notificationCreator (user: UserModel) {
333 const notification = await UserNotificationModel.create({
334 type: UserNotificationType.MY_VIDEO_PUBLISHED,
335 userId: user.id,
336 videoId: video.id
337 })
338 notification.Video = video
339
340 return notification
341 }
342
343 function emailSender (emails: string[]) {
344 return Emailer.Instance.myVideoPublishedNotification(emails, video)
345 }
346
347 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
348 }
349
350 private async notifyOwnerVideoImportIsFinished (videoImport: VideoImportModel, success: boolean) {
351 const user = await UserModel.loadByVideoImportId(videoImport.id)
352 if (!user) return
353
354 logger.info('Notifying user %s its video import %s is finished.', user.username, videoImport.getTargetIdentifier())
355
356 function settingGetter (user: UserModel) {
357 return user.NotificationSetting.myVideoImportFinished
358 }
359
360 async function notificationCreator (user: UserModel) {
361 const notification = await UserNotificationModel.create({
362 type: success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR,
363 userId: user.id,
364 videoImportId: videoImport.id
365 })
366 notification.VideoImport = videoImport
367
368 return notification
369 }
370
371 function emailSender (emails: string[]) {
372 return success
373 ? Emailer.Instance.myVideoImportSuccessNotification(emails, videoImport)
374 : Emailer.Instance.myVideoImportErrorNotification(emails, videoImport)
375 }
376
377 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
378 }
379
380 private async notifyModeratorsOfNewUserRegistration (registeredUser: UserModel) {
381 const moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
382 if (moderators.length === 0) return
383
384 logger.info(
385 'Notifying %s moderators of new user registration of %s.',
386 moderators.length, registeredUser.Account.Actor.preferredUsername
387 )
388
389 function settingGetter (user: UserModel) {
390 return user.NotificationSetting.newUserRegistration
391 }
392
393 async function notificationCreator (user: UserModel) {
394 const notification = await UserNotificationModel.create({
395 type: UserNotificationType.NEW_USER_REGISTRATION,
396 userId: user.id,
397 accountId: registeredUser.Account.id
398 })
399 notification.Account = registeredUser.Account
400
401 return notification
402 }
403
404 function emailSender (emails: string[]) {
405 return Emailer.Instance.addNewUserRegistrationNotification(emails, registeredUser)
406 }
407
408 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
409 }
410
411 private async notify (options: {
412 users: UserModel[],
413 notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,
414 emailSender: (emails: string[]) => Promise<any> | Bluebird<any>,
415 settingGetter: (user: UserModel) => UserNotificationSettingValue
416 }) {
417 const emails: string[] = []
418
419 for (const user of options.users) {
420 if (this.isWebNotificationEnabled(options.settingGetter(user))) {
421 const notification = await options.notificationCreator(user)
422
423 PeerTubeSocket.Instance.sendNotification(user.id, notification)
424 }
425
426 if (this.isEmailEnabled(user, options.settingGetter(user))) {
427 emails.push(user.email)
428 }
429 }
430
431 if (emails.length !== 0) {
432 await options.emailSender(emails)
433 }
434 }
435
436 private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
437 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false
438
439 return value & UserNotificationSettingValue.EMAIL
440 }
441
442 private isWebNotificationEnabled (value: UserNotificationSettingValue) {
443 return value & UserNotificationSettingValue.WEB
444 }
445
446 static get Instance () {
447 return this.instance || (this.instance = new this())
448 }
449}
450
451// ---------------------------------------------------------------------------
452
453export {
454 Notifier
455}
diff --git a/server/lib/oauth-model.ts b/server/lib/oauth-model.ts
index 5cbe60b82..2cd2ae97c 100644
--- a/server/lib/oauth-model.ts
+++ b/server/lib/oauth-model.ts
@@ -1,3 +1,4 @@
1import * as Bluebird from 'bluebird'
1import { AccessDeniedError } from 'oauth2-server' 2import { AccessDeniedError } from 'oauth2-server'
2import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
3import { UserModel } from '../models/account/user' 4import { UserModel } from '../models/account/user'
@@ -37,7 +38,7 @@ function clearCacheByToken (token: string) {
37function getAccessToken (bearerToken: string) { 38function getAccessToken (bearerToken: string) {
38 logger.debug('Getting access token (bearerToken: ' + bearerToken + ').') 39 logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
39 40
40 if (accessTokenCache[bearerToken] !== undefined) return accessTokenCache[bearerToken] 41 if (accessTokenCache[bearerToken] !== undefined) return Bluebird.resolve(accessTokenCache[bearerToken])
41 42
42 return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken) 43 return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
43 .then(tokenModel => { 44 .then(tokenModel => {
diff --git a/server/lib/peertube-socket.ts b/server/lib/peertube-socket.ts
new file mode 100644
index 000000000..eb84ecd4b
--- /dev/null
+++ b/server/lib/peertube-socket.ts
@@ -0,0 +1,52 @@
1import * as SocketIO from 'socket.io'
2import { authenticateSocket } from '../middlewares'
3import { UserNotificationModel } from '../models/account/user-notification'
4import { logger } from '../helpers/logger'
5import { Server } from 'http'
6
7class PeerTubeSocket {
8
9 private static instance: PeerTubeSocket
10
11 private userNotificationSockets: { [ userId: number ]: SocketIO.Socket } = {}
12
13 private constructor () {}
14
15 init (server: Server) {
16 const io = SocketIO(server)
17
18 io.of('/user-notifications')
19 .use(authenticateSocket)
20 .on('connection', socket => {
21 const userId = socket.handshake.query.user.id
22
23 logger.debug('User %d connected on the notification system.', userId)
24
25 this.userNotificationSockets[userId] = socket
26
27 socket.on('disconnect', () => {
28 logger.debug('User %d disconnected from SocketIO notifications.', userId)
29
30 delete this.userNotificationSockets[userId]
31 })
32 })
33 }
34
35 sendNotification (userId: number, notification: UserNotificationModel) {
36 const socket = this.userNotificationSockets[userId]
37
38 if (!socket) return
39
40 socket.emit('new-notification', notification.toFormattedJSON())
41 }
42
43 static get Instance () {
44 return this.instance || (this.instance = new this())
45 }
46}
47
48// ---------------------------------------------------------------------------
49
50export {
51 PeerTubeSocket
52}
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index abd75d512..3628c0583 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -2,7 +2,13 @@ import * as express from 'express'
2import { createClient, RedisClient } from 'redis' 2import { createClient, RedisClient } from 'redis'
3import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
4import { generateRandomString } from '../helpers/utils' 4import { generateRandomString } from '../helpers/utils'
5import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' 5import {
6 CONFIG,
7 CONTACT_FORM_LIFETIME,
8 USER_EMAIL_VERIFY_LIFETIME,
9 USER_PASSWORD_RESET_LIFETIME,
10 VIDEO_VIEW_LIFETIME
11} from '../initializers'
6 12
7type CachedRoute = { 13type CachedRoute = {
8 body: string, 14 body: string,
@@ -76,6 +82,16 @@ class Redis {
76 return this.getValue(this.generateVerifyEmailKey(userId)) 82 return this.getValue(this.generateVerifyEmailKey(userId))
77 } 83 }
78 84
85 /************* Contact form per IP *************/
86
87 async setContactFormIp (ip: string) {
88 return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME)
89 }
90
91 async isContactFormIpExists (ip: string) {
92 return this.exists(this.generateContactFormKey(ip))
93 }
94
79 /************* Views per IP *************/ 95 /************* Views per IP *************/
80 96
81 setIPVideoView (ip: string, videoUUID: string) { 97 setIPVideoView (ip: string, videoUUID: string) {
@@ -121,7 +137,14 @@ class Redis {
121 const key = this.generateVideoViewKey(videoId, hour) 137 const key = this.generateVideoViewKey(videoId, hour)
122 138
123 const valueString = await this.getValue(key) 139 const valueString = await this.getValue(key)
124 return parseInt(valueString, 10) 140 const valueInt = parseInt(valueString, 10)
141
142 if (isNaN(valueInt)) {
143 logger.error('Cannot get videos views of video %d in hour %d: views number is NaN (%s).', videoId, hour, valueString)
144 return undefined
145 }
146
147 return valueInt
125 } 148 }
126 149
127 async getVideosIdViewed (hour: number) { 150 async getVideosIdViewed (hour: number) {
@@ -168,7 +191,11 @@ class Redis {
168 } 191 }
169 192
170 private generateViewKey (ip: string, videoUUID: string) { 193 private generateViewKey (ip: string, videoUUID: string) {
171 return videoUUID + '-' + ip 194 return `views-${videoUUID}-${ip}`
195 }
196
197 private generateContactFormKey (ip: string) {
198 return 'contact-form-' + ip
172 } 199 }
173 200
174 /************* Redis helpers *************/ 201 /************* Redis helpers *************/
diff --git a/server/lib/schedulers/abstract-scheduler.ts b/server/lib/schedulers/abstract-scheduler.ts
index b9d0a4d17..86ea7aa38 100644
--- a/server/lib/schedulers/abstract-scheduler.ts
+++ b/server/lib/schedulers/abstract-scheduler.ts
@@ -1,8 +1,11 @@
1import { logger } from '../../helpers/logger'
2
1export abstract class AbstractScheduler { 3export abstract class AbstractScheduler {
2 4
3 protected abstract schedulerIntervalMs: number 5 protected abstract schedulerIntervalMs: number
4 6
5 private interval: NodeJS.Timer 7 private interval: NodeJS.Timer
8 private isRunning = false
6 9
7 enable () { 10 enable () {
8 if (!this.schedulerIntervalMs) throw new Error('Interval is not correctly set.') 11 if (!this.schedulerIntervalMs) throw new Error('Interval is not correctly set.')
@@ -14,5 +17,18 @@ export abstract class AbstractScheduler {
14 clearInterval(this.interval) 17 clearInterval(this.interval)
15 } 18 }
16 19
17 abstract execute () 20 async execute () {
21 if (this.isRunning === true) return
22 this.isRunning = true
23
24 try {
25 await this.internalExecute()
26 } catch (err) {
27 logger.error('Cannot execute %s scheduler.', this.constructor.name, { err })
28 } finally {
29 this.isRunning = false
30 }
31 }
32
33 protected abstract internalExecute (): Promise<any>
18} 34}
diff --git a/server/lib/schedulers/bad-actor-follow-scheduler.ts b/server/lib/schedulers/actor-follow-scheduler.ts
index 617149aaf..3967be7f8 100644
--- a/server/lib/schedulers/bad-actor-follow-scheduler.ts
+++ b/server/lib/schedulers/actor-follow-scheduler.ts
@@ -3,18 +3,35 @@ import { logger } from '../../helpers/logger'
3import { ActorFollowModel } from '../../models/activitypub/actor-follow' 3import { ActorFollowModel } from '../../models/activitypub/actor-follow'
4import { AbstractScheduler } from './abstract-scheduler' 4import { AbstractScheduler } from './abstract-scheduler'
5import { SCHEDULER_INTERVALS_MS } from '../../initializers' 5import { SCHEDULER_INTERVALS_MS } from '../../initializers'
6import { ActorFollowScoreCache } from '../cache'
6 7
7export class BadActorFollowScheduler extends AbstractScheduler { 8export class ActorFollowScheduler extends AbstractScheduler {
8 9
9 private static instance: AbstractScheduler 10 private static instance: AbstractScheduler
10 11
11 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.badActorFollow 12 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.actorFollowScores
12 13
13 private constructor () { 14 private constructor () {
14 super() 15 super()
15 } 16 }
16 17
17 async execute () { 18 protected async internalExecute () {
19 await this.processPendingScores()
20
21 await this.removeBadActorFollows()
22 }
23
24 private async processPendingScores () {
25 const pendingScores = ActorFollowScoreCache.Instance.getPendingFollowsScoreCopy()
26
27 ActorFollowScoreCache.Instance.clearPendingFollowsScore()
28
29 for (const inbox of Object.keys(pendingScores)) {
30 await ActorFollowModel.updateFollowScore(inbox, pendingScores[inbox])
31 }
32 }
33
34 private async removeBadActorFollows () {
18 if (!isTestInstance()) logger.info('Removing bad actor follows (scheduler).') 35 if (!isTestInstance()) logger.info('Removing bad actor follows (scheduler).')
19 36
20 try { 37 try {
diff --git a/server/lib/schedulers/remove-old-jobs-scheduler.ts b/server/lib/schedulers/remove-old-jobs-scheduler.ts
index a29a6b800..4a4341ba9 100644
--- a/server/lib/schedulers/remove-old-jobs-scheduler.ts
+++ b/server/lib/schedulers/remove-old-jobs-scheduler.ts
@@ -14,10 +14,10 @@ export class RemoveOldJobsScheduler extends AbstractScheduler {
14 super() 14 super()
15 } 15 }
16 16
17 async execute () { 17 protected internalExecute () {
18 if (!isTestInstance()) logger.info('Removing old jobs (scheduler).') 18 if (!isTestInstance()) logger.info('Removing old jobs in scheduler.')
19 19
20 JobQueue.Instance.removeOldJobs() 20 return JobQueue.Instance.removeOldJobs()
21 } 21 }
22 22
23 static get Instance () { 23 static get Instance () {
diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts
index fd2edfd17..2618a5857 100644
--- a/server/lib/schedulers/update-videos-scheduler.ts
+++ b/server/lib/schedulers/update-videos-scheduler.ts
@@ -5,6 +5,8 @@ import { retryTransactionWrapper } from '../../helpers/database-utils'
5import { federateVideoIfNeeded } from '../activitypub' 5import { federateVideoIfNeeded } from '../activitypub'
6import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers' 6import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers'
7import { VideoPrivacy } from '../../../shared/models/videos' 7import { VideoPrivacy } from '../../../shared/models/videos'
8import { Notifier } from '../notifier'
9import { VideoModel } from '../../models/video/video'
8 10
9export class UpdateVideosScheduler extends AbstractScheduler { 11export class UpdateVideosScheduler extends AbstractScheduler {
10 12
@@ -12,30 +14,20 @@ export class UpdateVideosScheduler extends AbstractScheduler {
12 14
13 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.updateVideos 15 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.updateVideos
14 16
15 private isRunning = false
16
17 private constructor () { 17 private constructor () {
18 super() 18 super()
19 } 19 }
20 20
21 async execute () { 21 protected async internalExecute () {
22 if (this.isRunning === true) return 22 return retryTransactionWrapper(this.updateVideos.bind(this))
23 this.isRunning = true
24
25 try {
26 await retryTransactionWrapper(this.updateVideos.bind(this))
27 } catch (err) {
28 logger.error('Cannot execute update videos scheduler.', { err })
29 } finally {
30 this.isRunning = false
31 }
32 } 23 }
33 24
34 private async updateVideos () { 25 private async updateVideos () {
35 if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined 26 if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined
36 27
37 return sequelizeTypescript.transaction(async t => { 28 const publishedVideos = await sequelizeTypescript.transaction(async t => {
38 const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t) 29 const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t)
30 const publishedVideos: VideoModel[] = []
39 31
40 for (const schedule of schedules) { 32 for (const schedule of schedules) {
41 const video = schedule.Video 33 const video = schedule.Video
@@ -50,11 +42,23 @@ export class UpdateVideosScheduler extends AbstractScheduler {
50 42
51 await video.save({ transaction: t }) 43 await video.save({ transaction: t })
52 await federateVideoIfNeeded(video, isNewVideo, t) 44 await federateVideoIfNeeded(video, isNewVideo, t)
45
46 if (oldPrivacy === VideoPrivacy.UNLISTED || oldPrivacy === VideoPrivacy.PRIVATE) {
47 video.ScheduleVideoUpdate = schedule
48 publishedVideos.push(video)
49 }
53 } 50 }
54 51
55 await schedule.destroy({ transaction: t }) 52 await schedule.destroy({ transaction: t })
56 } 53 }
54
55 return publishedVideos
57 }) 56 })
57
58 for (const v of publishedVideos) {
59 Notifier.Instance.notifyOnNewVideo(v)
60 Notifier.Instance.notifyOnPendingVideoPublished(v)
61 }
58 } 62 }
59 63
60 static get Instance () { 64 static get Instance () {
diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts
index c49a8c89a..1a48f2bd0 100644
--- a/server/lib/schedulers/videos-redundancy-scheduler.ts
+++ b/server/lib/schedulers/videos-redundancy-scheduler.ts
@@ -1,22 +1,31 @@
1import { AbstractScheduler } from './abstract-scheduler' 1import { AbstractScheduler } from './abstract-scheduler'
2import { CONFIG, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers' 2import { CONFIG, HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import { VideosRedundancy } from '../../../shared/models/redundancy' 4import { VideosRedundancy } from '../../../shared/models/redundancy'
5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
6import { VideoFileModel } from '../../models/video/video-file' 6import { VideoFileModel } from '../../models/video/video-file'
7import { downloadWebTorrentVideo } from '../../helpers/webtorrent' 7import { downloadWebTorrentVideo } from '../../helpers/webtorrent'
8import { join } from 'path' 8import { join } from 'path'
9import { rename } from 'fs-extra' 9import { move } from 'fs-extra'
10import { getServerActor } from '../../helpers/utils' 10import { getServerActor } from '../../helpers/utils'
11import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send' 11import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
12import { getVideoCacheFileActivityPubUrl } from '../activitypub/url' 12import { getVideoCacheFileActivityPubUrl, getVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url'
13import { removeVideoRedundancy } from '../redundancy' 13import { removeVideoRedundancy } from '../redundancy'
14import { getOrCreateVideoAndAccountAndChannel } from '../activitypub' 14import { getOrCreateVideoAndAccountAndChannel } from '../activitypub'
15import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
16import { VideoModel } from '../../models/video/video'
17import { downloadPlaylistSegments } from '../hls'
18
19type CandidateToDuplicate = {
20 redundancy: VideosRedundancy,
21 video: VideoModel,
22 files: VideoFileModel[],
23 streamingPlaylists: VideoStreamingPlaylistModel[]
24}
15 25
16export class VideosRedundancyScheduler extends AbstractScheduler { 26export class VideosRedundancyScheduler extends AbstractScheduler {
17 27
18 private static instance: AbstractScheduler 28 private static instance: AbstractScheduler
19 private executing = false
20 29
21 protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL 30 protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
22 31
@@ -24,41 +33,39 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
24 super() 33 super()
25 } 34 }
26 35
27 async execute () { 36 protected async internalExecute () {
28 if (this.executing) return 37 for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
29 38 logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy)
30 this.executing = true
31
32 for (const obj of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
33 logger.info('Running redundancy scheduler for strategy %s.', obj.strategy)
34 39
35 try { 40 try {
36 const videoToDuplicate = await this.findVideoToDuplicate(obj) 41 const videoToDuplicate = await this.findVideoToDuplicate(redundancyConfig)
37 if (!videoToDuplicate) continue 42 if (!videoToDuplicate) continue
38 43
39 const videoFiles = videoToDuplicate.VideoFiles 44 const candidateToDuplicate = {
40 videoFiles.forEach(f => f.Video = videoToDuplicate) 45 video: videoToDuplicate,
46 redundancy: redundancyConfig,
47 files: videoToDuplicate.VideoFiles,
48 streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
49 }
41 50
42 await this.purgeCacheIfNeeded(obj, videoFiles) 51 await this.purgeCacheIfNeeded(candidateToDuplicate)
43 52
44 if (await this.isTooHeavy(obj, videoFiles)) { 53 if (await this.isTooHeavy(candidateToDuplicate)) {
45 logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url) 54 logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url)
46 continue 55 continue
47 } 56 }
48 57
49 logger.info('Will duplicate video %s in redundancy scheduler "%s".', videoToDuplicate.url, obj.strategy) 58 logger.info('Will duplicate video %s in redundancy scheduler "%s".', videoToDuplicate.url, redundancyConfig.strategy)
50 59
51 await this.createVideoRedundancy(obj, videoFiles) 60 await this.createVideoRedundancies(candidateToDuplicate)
52 } catch (err) { 61 } catch (err) {
53 logger.error('Cannot run videos redundancy %s.', obj.strategy, { err }) 62 logger.error('Cannot run videos redundancy %s.', redundancyConfig.strategy, { err })
54 } 63 }
55 } 64 }
56 65
57 await this.extendsLocalExpiration() 66 await this.extendsLocalExpiration()
58 67
59 await this.purgeRemoteExpired() 68 await this.purgeRemoteExpired()
60
61 this.executing = false
62 } 69 }
63 70
64 static get Instance () { 71 static get Instance () {
@@ -70,25 +77,35 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
70 77
71 for (const redundancyModel of expired) { 78 for (const redundancyModel of expired) {
72 try { 79 try {
73 await this.extendsOrDeleteRedundancy(redundancyModel) 80 const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
81 const candidate = {
82 redundancy: redundancyConfig,
83 video: null,
84 files: [],
85 streamingPlaylists: []
86 }
87
88 // If the administrator disabled the redundancy or decreased the cache size, remove this redundancy instead of extending it
89 if (!redundancyConfig || await this.isTooHeavy(candidate)) {
90 logger.info('Destroying redundancy %s because the cache size %s is too heavy.', redundancyModel.url, redundancyModel.strategy)
91 await removeVideoRedundancy(redundancyModel)
92 } else {
93 await this.extendsRedundancy(redundancyModel)
94 }
74 } catch (err) { 95 } catch (err) {
75 logger.error('Cannot extend expiration of %s video from our redundancy system.', this.buildEntryLogId(redundancyModel)) 96 logger.error(
97 'Cannot extend or remove expiration of %s video from our redundancy system.', this.buildEntryLogId(redundancyModel),
98 { err }
99 )
76 } 100 }
77 } 101 }
78 } 102 }
79 103
80 private async extendsOrDeleteRedundancy (redundancyModel: VideoRedundancyModel) { 104 private async extendsRedundancy (redundancyModel: VideoRedundancyModel) {
81 // Refresh the video, maybe it was deleted
82 const video = await this.loadAndRefreshVideo(redundancyModel.VideoFile.Video.url)
83
84 if (!video) {
85 logger.info('Destroying existing redundancy %s, because the associated video does not exist anymore.', redundancyModel.url)
86
87 await redundancyModel.destroy()
88 return
89 }
90
91 const redundancy = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy) 105 const redundancy = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
106 // Redundancy strategy disabled, remove our redundancy instead of extending expiration
107 if (!redundancy) await removeVideoRedundancy(redundancyModel)
108
92 await this.extendsExpirationOf(redundancyModel, redundancy.minLifetime) 109 await this.extendsExpirationOf(redundancyModel, redundancy.minLifetime)
93 } 110 }
94 111
@@ -119,49 +136,93 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
119 } 136 }
120 } 137 }
121 138
122 private async createVideoRedundancy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { 139 private async createVideoRedundancies (data: CandidateToDuplicate) {
123 const serverActor = await getServerActor() 140 const video = await this.loadAndRefreshVideo(data.video.url)
124 141
125 for (const file of filesToDuplicate) { 142 if (!video) {
126 const video = await this.loadAndRefreshVideo(file.Video.url) 143 logger.info('Video %s we want to duplicate does not existing anymore, skipping.', data.video.url)
127 144
145 return
146 }
147
148 for (const file of data.files) {
128 const existingRedundancy = await VideoRedundancyModel.loadLocalByFileId(file.id) 149 const existingRedundancy = await VideoRedundancyModel.loadLocalByFileId(file.id)
129 if (existingRedundancy) { 150 if (existingRedundancy) {
130 await this.extendsOrDeleteRedundancy(existingRedundancy) 151 await this.extendsRedundancy(existingRedundancy)
131 152
132 continue 153 continue
133 } 154 }
134 155
135 if (!video) { 156 await this.createVideoFileRedundancy(data.redundancy, video, file)
136 logger.info('Video %s we want to duplicate does not existing anymore, skipping.', file.Video.url) 157 }
158
159 for (const streamingPlaylist of data.streamingPlaylists) {
160 const existingRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(streamingPlaylist.id)
161 if (existingRedundancy) {
162 await this.extendsRedundancy(existingRedundancy)
137 163
138 continue 164 continue
139 } 165 }
140 166
141 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy) 167 await this.createStreamingPlaylistRedundancy(data.redundancy, video, streamingPlaylist)
168 }
169 }
142 170
143 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 171 private async createVideoFileRedundancy (redundancy: VideosRedundancy, video: VideoModel, file: VideoFileModel) {
144 const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs) 172 file.Video = video
145 173
146 const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT) 174 const serverActor = await getServerActor()
147 175
148 const destPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(file)) 176 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy)
149 await rename(tmpPath, destPath)
150 177
151 const createdModel = await VideoRedundancyModel.create({ 178 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
152 expiresOn: this.buildNewExpiration(redundancy.minLifetime), 179 const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs)
153 url: getVideoCacheFileActivityPubUrl(file),
154 fileUrl: video.getVideoFileUrl(file, CONFIG.WEBSERVER.URL),
155 strategy: redundancy.strategy,
156 videoFileId: file.id,
157 actorId: serverActor.id
158 })
159 createdModel.VideoFile = file
160 180
161 await sendCreateCacheFile(serverActor, createdModel) 181 const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT)
162 182
163 logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url) 183 const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, video.getVideoFilename(file))
164 } 184 await move(tmpPath, destPath)
185
186 const createdModel = await VideoRedundancyModel.create({
187 expiresOn: this.buildNewExpiration(redundancy.minLifetime),
188 url: getVideoCacheFileActivityPubUrl(file),
189 fileUrl: video.getVideoRedundancyUrl(file, CONFIG.WEBSERVER.URL),
190 strategy: redundancy.strategy,
191 videoFileId: file.id,
192 actorId: serverActor.id
193 })
194
195 createdModel.VideoFile = file
196
197 await sendCreateCacheFile(serverActor, video, createdModel)
198
199 logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url)
200 }
201
202 private async createStreamingPlaylistRedundancy (redundancy: VideosRedundancy, video: VideoModel, playlist: VideoStreamingPlaylistModel) {
203 playlist.Video = video
204
205 const serverActor = await getServerActor()
206
207 logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, redundancy.strategy)
208
209 const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid)
210 await downloadPlaylistSegments(playlist.playlistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT)
211
212 const createdModel = await VideoRedundancyModel.create({
213 expiresOn: this.buildNewExpiration(redundancy.minLifetime),
214 url: getVideoCacheStreamingPlaylistActivityPubUrl(video, playlist),
215 fileUrl: playlist.getVideoRedundancyUrl(CONFIG.WEBSERVER.URL),
216 strategy: redundancy.strategy,
217 videoStreamingPlaylistId: playlist.id,
218 actorId: serverActor.id
219 })
220
221 createdModel.VideoStreamingPlaylist = playlist
222
223 await sendCreateCacheFile(serverActor, video, createdModel)
224
225 logger.info('Duplicated playlist %s -> %s.', playlist.playlistUrl, createdModel.url)
165 } 226 }
166 227
167 private async extendsExpirationOf (redundancy: VideoRedundancyModel, expiresAfterMs: number) { 228 private async extendsExpirationOf (redundancy: VideoRedundancyModel, expiresAfterMs: number) {
@@ -175,8 +236,9 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
175 await sendUpdateCacheFile(serverActor, redundancy) 236 await sendUpdateCacheFile(serverActor, redundancy)
176 } 237 }
177 238
178 private async purgeCacheIfNeeded (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { 239 private async purgeCacheIfNeeded (candidateToDuplicate: CandidateToDuplicate) {
179 while (this.isTooHeavy(redundancy, filesToDuplicate)) { 240 while (this.isTooHeavy(candidateToDuplicate)) {
241 const redundancy = candidateToDuplicate.redundancy
180 const toDelete = await VideoRedundancyModel.loadOldestLocalThatAlreadyExpired(redundancy.strategy, redundancy.minLifetime) 242 const toDelete = await VideoRedundancyModel.loadOldestLocalThatAlreadyExpired(redundancy.strategy, redundancy.minLifetime)
181 if (!toDelete) return 243 if (!toDelete) return
182 244
@@ -184,12 +246,13 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
184 } 246 }
185 } 247 }
186 248
187 private async isTooHeavy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { 249 private async isTooHeavy (candidateToDuplicate: CandidateToDuplicate) {
188 const maxSize = redundancy.size - this.getTotalFileSizes(filesToDuplicate) 250 const maxSize = candidateToDuplicate.redundancy.size
189 251
190 const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(redundancy.strategy) 252 const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(candidateToDuplicate.redundancy.strategy)
253 const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(candidateToDuplicate.files, candidateToDuplicate.streamingPlaylists)
191 254
192 return totalDuplicated > maxSize 255 return totalWillDuplicate > maxSize
193 } 256 }
194 257
195 private buildNewExpiration (expiresAfterMs: number) { 258 private buildNewExpiration (expiresAfterMs: number) {
@@ -197,13 +260,15 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
197 } 260 }
198 261
199 private buildEntryLogId (object: VideoRedundancyModel) { 262 private buildEntryLogId (object: VideoRedundancyModel) {
200 return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}` 263 if (object.VideoFile) return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}`
264
265 return `${object.VideoStreamingPlaylist.playlistUrl}`
201 } 266 }
202 267
203 private getTotalFileSizes (files: VideoFileModel[]) { 268 private getTotalFileSizes (files: VideoFileModel[], playlists: VideoStreamingPlaylistModel[]) {
204 const fileReducer = (previous: number, current: VideoFileModel) => previous + current.size 269 const fileReducer = (previous: number, current: VideoFileModel) => previous + current.size
205 270
206 return files.reduce(fileReducer, 0) 271 return files.reduce(fileReducer, 0) * playlists.length
207 } 272 }
208 273
209 private async loadAndRefreshVideo (videoUrl: string) { 274 private async loadAndRefreshVideo (videoUrl: string) {
diff --git a/server/lib/schedulers/youtube-dl-update-scheduler.ts b/server/lib/schedulers/youtube-dl-update-scheduler.ts
index 461cd045e..aa027116d 100644
--- a/server/lib/schedulers/youtube-dl-update-scheduler.ts
+++ b/server/lib/schedulers/youtube-dl-update-scheduler.ts
@@ -12,7 +12,7 @@ export class YoutubeDlUpdateScheduler extends AbstractScheduler {
12 super() 12 super()
13 } 13 }
14 14
15 execute () { 15 protected internalExecute () {
16 return updateYoutubeDLBinary() 16 return updateYoutubeDLBinary()
17 } 17 }
18 18
diff --git a/server/lib/user.ts b/server/lib/user.ts
index db29469eb..a39ef6c3d 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -9,6 +9,8 @@ import { createVideoChannel } from './video-channel'
9import { VideoChannelModel } from '../models/video/video-channel' 9import { VideoChannelModel } from '../models/video/video-channel'
10import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' 10import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
11import { ActorModel } from '../models/activitypub/actor' 11import { ActorModel } from '../models/activitypub/actor'
12import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
13import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
12 14
13async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { 15async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
14 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => { 16 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
@@ -18,7 +20,9 @@ async function createUserAccountAndChannel (userToCreate: UserModel, validateUse
18 } 20 }
19 21
20 const userCreated = await userToCreate.save(userOptions) 22 const userCreated = await userToCreate.save(userOptions)
21 const accountCreated = await createLocalAccountWithoutKeys(userToCreate.username, userToCreate.id, null, t) 23 userCreated.NotificationSetting = await createDefaultUserNotificationSettings(userCreated, t)
24
25 const accountCreated = await createLocalAccountWithoutKeys(userCreated.username, userCreated.id, null, t)
22 userCreated.Account = accountCreated 26 userCreated.Account = accountCreated
23 27
24 let channelName = userCreated.username + '_channel' 28 let channelName = userCreated.username + '_channel'
@@ -37,8 +41,13 @@ async function createUserAccountAndChannel (userToCreate: UserModel, validateUse
37 return { user: userCreated, account: accountCreated, videoChannel } 41 return { user: userCreated, account: accountCreated, videoChannel }
38 }) 42 })
39 43
40 account.Actor = await setAsyncActorKeys(account.Actor) 44 const [ accountKeys, channelKeys ] = await Promise.all([
41 videoChannel.Actor = await setAsyncActorKeys(videoChannel.Actor) 45 setAsyncActorKeys(account.Actor),
46 setAsyncActorKeys(videoChannel.Actor)
47 ])
48
49 account.Actor = accountKeys
50 videoChannel.Actor = channelKeys
42 51
43 return { user, account, videoChannel } as { user: UserModel, account: AccountModel, videoChannel: VideoChannelModel } 52 return { user, account, videoChannel } as { user: UserModel, account: AccountModel, videoChannel: VideoChannelModel }
44} 53}
@@ -83,3 +92,22 @@ export {
83 createUserAccountAndChannel, 92 createUserAccountAndChannel,
84 createLocalAccountWithoutKeys 93 createLocalAccountWithoutKeys
85} 94}
95
96// ---------------------------------------------------------------------------
97
98function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
99 const values: UserNotificationSetting & { userId: number } = {
100 userId: user.id,
101 newVideoFromSubscription: UserNotificationSettingValue.WEB,
102 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
103 myVideoImportFinished: UserNotificationSettingValue.WEB,
104 myVideoPublished: UserNotificationSettingValue.WEB,
105 videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
106 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
107 newUserRegistration: UserNotificationSettingValue.WEB,
108 commentMention: UserNotificationSettingValue.WEB,
109 newFollow: UserNotificationSettingValue.WEB
110 }
111
112 return UserNotificationSettingModel.create(values, { transaction: t })
113}
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 70ba7c303..59bce7520 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -64,10 +64,8 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
64 } 64 }
65 65
66 const parentCommentThread = idx[childComment.inReplyToCommentId] 66 const parentCommentThread = idx[childComment.inReplyToCommentId]
67 if (!parentCommentThread) { 67 // Maybe the parent comment was blocked by the admin/user
68 const msg = `Cannot format video thread tree, parent ${childComment.inReplyToCommentId} not found for child ${childComment.id}` 68 if (!parentCommentThread) continue
69 throw new Error(msg)
70 }
71 69
72 parentCommentThread.children.push(childCommentThread) 70 parentCommentThread.children.push(childCommentThread)
73 idx[childComment.id] = childCommentThread 71 idx[childComment.id] = childCommentThread
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
index bf3ff78c2..086b860a2 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/video-transcoding.ts
@@ -1,22 +1,27 @@
1import { CONFIG } from '../initializers' 1import { CONFIG, HLS_PLAYLIST_DIRECTORY } from '../initializers'
2import { join, extname } from 'path' 2import { extname, join } from 'path'
3import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils' 3import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
4import { copy, remove, rename, stat } from 'fs-extra' 4import { copy, ensureDir, move, remove, stat } from 'fs-extra'
5import { logger } from '../helpers/logger' 5import { logger } from '../helpers/logger'
6import { VideoResolution } from '../../shared/models/videos' 6import { VideoResolution } from '../../shared/models/videos'
7import { VideoFileModel } from '../models/video/video-file' 7import { VideoFileModel } from '../models/video/video-file'
8import { VideoModel } from '../models/video/video' 8import { VideoModel } from '../models/video/video'
9import { updateMasterHLSPlaylist, updateSha256Segments } from './hls'
10import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
11import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
9 12
10async function optimizeOriginalVideofile (video: VideoModel) { 13async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFileModel) {
11 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR 14 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
12 const newExtname = '.mp4' 15 const newExtname = '.mp4'
13 const inputVideoFile = video.getOriginalFile() 16
17 const inputVideoFile = inputVideoFileArg ? inputVideoFileArg : video.getOriginalFile()
14 const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile)) 18 const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
15 const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname) 19 const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
16 20
17 const transcodeOptions = { 21 const transcodeOptions = {
18 inputPath: videoInputPath, 22 inputPath: videoInputPath,
19 outputPath: videoTranscodedPath 23 outputPath: videoTranscodedPath,
24 resolution: inputVideoFile.resolution
20 } 25 }
21 26
22 // Could be very long! 27 // Could be very long!
@@ -29,7 +34,7 @@ async function optimizeOriginalVideofile (video: VideoModel) {
29 inputVideoFile.set('extname', newExtname) 34 inputVideoFile.set('extname', newExtname)
30 35
31 const videoOutputPath = video.getVideoFilePath(inputVideoFile) 36 const videoOutputPath = video.getVideoFilePath(inputVideoFile)
32 await rename(videoTranscodedPath, videoOutputPath) 37 await move(videoTranscodedPath, videoOutputPath)
33 const stats = await stat(videoOutputPath) 38 const stats = await stat(videoOutputPath)
34 const fps = await getVideoFileFPS(videoOutputPath) 39 const fps = await getVideoFileFPS(videoOutputPath)
35 40
@@ -46,7 +51,7 @@ async function optimizeOriginalVideofile (video: VideoModel) {
46 } 51 }
47} 52}
48 53
49async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) { 54async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortrait: boolean) {
50 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR 55 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
51 const extname = '.mp4' 56 const extname = '.mp4'
52 57
@@ -59,13 +64,13 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
59 size: 0, 64 size: 0,
60 videoId: video.id 65 videoId: video.id
61 }) 66 })
62 const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile)) 67 const videoOutputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(newVideoFile))
63 68
64 const transcodeOptions = { 69 const transcodeOptions = {
65 inputPath: videoInputPath, 70 inputPath: videoInputPath,
66 outputPath: videoOutputPath, 71 outputPath: videoOutputPath,
67 resolution, 72 resolution,
68 isPortraitMode 73 isPortraitMode: isPortrait
69 } 74 }
70 75
71 await transcode(transcodeOptions) 76 await transcode(transcodeOptions)
@@ -83,6 +88,41 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
83 video.VideoFiles.push(newVideoFile) 88 video.VideoFiles.push(newVideoFile)
84} 89}
85 90
91async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
92 const baseHlsDirectory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
93 await ensureDir(join(HLS_PLAYLIST_DIRECTORY, video.uuid))
94
95 const videoInputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(video.getOriginalFile()))
96 const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
97
98 const transcodeOptions = {
99 inputPath: videoInputPath,
100 outputPath,
101 resolution,
102 isPortraitMode,
103
104 hlsPlaylist: {
105 videoFilename: VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, resolution)
106 }
107 }
108
109 await transcode(transcodeOptions)
110
111 await updateMasterHLSPlaylist(video)
112 await updateSha256Segments(video)
113
114 const playlistUrl = CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
115
116 await VideoStreamingPlaylistModel.upsert({
117 videoId: video.id,
118 playlistUrl,
119 segmentsSha256Url: CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid),
120 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
121
122 type: VideoStreamingPlaylistType.HLS
123 })
124}
125
86async function importVideoFile (video: VideoModel, inputFilePath: string) { 126async function importVideoFile (video: VideoModel, inputFilePath: string) {
87 const { videoFileResolution } = await getVideoFileResolution(inputFilePath) 127 const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
88 const { size } = await stat(inputFilePath) 128 const { size } = await stat(inputFilePath)
@@ -124,7 +164,8 @@ async function importVideoFile (video: VideoModel, inputFilePath: string) {
124} 164}
125 165
126export { 166export {
127 optimizeOriginalVideofile, 167 generateHlsPlaylist,
168 optimizeVideofile,
128 transcodeOriginalVideofile, 169 transcodeOriginalVideofile,
129 importVideoFile 170 importVideoFile
130} 171}
diff --git a/server/middlewares/activitypub.ts b/server/middlewares/activitypub.ts
index d7f59be8c..01e5dd24e 100644
--- a/server/middlewares/activitypub.ts
+++ b/server/middlewares/activitypub.ts
@@ -2,34 +2,32 @@ import { eachSeries } from 'async'
2import { NextFunction, Request, RequestHandler, Response } from 'express' 2import { NextFunction, Request, RequestHandler, Response } from 'express'
3import { ActivityPubSignature } from '../../shared' 3import { ActivityPubSignature } from '../../shared'
4import { logger } from '../helpers/logger' 4import { logger } from '../helpers/logger'
5import { isSignatureVerified } from '../helpers/peertube-crypto' 5import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../helpers/peertube-crypto'
6import { ACCEPT_HEADERS, ACTIVITY_PUB } from '../initializers' 6import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers'
7import { getOrCreateActorAndServerAndModel } from '../lib/activitypub' 7import { getOrCreateActorAndServerAndModel } from '../lib/activitypub'
8import { ActorModel } from '../models/activitypub/actor' 8import { ActorModel } from '../models/activitypub/actor'
9import { loadActorUrlOrGetFromWebfinger } from '../helpers/webfinger'
9 10
10async function checkSignature (req: Request, res: Response, next: NextFunction) { 11async function checkSignature (req: Request, res: Response, next: NextFunction) {
11 const signatureObject: ActivityPubSignature = req.body.signature 12 try {
13 const httpSignatureChecked = await checkHttpSignature(req, res)
14 if (httpSignatureChecked !== true) return
12 15
13 const [ creator ] = signatureObject.creator.split('#') 16 const actor: ActorModel = res.locals.signature.actor
14 17
15 logger.debug('Checking signature of actor %s...', creator) 18 // Forwarded activity
19 const bodyActor = req.body.actor
20 const bodyActorId = bodyActor && bodyActor.id ? bodyActor.id : bodyActor
21 if (bodyActorId && bodyActorId !== actor.url) {
22 const jsonLDSignatureChecked = await checkJsonLDSignature(req, res)
23 if (jsonLDSignatureChecked !== true) return
24 }
16 25
17 let actor: ActorModel 26 return next()
18 try {
19 actor = await getOrCreateActorAndServerAndModel(creator)
20 } catch (err) { 27 } catch (err) {
21 logger.warn('Cannot create remote actor %s and check signature.', creator, { err }) 28 logger.error('Error in ActivityPub signature checker.', err)
22 return res.sendStatus(403) 29 return res.sendStatus(403)
23 } 30 }
24
25 const verified = await isSignatureVerified(actor, req.body)
26 if (verified === false) return res.sendStatus(403)
27
28 res.locals.signature = {
29 actor
30 }
31
32 return next()
33} 31}
34 32
35function executeIfActivityPub (fun: RequestHandler | RequestHandler[]) { 33function executeIfActivityPub (fun: RequestHandler | RequestHandler[]) {
@@ -55,5 +53,66 @@ function executeIfActivityPub (fun: RequestHandler | RequestHandler[]) {
55 53
56export { 54export {
57 checkSignature, 55 checkSignature,
58 executeIfActivityPub 56 executeIfActivityPub,
57 checkHttpSignature
58}
59
60// ---------------------------------------------------------------------------
61
62async function checkHttpSignature (req: Request, res: Response) {
63 // FIXME: mastodon does not include the Signature scheme
64 const sig = req.headers[HTTP_SIGNATURE.HEADER_NAME] as string
65 if (sig && sig.startsWith('Signature ') === false) req.headers[HTTP_SIGNATURE.HEADER_NAME] = 'Signature ' + sig
66
67 const parsed = parseHTTPSignature(req)
68
69 const keyId = parsed.keyId
70 if (!keyId) {
71 res.sendStatus(403)
72 return false
73 }
74
75 logger.debug('Checking HTTP signature of actor %s...', keyId)
76
77 let [ actorUrl ] = keyId.split('#')
78 if (actorUrl.startsWith('acct:')) {
79 actorUrl = await loadActorUrlOrGetFromWebfinger(actorUrl.replace(/^acct:/, ''))
80 }
81
82 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
83
84 const verified = isHTTPSignatureVerified(parsed, actor)
85 if (verified !== true) {
86 res.sendStatus(403)
87 return false
88 }
89
90 res.locals.signature = { actor }
91
92 return true
93}
94
95async function checkJsonLDSignature (req: Request, res: Response) {
96 const signatureObject: ActivityPubSignature = req.body.signature
97
98 if (!signatureObject || !signatureObject.creator) {
99 res.sendStatus(403)
100 return false
101 }
102
103 const [ creator ] = signatureObject.creator.split('#')
104
105 logger.debug('Checking JsonLD signature of actor %s...', creator)
106
107 const actor = await getOrCreateActorAndServerAndModel(creator)
108 const verified = await isJsonLDSignatureVerified(actor, req.body)
109
110 if (verified !== true) {
111 res.sendStatus(403)
112 return false
113 }
114
115 res.locals.signature = { actor }
116
117 return true
59} 118}
diff --git a/server/middlewares/cache.ts b/server/middlewares/cache.ts
index 1e00fc731..8ffe75700 100644
--- a/server/middlewares/cache.ts
+++ b/server/middlewares/cache.ts
@@ -19,6 +19,7 @@ function cacheRoute (lifetimeArg: string | number) {
19 logger.debug('No cached results for route %s.', req.originalUrl) 19 logger.debug('No cached results for route %s.', req.originalUrl)
20 20
21 const sendSave = res.send.bind(res) 21 const sendSave = res.send.bind(res)
22 const redirectSave = res.redirect.bind(res)
22 23
23 res.send = (body) => { 24 res.send = (body) => {
24 if (res.statusCode >= 200 && res.statusCode < 400) { 25 if (res.statusCode >= 200 && res.statusCode < 400) {
@@ -38,6 +39,12 @@ function cacheRoute (lifetimeArg: string | number) {
38 return sendSave(body) 39 return sendSave(body)
39 } 40 }
40 41
42 res.redirect = url => {
43 done()
44
45 return redirectSave(url)
46 }
47
41 return next() 48 return next()
42 } 49 }
43 50
diff --git a/server/middlewares/csp.ts b/server/middlewares/csp.ts
new file mode 100644
index 000000000..5fa9d1ab5
--- /dev/null
+++ b/server/middlewares/csp.ts
@@ -0,0 +1,44 @@
1import * as helmet from 'helmet'
2import { CONFIG } from '../initializers/constants'
3
4const baseDirectives = Object.assign({},
5 {
6 defaultSrc: ["'none'"], // by default, not specifying default-src = '*'
7 connectSrc: ['*', 'data:'],
8 mediaSrc: ["'self'", 'https:', 'blob:'],
9 fontSrc: ["'self'", 'data:'],
10 imgSrc: ["'self'", 'data:'],
11 scriptSrc: ["'self' 'unsafe-inline' 'unsafe-eval'"],
12 styleSrc: ["'self' 'unsafe-inline'"],
13 objectSrc: ["'none'"], // only define to allow plugins, else let defaultSrc 'none' block it
14 formAction: ["'self'"],
15 frameAncestors: ["'none'"],
16 baseUri: ["'self'"],
17 manifestSrc: ["'self'"],
18 frameSrc: ["'self'"], // instead of deprecated child-src / self because of test-embed
19 workerSrc: ["'self'", 'blob:'] // instead of deprecated child-src
20 },
21 CONFIG.SERVICES['CSP-LOGGER'] ? { reportUri: CONFIG.SERVICES['CSP-LOGGER'] } : {},
22 CONFIG.WEBSERVER.SCHEME === 'https' ? { upgradeInsecureRequests: true } : {}
23)
24
25const baseCSP = helmet.contentSecurityPolicy({
26 directives: baseDirectives,
27 browserSniff: false,
28 reportOnly: true
29})
30
31const embedCSP = helmet.contentSecurityPolicy({
32 directives: Object.assign(baseDirectives, {
33 frameAncestors: ['*']
34 }),
35 browserSniff: false, // assumes a modern browser, but allows CDN in front
36 reportOnly: true
37})
38
39// ---------------------------------------------------------------------------
40
41export {
42 baseCSP,
43 embedCSP
44}
diff --git a/server/middlewares/dnt.ts b/server/middlewares/dnt.ts
index cabad39c6..607def855 100644
--- a/server/middlewares/dnt.ts
+++ b/server/middlewares/dnt.ts
@@ -10,4 +10,4 @@ const advertiseDoNotTrack = (_, res, next) => {
10 10
11export { 11export {
12 advertiseDoNotTrack 12 advertiseDoNotTrack
13 } 13}
diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts
index 0cef26953..b758a8586 100644
--- a/server/middlewares/index.ts
+++ b/server/middlewares/index.ts
@@ -6,3 +6,5 @@ export * from './pagination'
6export * from './servers' 6export * from './servers'
7export * from './sort' 7export * from './sort'
8export * from './user-right' 8export * from './user-right'
9export * from './dnt'
10export * from './csp'
diff --git a/server/middlewares/oauth.ts b/server/middlewares/oauth.ts
index 5233b66bd..1d193d467 100644
--- a/server/middlewares/oauth.ts
+++ b/server/middlewares/oauth.ts
@@ -3,6 +3,8 @@ import * as OAuthServer from 'express-oauth-server'
3import 'express-validator' 3import 'express-validator'
4import { OAUTH_LIFETIME } from '../initializers' 4import { OAUTH_LIFETIME } from '../initializers'
5import { logger } from '../helpers/logger' 5import { logger } from '../helpers/logger'
6import { Socket } from 'socket.io'
7import { getAccessToken } from '../lib/oauth-model'
6 8
7const oAuthServer = new OAuthServer({ 9const oAuthServer = new OAuthServer({
8 useErrorHandler: true, 10 useErrorHandler: true,
@@ -28,9 +30,43 @@ function authenticate (req: express.Request, res: express.Response, next: expres
28 }) 30 })
29} 31}
30 32
33function authenticateSocket (socket: Socket, next: (err?: any) => void) {
34 const accessToken = socket.handshake.query.accessToken
35
36 logger.debug('Checking socket access token %s.', accessToken)
37
38 getAccessToken(accessToken)
39 .then(tokenDB => {
40 const now = new Date()
41
42 if (!tokenDB || tokenDB.accessTokenExpiresAt < now || tokenDB.refreshTokenExpiresAt < now) {
43 return next(new Error('Invalid access token.'))
44 }
45
46 socket.handshake.query.user = tokenDB.User
47
48 return next()
49 })
50}
51
52function authenticatePromiseIfNeeded (req: express.Request, res: express.Response) {
53 return new Promise(resolve => {
54 // Already authenticated? (or tried to)
55 if (res.locals.oauth && res.locals.oauth.token.User) return resolve()
56
57 if (res.locals.authenticated === false) return res.sendStatus(401)
58
59 authenticate(req, res, () => {
60 return resolve()
61 })
62 })
63}
64
31function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) { 65function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
32 if (req.header('authorization')) return authenticate(req, res, next) 66 if (req.header('authorization')) return authenticate(req, res, next)
33 67
68 res.locals.authenticated = false
69
34 return next() 70 return next()
35} 71}
36 72
@@ -53,6 +89,8 @@ function token (req: express.Request, res: express.Response, next: express.NextF
53 89
54export { 90export {
55 authenticate, 91 authenticate,
92 authenticateSocket,
93 authenticatePromiseIfNeeded,
56 optionalAuthenticate, 94 optionalAuthenticate,
57 token 95 token
58} 96}
diff --git a/server/middlewares/validators/activitypub/signature.ts b/server/middlewares/validators/activitypub/signature.ts
index 4efe9aafa..be14e92ea 100644
--- a/server/middlewares/validators/activitypub/signature.ts
+++ b/server/middlewares/validators/activitypub/signature.ts
@@ -9,10 +9,18 @@ import { logger } from '../../../helpers/logger'
9import { areValidationErrors } from '../utils' 9import { areValidationErrors } from '../utils'
10 10
11const signatureValidator = [ 11const signatureValidator = [
12 body('signature.type').custom(isSignatureTypeValid).withMessage('Should have a valid signature type'), 12 body('signature.type')
13 body('signature.created').custom(isDateValid).withMessage('Should have a valid signature created date'), 13 .optional()
14 body('signature.creator').custom(isSignatureCreatorValid).withMessage('Should have a valid signature creator'), 14 .custom(isSignatureTypeValid).withMessage('Should have a valid signature type'),
15 body('signature.signatureValue').custom(isSignatureValueValid).withMessage('Should have a valid signature value'), 15 body('signature.created')
16 .optional()
17 .custom(isDateValid).withMessage('Should have a valid signature created date'),
18 body('signature.creator')
19 .optional()
20 .custom(isSignatureCreatorValid).withMessage('Should have a valid signature creator'),
21 body('signature.signatureValue')
22 .optional()
23 .custom(isSignatureValueValid).withMessage('Should have a valid signature value'),
16 24
17 (req: express.Request, res: express.Response, next: express.NextFunction) => { 25 (req: express.Request, res: express.Response, next: express.NextFunction) => {
18 logger.debug('Checking activitypub signature parameter', { parameters: { signature: req.body.signature } }) 26 logger.debug('Checking activitypub signature parameter', { parameters: { signature: req.body.signature } })
diff --git a/server/middlewares/validators/blocklist.ts b/server/middlewares/validators/blocklist.ts
new file mode 100644
index 000000000..109276c63
--- /dev/null
+++ b/server/middlewares/validators/blocklist.ts
@@ -0,0 +1,172 @@
1import { body, param } from 'express-validator/check'
2import * as express from 'express'
3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils'
5import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts'
6import { UserModel } from '../../models/account/user'
7import { AccountBlocklistModel } from '../../models/account/account-blocklist'
8import { isHostValid } from '../../helpers/custom-validators/servers'
9import { ServerBlocklistModel } from '../../models/server/server-blocklist'
10import { ServerModel } from '../../models/server/server'
11import { CONFIG } from '../../initializers'
12import { getServerActor } from '../../helpers/utils'
13
14const blockAccountValidator = [
15 body('accountName').exists().withMessage('Should have an account name with host'),
16
17 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
18 logger.debug('Checking blockAccountByAccountValidator parameters', { parameters: req.body })
19
20 if (areValidationErrors(req, res)) return
21 if (!await isAccountNameWithHostExist(req.body.accountName, res)) return
22
23 const user = res.locals.oauth.token.User as UserModel
24 const accountToBlock = res.locals.account
25
26 if (user.Account.id === accountToBlock.id) {
27 res.status(409)
28 .send({ error: 'You cannot block yourself.' })
29 .end()
30
31 return
32 }
33
34 return next()
35 }
36]
37
38const unblockAccountByAccountValidator = [
39 param('accountName').exists().withMessage('Should have an account name with host'),
40
41 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 logger.debug('Checking unblockAccountByAccountValidator parameters', { parameters: req.params })
43
44 if (areValidationErrors(req, res)) return
45 if (!await isAccountNameWithHostExist(req.params.accountName, res)) return
46
47 const user = res.locals.oauth.token.User as UserModel
48 const targetAccount = res.locals.account
49 if (!await isUnblockAccountExists(user.Account.id, targetAccount.id, res)) return
50
51 return next()
52 }
53]
54
55const unblockAccountByServerValidator = [
56 param('accountName').exists().withMessage('Should have an account name with host'),
57
58 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
59 logger.debug('Checking unblockAccountByServerValidator parameters', { parameters: req.params })
60
61 if (areValidationErrors(req, res)) return
62 if (!await isAccountNameWithHostExist(req.params.accountName, res)) return
63
64 const serverActor = await getServerActor()
65 const targetAccount = res.locals.account
66 if (!await isUnblockAccountExists(serverActor.Account.id, targetAccount.id, res)) return
67
68 return next()
69 }
70]
71
72const blockServerValidator = [
73 body('host').custom(isHostValid).withMessage('Should have a valid host'),
74
75 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
76 logger.debug('Checking serverGetValidator parameters', { parameters: req.body })
77
78 if (areValidationErrors(req, res)) return
79
80 const host: string = req.body.host
81
82 if (host === CONFIG.WEBSERVER.HOST) {
83 return res.status(409)
84 .send({ error: 'You cannot block your own server.' })
85 .end()
86 }
87
88 const server = await ServerModel.loadByHost(host)
89 if (!server) {
90 return res.status(404)
91 .send({ error: 'Server host not found.' })
92 .end()
93 }
94
95 res.locals.server = server
96
97 return next()
98 }
99]
100
101const unblockServerByAccountValidator = [
102 param('host').custom(isHostValid).withMessage('Should have an account name with host'),
103
104 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
105 logger.debug('Checking unblockServerByAccountValidator parameters', { parameters: req.params })
106
107 if (areValidationErrors(req, res)) return
108
109 const user = res.locals.oauth.token.User as UserModel
110 if (!await isUnblockServerExists(user.Account.id, req.params.host, res)) return
111
112 return next()
113 }
114]
115
116const unblockServerByServerValidator = [
117 param('host').custom(isHostValid).withMessage('Should have an account name with host'),
118
119 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
120 logger.debug('Checking unblockServerByServerValidator parameters', { parameters: req.params })
121
122 if (areValidationErrors(req, res)) return
123
124 const serverActor = await getServerActor()
125 if (!await isUnblockServerExists(serverActor.Account.id, req.params.host, res)) return
126
127 return next()
128 }
129]
130
131// ---------------------------------------------------------------------------
132
133export {
134 blockServerValidator,
135 blockAccountValidator,
136 unblockAccountByAccountValidator,
137 unblockServerByAccountValidator,
138 unblockAccountByServerValidator,
139 unblockServerByServerValidator
140}
141
142// ---------------------------------------------------------------------------
143
144async function isUnblockAccountExists (accountId: number, targetAccountId: number, res: express.Response) {
145 const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId)
146 if (!accountBlock) {
147 res.status(404)
148 .send({ error: 'Account block entry not found.' })
149 .end()
150
151 return false
152 }
153
154 res.locals.accountBlock = accountBlock
155
156 return true
157}
158
159async function isUnblockServerExists (accountId: number, host: string, res: express.Response) {
160 const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host)
161 if (!serverBlock) {
162 res.status(404)
163 .send({ error: 'Server block entry not found.' })
164 .end()
165
166 return false
167 }
168
169 res.locals.serverBlock = serverBlock
170
171 return true
172}
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index f3f257d57..90108fa82 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -1,29 +1,44 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator/check' 2import { body } from 'express-validator/check'
3import { isUserNSFWPolicyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' 3import { isUserNSFWPolicyValid, isUserVideoQuotaValid, isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6 6
7const customConfigUpdateValidator = [ 7const customConfigUpdateValidator = [
8 body('instance.name').exists().withMessage('Should have a valid instance name'), 8 body('instance.name').exists().withMessage('Should have a valid instance name'),
9 body('instance.shortDescription').exists().withMessage('Should have a valid instance short description'),
9 body('instance.description').exists().withMessage('Should have a valid instance description'), 10 body('instance.description').exists().withMessage('Should have a valid instance description'),
10 body('instance.terms').exists().withMessage('Should have a valid instance terms'), 11 body('instance.terms').exists().withMessage('Should have a valid instance terms'),
11 body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'), 12 body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'),
12 body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'), 13 body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'),
13 body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'), 14 body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'),
14 body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'), 15 body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'),
15 body('cache.previews.size').isInt().withMessage('Should have a valid previews size'), 16
17 body('services.twitter.username').exists().withMessage('Should have a valid twitter username'),
18 body('services.twitter.whitelisted').isBoolean().withMessage('Should have a valid twitter whitelisted boolean'),
19
20 body('cache.previews.size').isInt().withMessage('Should have a valid previews cache size'),
21 body('cache.captions.size').isInt().withMessage('Should have a valid captions cache size'),
22
16 body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'), 23 body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'),
17 body('signup.limit').isInt().withMessage('Should have a valid signup limit'), 24 body('signup.limit').isInt().withMessage('Should have a valid signup limit'),
25 body('signup.requiresEmailVerification').isBoolean().withMessage('Should have a valid requiresEmailVerification boolean'),
26
18 body('admin.email').isEmail().withMessage('Should have a valid administrator email'), 27 body('admin.email').isEmail().withMessage('Should have a valid administrator email'),
28 body('contactForm.enabled').isBoolean().withMessage('Should have a valid contact form enabled boolean'),
29
19 body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'), 30 body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'),
31 body('user.videoQuotaDaily').custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily video quota'),
32
20 body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'), 33 body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'),
34 body('transcoding.allowAdditionalExtensions').isBoolean().withMessage('Should have a valid additional extensions boolean'),
21 body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'), 35 body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'),
22 body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'), 36 body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
23 body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'), 37 body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
24 body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'), 38 body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
25 body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'), 39 body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
26 body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'), 40 body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
41
27 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'), 42 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
28 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'), 43 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
29 44
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 17226614c..65dd00335 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -1,4 +1,5 @@
1export * from './account' 1export * from './account'
2export * from './blocklist'
2export * from './oembed' 3export * from './oembed'
3export * from './activitypub' 4export * from './activitypub'
4export * from './pagination' 5export * from './pagination'
@@ -10,3 +11,5 @@ export * from './user-subscriptions'
10export * from './videos' 11export * from './videos'
11export * from './webfinger' 12export * from './webfinger'
12export * from './search' 13export * from './search'
14export * from './server'
15export * from './user-history'
diff --git a/server/middlewares/validators/redundancy.ts b/server/middlewares/validators/redundancy.ts
index c72ab78b2..329322509 100644
--- a/server/middlewares/validators/redundancy.ts
+++ b/server/middlewares/validators/redundancy.ts
@@ -13,7 +13,7 @@ import { ActorFollowModel } from '../../models/activitypub/actor-follow'
13import { SERVER_ACTOR_NAME } from '../../initializers' 13import { SERVER_ACTOR_NAME } from '../../initializers'
14import { ServerModel } from '../../models/server/server' 14import { ServerModel } from '../../models/server/server'
15 15
16const videoRedundancyGetValidator = [ 16const videoFileRedundancyGetValidator = [
17 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 17 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
18 param('resolution') 18 param('resolution')
19 .customSanitizer(toIntOrNull) 19 .customSanitizer(toIntOrNull)
@@ -24,7 +24,7 @@ const videoRedundancyGetValidator = [
24 .custom(exists).withMessage('Should have a valid fps'), 24 .custom(exists).withMessage('Should have a valid fps'),
25 25
26 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 26 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
27 logger.debug('Checking videoRedundancyGetValidator parameters', { parameters: req.params }) 27 logger.debug('Checking videoFileRedundancyGetValidator parameters', { parameters: req.params })
28 28
29 if (areValidationErrors(req, res)) return 29 if (areValidationErrors(req, res)) return
30 if (!await isVideoExist(req.params.videoId, res)) return 30 if (!await isVideoExist(req.params.videoId, res)) return
@@ -38,7 +38,31 @@ const videoRedundancyGetValidator = [
38 res.locals.videoFile = videoFile 38 res.locals.videoFile = videoFile
39 39
40 const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id) 40 const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id)
41 if (!videoRedundancy)return res.status(404).json({ error: 'Video redundancy not found.' }) 41 if (!videoRedundancy) return res.status(404).json({ error: 'Video redundancy not found.' })
42 res.locals.videoRedundancy = videoRedundancy
43
44 return next()
45 }
46]
47
48const videoPlaylistRedundancyGetValidator = [
49 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
50 param('streamingPlaylistType').custom(exists).withMessage('Should have a valid streaming playlist type'),
51
52 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
53 logger.debug('Checking videoPlaylistRedundancyGetValidator parameters', { parameters: req.params })
54
55 if (areValidationErrors(req, res)) return
56 if (!await isVideoExist(req.params.videoId, res)) return
57
58 const video: VideoModel = res.locals.video
59 const videoStreamingPlaylist = video.VideoStreamingPlaylists.find(p => p === req.params.streamingPlaylistType)
60
61 if (!videoStreamingPlaylist) return res.status(404).json({ error: 'Video playlist not found.' })
62 res.locals.videoStreamingPlaylist = videoStreamingPlaylist
63
64 const videoRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(videoStreamingPlaylist.id)
65 if (!videoRedundancy) return res.status(404).json({ error: 'Video redundancy not found.' })
42 res.locals.videoRedundancy = videoRedundancy 66 res.locals.videoRedundancy = videoRedundancy
43 67
44 return next() 68 return next()
@@ -75,6 +99,7 @@ const updateServerRedundancyValidator = [
75// --------------------------------------------------------------------------- 99// ---------------------------------------------------------------------------
76 100
77export { 101export {
78 videoRedundancyGetValidator, 102 videoFileRedundancyGetValidator,
103 videoPlaylistRedundancyGetValidator,
79 updateServerRedundancyValidator 104 updateServerRedundancyValidator
80} 105}
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index 8baf643a5..6a95d6095 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -2,8 +2,7 @@ import * as express from 'express'
2import { areValidationErrors } from './utils' 2import { areValidationErrors } from './utils'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import { query } from 'express-validator/check' 4import { query } from 'express-validator/check'
5import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' 5import { isDateValid } from '../../helpers/custom-validators/misc'
6import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
7 6
8const videosSearchValidator = [ 7const videosSearchValidator = [
9 query('search').optional().not().isEmpty().withMessage('Should have a valid search'), 8 query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
@@ -35,44 +34,9 @@ const videoChannelsSearchValidator = [
35 } 34 }
36] 35]
37 36
38const commonVideosFiltersValidator = [
39 query('categoryOneOf')
40 .optional()
41 .customSanitizer(toArray)
42 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
43 query('licenceOneOf')
44 .optional()
45 .customSanitizer(toArray)
46 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
47 query('languageOneOf')
48 .optional()
49 .customSanitizer(toArray)
50 .custom(isStringArray).withMessage('Should have a valid one of language array'),
51 query('tagsOneOf')
52 .optional()
53 .customSanitizer(toArray)
54 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
55 query('tagsAllOf')
56 .optional()
57 .customSanitizer(toArray)
58 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
59 query('nsfw')
60 .optional()
61 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
62
63 (req: express.Request, res: express.Response, next: express.NextFunction) => {
64 logger.debug('Checking commons video filters query', { parameters: req.query })
65
66 if (areValidationErrors(req, res)) return
67
68 return next()
69 }
70]
71
72// --------------------------------------------------------------------------- 37// ---------------------------------------------------------------------------
73 38
74export { 39export {
75 commonVideosFiltersValidator,
76 videoChannelsSearchValidator, 40 videoChannelsSearchValidator,
77 videosSearchValidator 41 videosSearchValidator
78} 42}
diff --git a/server/middlewares/validators/server.ts b/server/middlewares/validators/server.ts
new file mode 100644
index 000000000..d85afc2ff
--- /dev/null
+++ b/server/middlewares/validators/server.ts
@@ -0,0 +1,78 @@
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/check'
7import { isUserDisplayNameValid } from '../../helpers/custom-validators/users'
8import { Emailer } from '../../lib/emailer'
9import { Redis } from '../../lib/redis'
10import { CONFIG } from '../../initializers/constants'
11
12const serverGetValidator = [
13 body('host').custom(isHostValid).withMessage('Should have a valid host'),
14
15 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
16 logger.debug('Checking serverGetValidator parameters', { parameters: req.body })
17
18 if (areValidationErrors(req, res)) return
19
20 const server = await ServerModel.loadByHost(req.body.host)
21 if (!server) {
22 return res.status(404)
23 .send({ error: 'Server host not found.' })
24 .end()
25 }
26
27 res.locals.server = server
28
29 return next()
30 }
31]
32
33const contactAdministratorValidator = [
34 body('fromName')
35 .custom(isUserDisplayNameValid).withMessage('Should have a valid name'),
36 body('fromEmail')
37 .isEmail().withMessage('Should have a valid email'),
38 body('body')
39 .custom(isValidContactBody).withMessage('Should have a valid body'),
40
41 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 logger.debug('Checking contactAdministratorValidator parameters', { parameters: req.body })
43
44 if (areValidationErrors(req, res)) return
45
46 if (CONFIG.CONTACT_FORM.ENABLED === false) {
47 return res
48 .status(409)
49 .send({ error: 'Contact form is not enabled on this instance.' })
50 .end()
51 }
52
53 if (Emailer.isEnabled() === false) {
54 return res
55 .status(409)
56 .send({ error: 'Emailer is not enabled on this instance.' })
57 .end()
58 }
59
60 if (await Redis.Instance.isContactFormIpExists(req.ip)) {
61 logger.info('Refusing a contact form by %s: already sent one recently.', req.ip)
62
63 return res
64 .status(403)
65 .send({ error: 'You already sent a contact form recently.' })
66 .end()
67 }
68
69 return next()
70 }
71]
72
73// ---------------------------------------------------------------------------
74
75export {
76 serverGetValidator,
77 contactAdministratorValidator
78}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 08dcc2680..5ceda845f 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -16,6 +16,9 @@ const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.V
16const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS) 16const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS)
17const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING) 17const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING)
18const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS) 18const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
19const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
20const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
21const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
19 22
20const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) 23const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
21const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) 24const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -31,6 +34,9 @@ const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
31const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS) 34const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS)
32const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS) 35const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS)
33const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS) 36const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS)
37const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
38const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
39const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
34 40
35// --------------------------------------------------------------------------- 41// ---------------------------------------------------------------------------
36 42
@@ -48,5 +54,8 @@ export {
48 jobsSortValidator, 54 jobsSortValidator,
49 videoCommentThreadsSortValidator, 55 videoCommentThreadsSortValidator,
50 userSubscriptionsSortValidator, 56 userSubscriptionsSortValidator,
51 videoChannelsSearchSortValidator 57 videoChannelsSearchSortValidator,
58 accountsBlocklistSortValidator,
59 serversBlocklistSortValidator,
60 userNotificationsSortValidator
52} 61}
diff --git a/server/middlewares/validators/user-history.ts b/server/middlewares/validators/user-history.ts
new file mode 100644
index 000000000..418313d09
--- /dev/null
+++ b/server/middlewares/validators/user-history.ts
@@ -0,0 +1,26 @@
1import * as express from 'express'
2import 'express-validator'
3import { body } from 'express-validator/check'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils'
6import { isDateValid } from '../../helpers/custom-validators/misc'
7
8const userHistoryRemoveValidator = [
9 body('beforeDate')
10 .optional()
11 .custom(isDateValid).withMessage('Should have a valid before date'),
12
13 (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 logger.debug('Checking userHistoryRemoveValidator parameters', { parameters: req.body })
15
16 if (areValidationErrors(req, res)) return
17
18 return next()
19 }
20]
21
22// ---------------------------------------------------------------------------
23
24export {
25 userHistoryRemoveValidator
26}
diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts
new file mode 100644
index 000000000..46486e081
--- /dev/null
+++ b/server/middlewares/validators/user-notifications.ts
@@ -0,0 +1,63 @@
1import * as express from 'express'
2import 'express-validator'
3import { body, query } from 'express-validator/check'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils'
6import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
7import { isNotEmptyIntArray } from '../../helpers/custom-validators/misc'
8
9const listUserNotificationsValidator = [
10 query('unread')
11 .optional()
12 .toBoolean()
13 .isBoolean().withMessage('Should have a valid unread boolean'),
14
15 (req: express.Request, res: express.Response, next: express.NextFunction) => {
16 logger.debug('Checking listUserNotificationsValidator parameters', { parameters: req.query })
17
18 if (areValidationErrors(req, res)) return
19
20 return next()
21 }
22]
23
24const updateNotificationSettingsValidator = [
25 body('newVideoFromSubscription')
26 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'),
27 body('newCommentOnMyVideo')
28 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'),
29 body('videoAbuseAsModerator')
30 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video abuse as moderator notification setting'),
31 body('blacklistOnMyVideo')
32 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new blacklist on my video notification setting'),
33
34 (req: express.Request, res: express.Response, next: express.NextFunction) => {
35 logger.debug('Checking updateNotificationSettingsValidator parameters', { parameters: req.body })
36
37 if (areValidationErrors(req, res)) return
38
39 return next()
40 }
41]
42
43const markAsReadUserNotificationsValidator = [
44 body('ids')
45 .optional()
46 .custom(isNotEmptyIntArray).withMessage('Should have a valid notification ids to mark as read'),
47
48 (req: express.Request, res: express.Response, next: express.NextFunction) => {
49 logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })
50
51 if (areValidationErrors(req, res)) return
52
53 return next()
54 }
55]
56
57// ---------------------------------------------------------------------------
58
59export {
60 listUserNotificationsValidator,
61 updateNotificationSettingsValidator,
62 markAsReadUserNotificationsValidator
63}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 61297120a..a52e3060a 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -5,15 +5,16 @@ import { body, param } from 'express-validator/check'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 6import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
7import { 7import {
8 isUserAutoPlayVideoValid, isUserBlockedReasonValid, 8 isUserAutoPlayVideoValid,
9 isUserBlockedReasonValid,
9 isUserDescriptionValid, 10 isUserDescriptionValid,
10 isUserDisplayNameValid, 11 isUserDisplayNameValid,
11 isUserNSFWPolicyValid, 12 isUserNSFWPolicyValid,
12 isUserPasswordValid, 13 isUserPasswordValid,
13 isUserRoleValid, 14 isUserRoleValid,
14 isUserUsernameValid, 15 isUserUsernameValid,
15 isUserVideoQuotaValid, 16 isUserVideoQuotaDailyValid,
16 isUserVideoQuotaDailyValid 17 isUserVideoQuotaValid, isUserVideosHistoryEnabledValid
17} from '../../helpers/custom-validators/users' 18} from '../../helpers/custom-validators/users'
18import { isVideoExist } from '../../helpers/custom-validators/videos' 19import { isVideoExist } from '../../helpers/custom-validators/videos'
19import { logger } from '../../helpers/logger' 20import { logger } from '../../helpers/logger'
@@ -22,7 +23,6 @@ import { Redis } from '../../lib/redis'
22import { UserModel } from '../../models/account/user' 23import { UserModel } from '../../models/account/user'
23import { areValidationErrors } from './utils' 24import { areValidationErrors } from './utils'
24import { ActorModel } from '../../models/activitypub/actor' 25import { ActorModel } from '../../models/activitypub/actor'
25import { comparePassword } from '../../helpers/peertube-crypto'
26 26
27const usersAddValidator = [ 27const usersAddValidator = [
28 body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), 28 body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
@@ -113,7 +113,9 @@ const deleteMeValidator = [
113 113
114const usersUpdateValidator = [ 114const usersUpdateValidator = [
115 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), 115 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
116 body('password').optional().custom(isUserPasswordValid).withMessage('Should have a valid password'),
116 body('email').optional().isEmail().withMessage('Should have a valid email attribute'), 117 body('email').optional().isEmail().withMessage('Should have a valid email attribute'),
118 body('emailVerified').optional().isBoolean().withMessage('Should have a valid email verified attribute'),
117 body('videoQuota').optional().custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'), 119 body('videoQuota').optional().custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'),
118 body('videoQuotaDaily').optional().custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily user quota'), 120 body('videoQuotaDaily').optional().custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily user quota'),
119 body('role').optional().custom(isUserRoleValid).withMessage('Should have a valid role'), 121 body('role').optional().custom(isUserRoleValid).withMessage('Should have a valid role'),
@@ -143,6 +145,9 @@ const usersUpdateMeValidator = [
143 body('email').optional().isEmail().withMessage('Should have a valid email attribute'), 145 body('email').optional().isEmail().withMessage('Should have a valid email attribute'),
144 body('nsfwPolicy').optional().custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'), 146 body('nsfwPolicy').optional().custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'),
145 body('autoPlayVideo').optional().custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'), 147 body('autoPlayVideo').optional().custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
148 body('videosHistoryEnabled')
149 .optional()
150 .custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
146 151
147 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 152 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
148 logger.debug('Checking usersUpdateMe parameters', { parameters: omit(req.body, 'password') }) 153 logger.debug('Checking usersUpdateMe parameters', { parameters: omit(req.body, 'password') })
@@ -229,6 +234,7 @@ const usersAskResetPasswordValidator = [
229 logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body }) 234 logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body })
230 235
231 if (areValidationErrors(req, res)) return 236 if (areValidationErrors(req, res)) return
237
232 const exists = await checkUserEmailExist(req.body.email, res, false) 238 const exists = await checkUserEmailExist(req.body.email, res, false)
233 if (!exists) { 239 if (!exists) {
234 logger.debug('User with email %s does not exist (asking reset password).', req.body.email) 240 logger.debug('User with email %s does not exist (asking reset password).', req.body.email)
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index 294783d85..a0d585b93 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -5,4 +5,6 @@ export * from './video-channels'
5export * from './video-comments' 5export * from './video-comments'
6export * from './video-imports' 6export * from './video-imports'
7export * from './video-watch' 7export * from './video-watch'
8export * from './video-rates'
9export * from './video-shares'
8export * from './videos' 10export * from './videos'
diff --git a/server/middlewares/validators/videos/video-blacklist.ts b/server/middlewares/validators/videos/video-blacklist.ts
index 13da7acff..2688f63ae 100644
--- a/server/middlewares/validators/videos/video-blacklist.ts
+++ b/server/middlewares/validators/videos/video-blacklist.ts
@@ -1,10 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator/check' 2import { body, param } from 'express-validator/check'
3import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' 3import { isBooleanValid, isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
4import { isVideoExist } from '../../../helpers/custom-validators/videos' 4import { isVideoExist } from '../../../helpers/custom-validators/videos'
5import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
6import { areValidationErrors } from '../utils' 6import { areValidationErrors } from '../utils'
7import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist' 7import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist'
8import { VideoModel } from '../../../models/video/video'
8 9
9const videosBlacklistRemoveValidator = [ 10const videosBlacklistRemoveValidator = [
10 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 11 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@@ -22,6 +23,10 @@ const videosBlacklistRemoveValidator = [
22 23
23const videosBlacklistAddValidator = [ 24const videosBlacklistAddValidator = [
24 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 25 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
26 body('unfederate')
27 .optional()
28 .toBoolean()
29 .custom(isBooleanValid).withMessage('Should have a valid unfederate boolean'),
25 body('reason') 30 body('reason')
26 .optional() 31 .optional()
27 .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), 32 .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'),
@@ -32,6 +37,14 @@ const videosBlacklistAddValidator = [
32 if (areValidationErrors(req, res)) return 37 if (areValidationErrors(req, res)) return
33 if (!await isVideoExist(req.params.videoId, res)) return 38 if (!await isVideoExist(req.params.videoId, res)) return
34 39
40 const video: VideoModel = res.locals.video
41 if (req.body.unfederate === true && video.remote === true) {
42 return res
43 .status(409)
44 .send({ error: 'You cannot unfederate a remote video.' })
45 .end()
46 }
47
35 return next() 48 return next()
36 } 49 }
37] 50]
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
new file mode 100644
index 000000000..793354520
--- /dev/null
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -0,0 +1,55 @@
1import * as express from 'express'
2import 'express-validator'
3import { body, param } from 'express-validator/check'
4import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
5import { isVideoExist, isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
6import { logger } from '../../../helpers/logger'
7import { areValidationErrors } from '../utils'
8import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
9import { VideoRateType } from '../../../../shared/models/videos'
10import { isAccountNameValid } from '../../../helpers/custom-validators/accounts'
11
12const videoUpdateRateValidator = [
13 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
14 body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
15
16 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
17 logger.debug('Checking videoRate parameters', { parameters: req.body })
18
19 if (areValidationErrors(req, res)) return
20 if (!await isVideoExist(req.params.id, res)) return
21
22 return next()
23 }
24]
25
26const getAccountVideoRateValidator = function (rateType: VideoRateType) {
27 return [
28 param('name').custom(isAccountNameValid).withMessage('Should have a valid account name'),
29 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
30
31 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
32 logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params })
33
34 if (areValidationErrors(req, res)) return
35
36 const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.name, req.params.videoId)
37 if (!rate) {
38 return res.status(404)
39 .json({ error: 'Video rate not found' })
40 .end()
41 }
42
43 res.locals.accountVideoRate = rate
44
45 return next()
46 }
47 ]
48}
49
50// ---------------------------------------------------------------------------
51
52export {
53 videoUpdateRateValidator,
54 getAccountVideoRateValidator
55}
diff --git a/server/middlewares/validators/videos/video-shares.ts b/server/middlewares/validators/videos/video-shares.ts
new file mode 100644
index 000000000..646d7acb1
--- /dev/null
+++ b/server/middlewares/validators/videos/video-shares.ts
@@ -0,0 +1,38 @@
1import * as express from 'express'
2import 'express-validator'
3import { param } from 'express-validator/check'
4import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
5import { isVideoExist } from '../../../helpers/custom-validators/videos'
6import { logger } from '../../../helpers/logger'
7import { VideoShareModel } from '../../../models/video/video-share'
8import { areValidationErrors } from '../utils'
9import { VideoModel } from '../../../models/video/video'
10
11const videosShareValidator = [
12 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
13 param('actorId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid actor id'),
14
15 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
16 logger.debug('Checking videoShare parameters', { parameters: req.params })
17
18 if (areValidationErrors(req, res)) return
19 if (!await isVideoExist(req.params.id, res)) return
20
21 const video: VideoModel = res.locals.video
22
23 const share = await VideoShareModel.load(req.params.actorId, video.id)
24 if (!share) {
25 return res.status(404)
26 .end()
27 }
28
29 res.locals.videoShare = share
30 return next()
31 }
32]
33
34// ---------------------------------------------------------------------------
35
36export {
37 videosShareValidator
38}
diff --git a/server/middlewares/validators/videos/video-watch.ts b/server/middlewares/validators/videos/video-watch.ts
index bca64662f..c38ad8a10 100644
--- a/server/middlewares/validators/videos/video-watch.ts
+++ b/server/middlewares/validators/videos/video-watch.ts
@@ -4,6 +4,7 @@ import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
4import { isVideoExist } from '../../../helpers/custom-validators/videos' 4import { isVideoExist } from '../../../helpers/custom-validators/videos'
5import { areValidationErrors } from '../utils' 5import { areValidationErrors } from '../utils'
6import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
7import { UserModel } from '../../../models/account/user'
7 8
8const videoWatchingValidator = [ 9const videoWatchingValidator = [
9 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 10 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
@@ -17,6 +18,12 @@ const videoWatchingValidator = [
17 if (areValidationErrors(req, res)) return 18 if (areValidationErrors(req, res)) return
18 if (!await isVideoExist(req.params.videoId, res, 'id')) return 19 if (!await isVideoExist(req.params.videoId, res, 'id')) return
19 20
21 const user = res.locals.oauth.token.User as UserModel
22 if (user.videosHistoryEnabled === false) {
23 logger.warn('Cannot set videos to watch by user %d: videos history is disabled.', user.id)
24 return res.status(409).end()
25 }
26
20 return next() 27 return next()
21 } 28 }
22] 29]
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 27e8a7449..d9626929c 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -1,6 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import 'express-validator' 2import 'express-validator'
3import { body, param, ValidationChain } from 'express-validator/check' 3import { body, param, query, ValidationChain } from 'express-validator/check'
4import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' 4import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
5import { 5import {
6 isBooleanValid, 6 isBooleanValid,
@@ -8,6 +8,7 @@ import {
8 isIdOrUUIDValid, 8 isIdOrUUIDValid,
9 isIdValid, 9 isIdValid,
10 isUUIDValid, 10 isUUIDValid,
11 toArray,
11 toIntOrNull, 12 toIntOrNull,
12 toValueOrNull 13 toValueOrNull
13} from '../../../helpers/custom-validators/misc' 14} from '../../../helpers/custom-validators/misc'
@@ -19,20 +20,19 @@ import {
19 isVideoDescriptionValid, 20 isVideoDescriptionValid,
20 isVideoExist, 21 isVideoExist,
21 isVideoFile, 22 isVideoFile,
23 isVideoFilterValid,
22 isVideoImage, 24 isVideoImage,
23 isVideoLanguageValid, 25 isVideoLanguageValid,
24 isVideoLicenceValid, 26 isVideoLicenceValid,
25 isVideoNameValid, 27 isVideoNameValid,
26 isVideoPrivacyValid, 28 isVideoPrivacyValid,
27 isVideoRatingTypeValid,
28 isVideoSupportValid, 29 isVideoSupportValid,
29 isVideoTagsValid 30 isVideoTagsValid
30} from '../../../helpers/custom-validators/videos' 31} from '../../../helpers/custom-validators/videos'
31import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' 32import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
32import { logger } from '../../../helpers/logger' 33import { logger } from '../../../helpers/logger'
33import { CONSTRAINTS_FIELDS } from '../../../initializers' 34import { CONFIG, CONSTRAINTS_FIELDS } from '../../../initializers'
34import { VideoShareModel } from '../../../models/video/video-share' 35import { authenticatePromiseIfNeeded } from '../../oauth'
35import { authenticate } from '../../oauth'
36import { areValidationErrors } from '../utils' 36import { areValidationErrors } from '../utils'
37import { cleanUpReqFiles } from '../../../helpers/express-utils' 37import { cleanUpReqFiles } from '../../../helpers/express-utils'
38import { VideoModel } from '../../../models/video/video' 38import { VideoModel } from '../../../models/video/video'
@@ -42,6 +42,8 @@ import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/vid
42import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership' 42import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership'
43import { AccountModel } from '../../../models/account/account' 43import { AccountModel } from '../../../models/account/account'
44import { VideoFetchType } from '../../../helpers/video' 44import { VideoFetchType } from '../../../helpers/video'
45import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
46import { getServerActor } from '../../../helpers/utils'
45 47
46const videosAddValidator = getCommonVideoAttributes().concat([ 48const videosAddValidator = getCommonVideoAttributes().concat([
47 body('videofile') 49 body('videofile')
@@ -69,7 +71,6 @@ const videosAddValidator = getCommonVideoAttributes().concat([
69 if (isAble === false) { 71 if (isAble === false) {
70 res.status(403) 72 res.status(403)
71 .json({ error: 'The user video quota is exceeded with this video.' }) 73 .json({ error: 'The user video quota is exceeded with this video.' })
72 .end()
73 74
74 return cleanUpReqFiles(req) 75 return cleanUpReqFiles(req)
75 } 76 }
@@ -82,7 +83,6 @@ const videosAddValidator = getCommonVideoAttributes().concat([
82 logger.error('Invalid input file in videosAddValidator.', { err }) 83 logger.error('Invalid input file in videosAddValidator.', { err })
83 res.status(400) 84 res.status(400)
84 .json({ error: 'Invalid input file.' }) 85 .json({ error: 'Invalid input file.' })
85 .end()
86 86
87 return cleanUpReqFiles(req) 87 return cleanUpReqFiles(req)
88 } 88 }
@@ -120,7 +120,6 @@ const videosUpdateValidator = getCommonVideoAttributes().concat([
120 cleanUpReqFiles(req) 120 cleanUpReqFiles(req)
121 return res.status(409) 121 return res.status(409)
122 .json({ error: 'Cannot set "private" a video that was not private.' }) 122 .json({ error: 'Cannot set "private" a video that was not private.' })
123 .end()
124 } 123 }
125 124
126 if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) 125 if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
@@ -129,6 +128,31 @@ const videosUpdateValidator = getCommonVideoAttributes().concat([
129 } 128 }
130]) 129])
131 130
131async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
132 const video: VideoModel = res.locals.video
133
134 // Anybody can watch local videos
135 if (video.isOwned() === true) return next()
136
137 // Logged user
138 if (res.locals.oauth) {
139 // Users can search or watch remote videos
140 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
141 }
142
143 // Anybody can search or watch remote videos
144 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
145
146 // Check our instance follows an actor that shared this video
147 const serverActor = await getServerActor()
148 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
149
150 return res.status(403)
151 .json({
152 error: 'Cannot get this video regarding follow constraints.'
153 })
154}
155
132const videosCustomGetValidator = (fetchType: VideoFetchType) => { 156const videosCustomGetValidator = (fetchType: VideoFetchType) => {
133 return [ 157 return [
134 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 158 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
@@ -143,18 +167,20 @@ const videosCustomGetValidator = (fetchType: VideoFetchType) => {
143 167
144 // Video private or blacklisted 168 // Video private or blacklisted
145 if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) { 169 if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
146 return authenticate(req, res, () => { 170 await authenticatePromiseIfNeeded(req, res)
147 const user: UserModel = res.locals.oauth.token.User 171
148 172 const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : null
149 // Only the owner or a user that have blacklist rights can see the video 173
150 if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) { 174 // Only the owner or a user that have blacklist rights can see the video
151 return res.status(403) 175 if (
152 .json({ error: 'Cannot get this private or blacklisted video.' }) 176 !user ||
153 .end() 177 (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST))
154 } 178 ) {
155 179 return res.status(403)
156 return next() 180 .json({ error: 'Cannot get this private or blacklisted video.' })
157 }) 181 }
182
183 return next()
158 } 184 }
159 185
160 // Video is public, anyone can access it 186 // Video is public, anyone can access it
@@ -189,41 +215,6 @@ const videosRemoveValidator = [
189 } 215 }
190] 216]
191 217
192const videoRateValidator = [
193 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
194 body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
195
196 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
197 logger.debug('Checking videoRate parameters', { parameters: req.body })
198
199 if (areValidationErrors(req, res)) return
200 if (!await isVideoExist(req.params.id, res)) return
201
202 return next()
203 }
204]
205
206const videosShareValidator = [
207 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
208 param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'),
209
210 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
211 logger.debug('Checking videoShare parameters', { parameters: req.params })
212
213 if (areValidationErrors(req, res)) return
214 if (!await isVideoExist(req.params.id, res)) return
215
216 const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined)
217 if (!share) {
218 return res.status(404)
219 .end()
220 }
221
222 res.locals.videoShare = share
223 return next()
224 }
225]
226
227const videosChangeOwnershipValidator = [ 218const videosChangeOwnershipValidator = [
228 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 219 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
229 220
@@ -239,8 +230,8 @@ const videosChangeOwnershipValidator = [
239 const nextOwner = await AccountModel.loadLocalByName(req.body.username) 230 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
240 if (!nextOwner) { 231 if (!nextOwner) {
241 res.status(400) 232 res.status(400)
242 .type('json') 233 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
243 .end() 234
244 return 235 return
245 } 236 }
246 res.locals.nextOwner = nextOwner 237 res.locals.nextOwner = nextOwner
@@ -271,7 +262,7 @@ const videosTerminateChangeOwnershipValidator = [
271 } else { 262 } else {
272 res.status(403) 263 res.status(403)
273 .json({ error: 'Ownership already accepted or refused' }) 264 .json({ error: 'Ownership already accepted or refused' })
274 .end() 265
275 return 266 return
276 } 267 }
277 } 268 }
@@ -288,7 +279,7 @@ const videosAcceptChangeOwnershipValidator = [
288 if (isAble === false) { 279 if (isAble === false) {
289 res.status(403) 280 res.status(403)
290 .json({ error: 'The user video quota is exceeded with this video.' }) 281 .json({ error: 'The user video quota is exceeded with this video.' })
291 .end() 282
292 return 283 return
293 } 284 }
294 285
@@ -367,23 +358,68 @@ function getCommonVideoAttributes () {
367 ] as (ValidationChain | express.Handler)[] 358 ] as (ValidationChain | express.Handler)[]
368} 359}
369 360
361const commonVideosFiltersValidator = [
362 query('categoryOneOf')
363 .optional()
364 .customSanitizer(toArray)
365 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
366 query('licenceOneOf')
367 .optional()
368 .customSanitizer(toArray)
369 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
370 query('languageOneOf')
371 .optional()
372 .customSanitizer(toArray)
373 .custom(isStringArray).withMessage('Should have a valid one of language array'),
374 query('tagsOneOf')
375 .optional()
376 .customSanitizer(toArray)
377 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
378 query('tagsAllOf')
379 .optional()
380 .customSanitizer(toArray)
381 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
382 query('nsfw')
383 .optional()
384 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
385 query('filter')
386 .optional()
387 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
388
389 (req: express.Request, res: express.Response, next: express.NextFunction) => {
390 logger.debug('Checking commons video filters query', { parameters: req.query })
391
392 if (areValidationErrors(req, res)) return
393
394 const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
395 if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) {
396 res.status(401)
397 .json({ error: 'You are not allowed to see all local videos.' })
398
399 return
400 }
401
402 return next()
403 }
404]
405
370// --------------------------------------------------------------------------- 406// ---------------------------------------------------------------------------
371 407
372export { 408export {
373 videosAddValidator, 409 videosAddValidator,
374 videosUpdateValidator, 410 videosUpdateValidator,
375 videosGetValidator, 411 videosGetValidator,
412 checkVideoFollowConstraints,
376 videosCustomGetValidator, 413 videosCustomGetValidator,
377 videosRemoveValidator, 414 videosRemoveValidator,
378 videosShareValidator,
379
380 videoRateValidator,
381 415
382 videosChangeOwnershipValidator, 416 videosChangeOwnershipValidator,
383 videosTerminateChangeOwnershipValidator, 417 videosTerminateChangeOwnershipValidator,
384 videosAcceptChangeOwnershipValidator, 418 videosAcceptChangeOwnershipValidator,
385 419
386 getCommonVideoAttributes 420 getCommonVideoAttributes,
421
422 commonVideosFiltersValidator
387} 423}
388 424
389// --------------------------------------------------------------------------- 425// ---------------------------------------------------------------------------
@@ -391,9 +427,10 @@ export {
391function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) { 427function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
392 if (req.body.scheduleUpdate) { 428 if (req.body.scheduleUpdate) {
393 if (!req.body.scheduleUpdate.updateAt) { 429 if (!req.body.scheduleUpdate.updateAt) {
430 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
431
394 res.status(400) 432 res.status(400)
395 .json({ error: 'Schedule update at is mandatory.' }) 433 .json({ error: 'Schedule update at is mandatory.' })
396 .end()
397 434
398 return true 435 return true
399 } 436 }
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
new file mode 100644
index 000000000..efd6ed59e
--- /dev/null
+++ b/server/models/account/account-blocklist.ts
@@ -0,0 +1,142 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from './account'
3import { getSort } from '../utils'
4import { AccountBlock } from '../../../shared/models/blocklist'
5import { Op } from 'sequelize'
6
7enum ScopeNames {
8 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
9}
10
11@Scopes({
12 [ScopeNames.WITH_ACCOUNTS]: {
13 include: [
14 {
15 model: () => AccountModel,
16 required: true,
17 as: 'ByAccount'
18 },
19 {
20 model: () => AccountModel,
21 required: true,
22 as: 'BlockedAccount'
23 }
24 ]
25 }
26})
27
28@Table({
29 tableName: 'accountBlocklist',
30 indexes: [
31 {
32 fields: [ 'accountId', 'targetAccountId' ],
33 unique: true
34 },
35 {
36 fields: [ 'targetAccountId' ]
37 }
38 ]
39})
40export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
41
42 @CreatedAt
43 createdAt: Date
44
45 @UpdatedAt
46 updatedAt: Date
47
48 @ForeignKey(() => AccountModel)
49 @Column
50 accountId: number
51
52 @BelongsTo(() => AccountModel, {
53 foreignKey: {
54 name: 'accountId',
55 allowNull: false
56 },
57 as: 'ByAccount',
58 onDelete: 'CASCADE'
59 })
60 ByAccount: AccountModel
61
62 @ForeignKey(() => AccountModel)
63 @Column
64 targetAccountId: number
65
66 @BelongsTo(() => AccountModel, {
67 foreignKey: {
68 name: 'targetAccountId',
69 allowNull: false
70 },
71 as: 'BlockedAccount',
72 onDelete: 'CASCADE'
73 })
74 BlockedAccount: AccountModel
75
76 static isAccountMutedBy (accountId: number, targetAccountId: number) {
77 return AccountBlocklistModel.isAccountMutedByMulti([ accountId ], targetAccountId)
78 .then(result => result[accountId])
79 }
80
81 static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) {
82 const query = {
83 attributes: [ 'accountId', 'id' ],
84 where: {
85 accountId: {
86 [Op.any]: accountIds
87 },
88 targetAccountId
89 },
90 raw: true
91 }
92
93 return AccountBlocklistModel.unscoped()
94 .findAll(query)
95 .then(rows => {
96 const result: { [accountId: number]: boolean } = {}
97
98 for (const accountId of accountIds) {
99 result[accountId] = !!rows.find(r => r.accountId === accountId)
100 }
101
102 return result
103 })
104 }
105
106 static loadByAccountAndTarget (accountId: number, targetAccountId: number) {
107 const query = {
108 where: {
109 accountId,
110 targetAccountId
111 }
112 }
113
114 return AccountBlocklistModel.findOne(query)
115 }
116
117 static listForApi (accountId: number, start: number, count: number, sort: string) {
118 const query = {
119 offset: start,
120 limit: count,
121 order: getSort(sort),
122 where: {
123 accountId
124 }
125 }
126
127 return AccountBlocklistModel
128 .scope([ ScopeNames.WITH_ACCOUNTS ])
129 .findAndCountAll(query)
130 .then(({ rows, count }) => {
131 return { total: count, data: rows }
132 })
133 }
134
135 toFormattedJSON (): AccountBlock {
136 return {
137 byAccount: this.ByAccount.toFormattedJSON(),
138 blockedAccount: this.BlockedAccount.toFormattedJSON(),
139 createdAt: this.createdAt
140 }
141 }
142}
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index c99e32012..18762f0c5 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -1,12 +1,14 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import { Transaction } from 'sequelize' 2import { Transaction } from 'sequelize'
3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions' 4import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions'
5import { VideoRateType } from '../../../shared/models/videos' 5import { VideoRateType } from '../../../shared/models/videos'
6import { VIDEO_RATE_TYPES } from '../../initializers' 6import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers'
7import { VideoModel } from '../video/video' 7import { VideoModel } from '../video/video'
8import { AccountModel } from './account' 8import { AccountModel } from './account'
9import { ActorModel } from '../activitypub/actor' 9import { ActorModel } from '../activitypub/actor'
10import { throwIfNotValid } from '../utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10 12
11/* 13/*
12 Account rates per video. 14 Account rates per video.
@@ -26,6 +28,10 @@ import { ActorModel } from '../activitypub/actor'
26 }, 28 },
27 { 29 {
28 fields: [ 'videoId', 'type' ] 30 fields: [ 'videoId', 'type' ]
31 },
32 {
33 fields: [ 'url' ],
34 unique: true
29 } 35 }
30 ] 36 ]
31}) 37})
@@ -35,6 +41,11 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
35 @Column(DataType.ENUM(values(VIDEO_RATE_TYPES))) 41 @Column(DataType.ENUM(values(VIDEO_RATE_TYPES)))
36 type: VideoRateType 42 type: VideoRateType
37 43
44 @AllowNull(false)
45 @Is('AccountVideoRateUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
46 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_RATES.URL.max))
47 url: string
48
38 @CreatedAt 49 @CreatedAt
39 createdAt: Date 50 createdAt: Date
40 51
@@ -65,7 +76,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
65 }) 76 })
66 Account: AccountModel 77 Account: AccountModel
67 78
68 static load (accountId: number, videoId: number, transaction: Transaction) { 79 static load (accountId: number, videoId: number, transaction?: Transaction) {
69 const options: IFindOptions<AccountVideoRateModel> = { 80 const options: IFindOptions<AccountVideoRateModel> = {
70 where: { 81 where: {
71 accountId, 82 accountId,
@@ -77,6 +88,49 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
77 return AccountVideoRateModel.findOne(options) 88 return AccountVideoRateModel.findOne(options)
78 } 89 }
79 90
91 static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) {
92 const options: IFindOptions<AccountVideoRateModel> = {
93 where: {
94 videoId,
95 type: rateType
96 },
97 include: [
98 {
99 model: AccountModel.unscoped(),
100 required: true,
101 include: [
102 {
103 attributes: [ 'id', 'url', 'preferredUsername' ],
104 model: ActorModel.unscoped(),
105 required: true,
106 where: {
107 preferredUsername: accountName
108 }
109 }
110 ]
111 },
112 {
113 model: VideoModel.unscoped(),
114 required: true
115 }
116 ]
117 }
118 if (transaction) options.transaction = transaction
119
120 return AccountVideoRateModel.findOne(options)
121 }
122
123 static loadByUrl (url: string, transaction: Transaction) {
124 const options: IFindOptions<AccountVideoRateModel> = {
125 where: {
126 url
127 }
128 }
129 if (transaction) options.transaction = transaction
130
131 return AccountVideoRateModel.findOne(options)
132 }
133
80 static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) { 134 static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) {
81 const query = { 135 const query = {
82 offset: start, 136 offset: start,
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 5a237d733..84ef0b30d 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -241,6 +241,27 @@ export class AccountModel extends Model<AccountModel> {
241 }) 241 })
242 } 242 }
243 243
244 static listLocalsForSitemap (sort: string) {
245 const query = {
246 attributes: [ ],
247 offset: 0,
248 order: getSort(sort),
249 include: [
250 {
251 attributes: [ 'preferredUsername', 'serverId' ],
252 model: ActorModel.unscoped(),
253 where: {
254 serverId: null
255 }
256 }
257 ]
258 }
259
260 return AccountModel
261 .unscoped()
262 .findAll(query)
263 }
264
244 toFormattedJSON (): Account { 265 toFormattedJSON (): Account {
245 const actor = this.Actor.toFormattedJSON() 266 const actor = this.Actor.toFormattedJSON()
246 const account = { 267 const account = {
@@ -267,6 +288,10 @@ export class AccountModel extends Model<AccountModel> {
267 return this.Actor.isOwned() 288 return this.Actor.isOwned()
268 } 289 }
269 290
291 isOutdated () {
292 return this.Actor.isOutdated()
293 }
294
270 getDisplayName () { 295 getDisplayName () {
271 return this.name 296 return this.name
272 } 297 }
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
new file mode 100644
index 000000000..f1c3ac223
--- /dev/null
+++ b/server/models/account/user-notification-setting.ts
@@ -0,0 +1,150 @@
1import {
2 AfterDestroy,
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils'
16import { UserModel } from './user'
17import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { clearCacheByUserId } from '../../lib/oauth-model'
20
21@Table({
22 tableName: 'userNotificationSetting',
23 indexes: [
24 {
25 fields: [ 'userId' ],
26 unique: true
27 }
28 ]
29})
30export class UserNotificationSettingModel extends Model<UserNotificationSettingModel> {
31
32 @AllowNull(false)
33 @Default(null)
34 @Is(
35 'UserNotificationSettingNewVideoFromSubscription',
36 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
37 )
38 @Column
39 newVideoFromSubscription: UserNotificationSettingValue
40
41 @AllowNull(false)
42 @Default(null)
43 @Is(
44 'UserNotificationSettingNewCommentOnMyVideo',
45 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
46 )
47 @Column
48 newCommentOnMyVideo: UserNotificationSettingValue
49
50 @AllowNull(false)
51 @Default(null)
52 @Is(
53 'UserNotificationSettingVideoAbuseAsModerator',
54 value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator')
55 )
56 @Column
57 videoAbuseAsModerator: UserNotificationSettingValue
58
59 @AllowNull(false)
60 @Default(null)
61 @Is(
62 'UserNotificationSettingBlacklistOnMyVideo',
63 value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
64 )
65 @Column
66 blacklistOnMyVideo: UserNotificationSettingValue
67
68 @AllowNull(false)
69 @Default(null)
70 @Is(
71 'UserNotificationSettingMyVideoPublished',
72 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoPublished')
73 )
74 @Column
75 myVideoPublished: UserNotificationSettingValue
76
77 @AllowNull(false)
78 @Default(null)
79 @Is(
80 'UserNotificationSettingMyVideoImportFinished',
81 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoImportFinished')
82 )
83 @Column
84 myVideoImportFinished: UserNotificationSettingValue
85
86 @AllowNull(false)
87 @Default(null)
88 @Is(
89 'UserNotificationSettingNewUserRegistration',
90 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
91 )
92 @Column
93 newUserRegistration: UserNotificationSettingValue
94
95 @AllowNull(false)
96 @Default(null)
97 @Is(
98 'UserNotificationSettingNewFollow',
99 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
100 )
101 @Column
102 newFollow: UserNotificationSettingValue
103
104 @AllowNull(false)
105 @Default(null)
106 @Is(
107 'UserNotificationSettingCommentMention',
108 value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
109 )
110 @Column
111 commentMention: UserNotificationSettingValue
112
113 @ForeignKey(() => UserModel)
114 @Column
115 userId: number
116
117 @BelongsTo(() => UserModel, {
118 foreignKey: {
119 allowNull: false
120 },
121 onDelete: 'cascade'
122 })
123 User: UserModel
124
125 @CreatedAt
126 createdAt: Date
127
128 @UpdatedAt
129 updatedAt: Date
130
131 @AfterUpdate
132 @AfterDestroy
133 static removeTokenCache (instance: UserNotificationSettingModel) {
134 return clearCacheByUserId(instance.userId)
135 }
136
137 toFormattedJSON (): UserNotificationSetting {
138 return {
139 newCommentOnMyVideo: this.newCommentOnMyVideo,
140 newVideoFromSubscription: this.newVideoFromSubscription,
141 videoAbuseAsModerator: this.videoAbuseAsModerator,
142 blacklistOnMyVideo: this.blacklistOnMyVideo,
143 myVideoPublished: this.myVideoPublished,
144 myVideoImportFinished: this.myVideoImportFinished,
145 newUserRegistration: this.newUserRegistration,
146 commentMention: this.commentMention,
147 newFollow: this.newFollow
148 }
149 }
150}
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
new file mode 100644
index 000000000..6cdbb827b
--- /dev/null
+++ b/server/models/account/user-notification.ts
@@ -0,0 +1,472 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 Default,
7 ForeignKey,
8 IFindOptions,
9 Is,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { UserNotification, UserNotificationType } from '../../../shared'
16import { getSort, throwIfNotValid } from '../utils'
17import { isBooleanValid } from '../../helpers/custom-validators/misc'
18import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
19import { UserModel } from './user'
20import { VideoModel } from '../video/video'
21import { VideoCommentModel } from '../video/video-comment'
22import { Op } from 'sequelize'
23import { VideoChannelModel } from '../video/video-channel'
24import { AccountModel } from './account'
25import { VideoAbuseModel } from '../video/video-abuse'
26import { VideoBlacklistModel } from '../video/video-blacklist'
27import { VideoImportModel } from '../video/video-import'
28import { ActorModel } from '../activitypub/actor'
29import { ActorFollowModel } from '../activitypub/actor-follow'
30import { AvatarModel } from '../avatar/avatar'
31import { ServerModel } from '../server/server'
32
33enum ScopeNames {
34 WITH_ALL = 'WITH_ALL'
35}
36
37function buildActorWithAvatarInclude () {
38 return {
39 attributes: [ 'preferredUsername' ],
40 model: () => ActorModel.unscoped(),
41 required: true,
42 include: [
43 {
44 attributes: [ 'filename' ],
45 model: () => AvatarModel.unscoped(),
46 required: false
47 },
48 {
49 attributes: [ 'host' ],
50 model: () => ServerModel.unscoped(),
51 required: false
52 }
53 ]
54 }
55}
56
57function buildVideoInclude (required: boolean) {
58 return {
59 attributes: [ 'id', 'uuid', 'name' ],
60 model: () => VideoModel.unscoped(),
61 required
62 }
63}
64
65function buildChannelInclude (required: boolean, withActor = false) {
66 return {
67 required,
68 attributes: [ 'id', 'name' ],
69 model: () => VideoChannelModel.unscoped(),
70 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
71 }
72}
73
74function buildAccountInclude (required: boolean, withActor = false) {
75 return {
76 required,
77 attributes: [ 'id', 'name' ],
78 model: () => AccountModel.unscoped(),
79 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
80 }
81}
82
83@Scopes({
84 [ScopeNames.WITH_ALL]: {
85 include: [
86 Object.assign(buildVideoInclude(false), {
87 include: [ buildChannelInclude(true, true) ]
88 }),
89
90 {
91 attributes: [ 'id', 'originCommentId' ],
92 model: () => VideoCommentModel.unscoped(),
93 required: false,
94 include: [
95 buildAccountInclude(true, true),
96 buildVideoInclude(true)
97 ]
98 },
99
100 {
101 attributes: [ 'id' ],
102 model: () => VideoAbuseModel.unscoped(),
103 required: false,
104 include: [ buildVideoInclude(true) ]
105 },
106
107 {
108 attributes: [ 'id' ],
109 model: () => VideoBlacklistModel.unscoped(),
110 required: false,
111 include: [ buildVideoInclude(true) ]
112 },
113
114 {
115 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
116 model: () => VideoImportModel.unscoped(),
117 required: false,
118 include: [ buildVideoInclude(false) ]
119 },
120
121 {
122 attributes: [ 'id' ],
123 model: () => ActorFollowModel.unscoped(),
124 required: false,
125 include: [
126 {
127 attributes: [ 'preferredUsername' ],
128 model: () => ActorModel.unscoped(),
129 required: true,
130 as: 'ActorFollower',
131 include: [
132 {
133 attributes: [ 'id', 'name' ],
134 model: () => AccountModel.unscoped(),
135 required: true
136 },
137 {
138 attributes: [ 'filename' ],
139 model: () => AvatarModel.unscoped(),
140 required: false
141 },
142 {
143 attributes: [ 'host' ],
144 model: () => ServerModel.unscoped(),
145 required: false
146 }
147 ]
148 },
149 {
150 attributes: [ 'preferredUsername' ],
151 model: () => ActorModel.unscoped(),
152 required: true,
153 as: 'ActorFollowing',
154 include: [
155 buildChannelInclude(false),
156 buildAccountInclude(false)
157 ]
158 }
159 ]
160 },
161
162 buildAccountInclude(false, true)
163 ]
164 }
165})
166@Table({
167 tableName: 'userNotification',
168 indexes: [
169 {
170 fields: [ 'userId' ]
171 },
172 {
173 fields: [ 'videoId' ],
174 where: {
175 videoId: {
176 [Op.ne]: null
177 }
178 }
179 },
180 {
181 fields: [ 'commentId' ],
182 where: {
183 commentId: {
184 [Op.ne]: null
185 }
186 }
187 },
188 {
189 fields: [ 'videoAbuseId' ],
190 where: {
191 videoAbuseId: {
192 [Op.ne]: null
193 }
194 }
195 },
196 {
197 fields: [ 'videoBlacklistId' ],
198 where: {
199 videoBlacklistId: {
200 [Op.ne]: null
201 }
202 }
203 },
204 {
205 fields: [ 'videoImportId' ],
206 where: {
207 videoImportId: {
208 [Op.ne]: null
209 }
210 }
211 },
212 {
213 fields: [ 'accountId' ],
214 where: {
215 accountId: {
216 [Op.ne]: null
217 }
218 }
219 },
220 {
221 fields: [ 'actorFollowId' ],
222 where: {
223 actorFollowId: {
224 [Op.ne]: null
225 }
226 }
227 }
228 ]
229})
230export class UserNotificationModel extends Model<UserNotificationModel> {
231
232 @AllowNull(false)
233 @Default(null)
234 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
235 @Column
236 type: UserNotificationType
237
238 @AllowNull(false)
239 @Default(false)
240 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
241 @Column
242 read: boolean
243
244 @CreatedAt
245 createdAt: Date
246
247 @UpdatedAt
248 updatedAt: Date
249
250 @ForeignKey(() => UserModel)
251 @Column
252 userId: number
253
254 @BelongsTo(() => UserModel, {
255 foreignKey: {
256 allowNull: false
257 },
258 onDelete: 'cascade'
259 })
260 User: UserModel
261
262 @ForeignKey(() => VideoModel)
263 @Column
264 videoId: number
265
266 @BelongsTo(() => VideoModel, {
267 foreignKey: {
268 allowNull: true
269 },
270 onDelete: 'cascade'
271 })
272 Video: VideoModel
273
274 @ForeignKey(() => VideoCommentModel)
275 @Column
276 commentId: number
277
278 @BelongsTo(() => VideoCommentModel, {
279 foreignKey: {
280 allowNull: true
281 },
282 onDelete: 'cascade'
283 })
284 Comment: VideoCommentModel
285
286 @ForeignKey(() => VideoAbuseModel)
287 @Column
288 videoAbuseId: number
289
290 @BelongsTo(() => VideoAbuseModel, {
291 foreignKey: {
292 allowNull: true
293 },
294 onDelete: 'cascade'
295 })
296 VideoAbuse: VideoAbuseModel
297
298 @ForeignKey(() => VideoBlacklistModel)
299 @Column
300 videoBlacklistId: number
301
302 @BelongsTo(() => VideoBlacklistModel, {
303 foreignKey: {
304 allowNull: true
305 },
306 onDelete: 'cascade'
307 })
308 VideoBlacklist: VideoBlacklistModel
309
310 @ForeignKey(() => VideoImportModel)
311 @Column
312 videoImportId: number
313
314 @BelongsTo(() => VideoImportModel, {
315 foreignKey: {
316 allowNull: true
317 },
318 onDelete: 'cascade'
319 })
320 VideoImport: VideoImportModel
321
322 @ForeignKey(() => AccountModel)
323 @Column
324 accountId: number
325
326 @BelongsTo(() => AccountModel, {
327 foreignKey: {
328 allowNull: true
329 },
330 onDelete: 'cascade'
331 })
332 Account: AccountModel
333
334 @ForeignKey(() => ActorFollowModel)
335 @Column
336 actorFollowId: number
337
338 @BelongsTo(() => ActorFollowModel, {
339 foreignKey: {
340 allowNull: true
341 },
342 onDelete: 'cascade'
343 })
344 ActorFollow: ActorFollowModel
345
346 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
347 const query: IFindOptions<UserNotificationModel> = {
348 offset: start,
349 limit: count,
350 order: getSort(sort),
351 where: {
352 userId
353 }
354 }
355
356 if (unread !== undefined) query.where['read'] = !unread
357
358 return UserNotificationModel.scope(ScopeNames.WITH_ALL)
359 .findAndCountAll(query)
360 .then(({ rows, count }) => {
361 return {
362 data: rows,
363 total: count
364 }
365 })
366 }
367
368 static markAsRead (userId: number, notificationIds: number[]) {
369 const query = {
370 where: {
371 userId,
372 id: {
373 [Op.any]: notificationIds
374 }
375 }
376 }
377
378 return UserNotificationModel.update({ read: true }, query)
379 }
380
381 static markAllAsRead (userId: number) {
382 const query = { where: { userId } }
383
384 return UserNotificationModel.update({ read: true }, query)
385 }
386
387 toFormattedJSON (): UserNotification {
388 const video = this.Video
389 ? Object.assign(this.formatVideo(this.Video),{ channel: this.formatActor(this.Video.VideoChannel) })
390 : undefined
391
392 const videoImport = this.VideoImport ? {
393 id: this.VideoImport.id,
394 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
395 torrentName: this.VideoImport.torrentName,
396 magnetUri: this.VideoImport.magnetUri,
397 targetUrl: this.VideoImport.targetUrl
398 } : undefined
399
400 const comment = this.Comment ? {
401 id: this.Comment.id,
402 threadId: this.Comment.getThreadId(),
403 account: this.formatActor(this.Comment.Account),
404 video: this.formatVideo(this.Comment.Video)
405 } : undefined
406
407 const videoAbuse = this.VideoAbuse ? {
408 id: this.VideoAbuse.id,
409 video: this.formatVideo(this.VideoAbuse.Video)
410 } : undefined
411
412 const videoBlacklist = this.VideoBlacklist ? {
413 id: this.VideoBlacklist.id,
414 video: this.formatVideo(this.VideoBlacklist.Video)
415 } : undefined
416
417 const account = this.Account ? this.formatActor(this.Account) : undefined
418
419 const actorFollow = this.ActorFollow ? {
420 id: this.ActorFollow.id,
421 follower: {
422 id: this.ActorFollow.ActorFollower.Account.id,
423 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
424 name: this.ActorFollow.ActorFollower.preferredUsername,
425 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getWebserverPath() } : undefined,
426 host: this.ActorFollow.ActorFollower.getHost()
427 },
428 following: {
429 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
430 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
431 name: this.ActorFollow.ActorFollowing.preferredUsername
432 }
433 } : undefined
434
435 return {
436 id: this.id,
437 type: this.type,
438 read: this.read,
439 video,
440 videoImport,
441 comment,
442 videoAbuse,
443 videoBlacklist,
444 account,
445 actorFollow,
446 createdAt: this.createdAt.toISOString(),
447 updatedAt: this.updatedAt.toISOString()
448 }
449 }
450
451 private formatVideo (video: VideoModel) {
452 return {
453 id: video.id,
454 uuid: video.uuid,
455 name: video.name
456 }
457 }
458
459 private formatActor (accountOrChannel: AccountModel | VideoChannelModel) {
460 const avatar = accountOrChannel.Actor.Avatar
461 ? { path: accountOrChannel.Actor.Avatar.getWebserverPath() }
462 : undefined
463
464 return {
465 id: accountOrChannel.id,
466 displayName: accountOrChannel.getDisplayName(),
467 name: accountOrChannel.Actor.preferredUsername,
468 host: accountOrChannel.Actor.getHost(),
469 avatar
470 }
471 }
472}
diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts
index 0476cad9d..15cb399c9 100644
--- a/server/models/account/user-video-history.ts
+++ b/server/models/account/user-video-history.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from '../video/video' 2import { VideoModel } from '../video/video'
3import { UserModel } from './user' 3import { UserModel } from './user'
4import { Transaction, Op, DestroyOptions } from 'sequelize'
4 5
5@Table({ 6@Table({
6 tableName: 'userVideoHistory', 7 tableName: 'userVideoHistory',
@@ -52,4 +53,34 @@ export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
52 onDelete: 'CASCADE' 53 onDelete: 'CASCADE'
53 }) 54 })
54 User: UserModel 55 User: UserModel
56
57 static listForApi (user: UserModel, start: number, count: number) {
58 return VideoModel.listForApi({
59 start,
60 count,
61 sort: '-UserVideoHistories.updatedAt',
62 nsfw: null, // All
63 includeLocalVideos: true,
64 withFiles: false,
65 user,
66 historyOfUser: user
67 })
68 }
69
70 static removeHistoryBefore (user: UserModel, beforeDate: string, t: Transaction) {
71 const query: DestroyOptions = {
72 where: {
73 userId: user.id
74 },
75 transaction: t
76 }
77
78 if (beforeDate) {
79 query.where.updatedAt = {
80 [Op.lt]: beforeDate
81 }
82 }
83
84 return UserVideoHistoryModel.destroy(query)
85 }
55} 86}
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index e56b0bf40..017a96657 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -1,6 +1,6 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { 2import {
3 AfterDelete, 3 AfterDestroy,
4 AfterUpdate, 4 AfterUpdate,
5 AllowNull, 5 AllowNull,
6 BeforeCreate, 6 BeforeCreate,
@@ -31,7 +31,9 @@ import {
31 isUserRoleValid, 31 isUserRoleValid,
32 isUserUsernameValid, 32 isUserUsernameValid,
33 isUserVideoQuotaDailyValid, 33 isUserVideoQuotaDailyValid,
34 isUserVideoQuotaValid 34 isUserVideoQuotaValid,
35 isUserVideosHistoryEnabledValid,
36 isUserWebTorrentEnabledValid
35} from '../../helpers/custom-validators/users' 37} from '../../helpers/custom-validators/users'
36import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' 38import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
37import { OAuthTokenModel } from '../oauth/oauth-token' 39import { OAuthTokenModel } from '../oauth/oauth-token'
@@ -42,6 +44,11 @@ import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
42import { values } from 'lodash' 44import { values } from 'lodash'
43import { NSFW_POLICY_TYPES } from '../../initializers' 45import { NSFW_POLICY_TYPES } from '../../initializers'
44import { clearCacheByUserId } from '../../lib/oauth-model' 46import { clearCacheByUserId } from '../../lib/oauth-model'
47import { UserNotificationSettingModel } from './user-notification-setting'
48import { VideoModel } from '../video/video'
49import { ActorModel } from '../activitypub/actor'
50import { ActorFollowModel } from '../activitypub/actor-follow'
51import { VideoImportModel } from '../video/video-import'
45 52
46enum ScopeNames { 53enum ScopeNames {
47 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' 54 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@@ -52,6 +59,10 @@ enum ScopeNames {
52 { 59 {
53 model: () => AccountModel, 60 model: () => AccountModel,
54 required: true 61 required: true
62 },
63 {
64 model: () => UserNotificationSettingModel,
65 required: true
55 } 66 }
56 ] 67 ]
57}) 68})
@@ -62,6 +73,10 @@ enum ScopeNames {
62 model: () => AccountModel, 73 model: () => AccountModel,
63 required: true, 74 required: true,
64 include: [ () => VideoChannelModel ] 75 include: [ () => VideoChannelModel ]
76 },
77 {
78 model: () => UserNotificationSettingModel,
79 required: true
65 } 80 }
66 ] 81 ]
67 } 82 }
@@ -109,6 +124,18 @@ export class UserModel extends Model<UserModel> {
109 124
110 @AllowNull(false) 125 @AllowNull(false)
111 @Default(true) 126 @Default(true)
127 @Is('UserWebTorrentEnabled', value => throwIfNotValid(value, isUserWebTorrentEnabledValid, 'WebTorrent enabled'))
128 @Column
129 webTorrentEnabled: boolean
130
131 @AllowNull(false)
132 @Default(true)
133 @Is('UserVideosHistoryEnabled', value => throwIfNotValid(value, isUserVideosHistoryEnabledValid, 'Videos history enabled'))
134 @Column
135 videosHistoryEnabled: boolean
136
137 @AllowNull(false)
138 @Default(true)
112 @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean')) 139 @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))
113 @Column 140 @Column
114 autoPlayVideo: boolean 141 autoPlayVideo: boolean
@@ -153,6 +180,19 @@ export class UserModel extends Model<UserModel> {
153 }) 180 })
154 Account: AccountModel 181 Account: AccountModel
155 182
183 @HasOne(() => UserNotificationSettingModel, {
184 foreignKey: 'userId',
185 onDelete: 'cascade',
186 hooks: true
187 })
188 NotificationSetting: UserNotificationSettingModel
189
190 @HasMany(() => VideoImportModel, {
191 foreignKey: 'userId',
192 onDelete: 'cascade'
193 })
194 VideoImports: VideoImportModel[]
195
156 @HasMany(() => OAuthTokenModel, { 196 @HasMany(() => OAuthTokenModel, {
157 foreignKey: 'userId', 197 foreignKey: 'userId',
158 onDelete: 'cascade' 198 onDelete: 'cascade'
@@ -172,7 +212,7 @@ export class UserModel extends Model<UserModel> {
172 } 212 }
173 213
174 @AfterUpdate 214 @AfterUpdate
175 @AfterDelete 215 @AfterDestroy
176 static removeTokenCache (instance: UserModel) { 216 static removeTokenCache (instance: UserModel) {
177 return clearCacheByUserId(instance.id) 217 return clearCacheByUserId(instance.id)
178 } 218 }
@@ -181,7 +221,25 @@ export class UserModel extends Model<UserModel> {
181 return this.count() 221 return this.count()
182 } 222 }
183 223
184 static listForApi (start: number, count: number, sort: string) { 224 static listForApi (start: number, count: number, sort: string, search?: string) {
225 let where = undefined
226 if (search) {
227 where = {
228 [Sequelize.Op.or]: [
229 {
230 email: {
231 [Sequelize.Op.iLike]: '%' + search + '%'
232 }
233 },
234 {
235 username: {
236 [ Sequelize.Op.iLike ]: '%' + search + '%'
237 }
238 }
239 ]
240 }
241 }
242
185 const query = { 243 const query = {
186 attributes: { 244 attributes: {
187 include: [ 245 include: [
@@ -204,7 +262,8 @@ export class UserModel extends Model<UserModel> {
204 }, 262 },
205 offset: start, 263 offset: start,
206 limit: count, 264 limit: count,
207 order: getSort(sort) 265 order: getSort(sort),
266 where
208 } 267 }
209 268
210 return UserModel.findAndCountAll(query) 269 return UserModel.findAndCountAll(query)
@@ -216,13 +275,12 @@ export class UserModel extends Model<UserModel> {
216 }) 275 })
217 } 276 }
218 277
219 static listEmailsWithRight (right: UserRight) { 278 static listWithRight (right: UserRight) {
220 const roles = Object.keys(USER_ROLE_LABELS) 279 const roles = Object.keys(USER_ROLE_LABELS)
221 .map(k => parseInt(k, 10) as UserRole) 280 .map(k => parseInt(k, 10) as UserRole)
222 .filter(role => hasUserRight(role, right)) 281 .filter(role => hasUserRight(role, right))
223 282
224 const query = { 283 const query = {
225 attribute: [ 'email' ],
226 where: { 284 where: {
227 role: { 285 role: {
228 [Sequelize.Op.in]: roles 286 [Sequelize.Op.in]: roles
@@ -230,9 +288,56 @@ export class UserModel extends Model<UserModel> {
230 } 288 }
231 } 289 }
232 290
233 return UserModel.unscoped() 291 return UserModel.findAll(query)
234 .findAll(query) 292 }
235 .then(u => u.map(u => u.email)) 293
294 static listUserSubscribersOf (actorId: number) {
295 const query = {
296 include: [
297 {
298 model: UserNotificationSettingModel.unscoped(),
299 required: true
300 },
301 {
302 attributes: [ 'userId' ],
303 model: AccountModel.unscoped(),
304 required: true,
305 include: [
306 {
307 attributes: [ ],
308 model: ActorModel.unscoped(),
309 required: true,
310 where: {
311 serverId: null
312 },
313 include: [
314 {
315 attributes: [ ],
316 as: 'ActorFollowings',
317 model: ActorFollowModel.unscoped(),
318 required: true,
319 where: {
320 targetActorId: actorId
321 }
322 }
323 ]
324 }
325 ]
326 }
327 ]
328 }
329
330 return UserModel.unscoped().findAll(query)
331 }
332
333 static listByUsernames (usernames: string[]) {
334 const query = {
335 where: {
336 username: usernames
337 }
338 }
339
340 return UserModel.findAll(query)
236 } 341 }
237 342
238 static loadById (id: number) { 343 static loadById (id: number) {
@@ -281,6 +386,95 @@ export class UserModel extends Model<UserModel> {
281 return UserModel.findOne(query) 386 return UserModel.findOne(query)
282 } 387 }
283 388
389 static loadByVideoId (videoId: number) {
390 const query = {
391 include: [
392 {
393 required: true,
394 attributes: [ 'id' ],
395 model: AccountModel.unscoped(),
396 include: [
397 {
398 required: true,
399 attributes: [ 'id' ],
400 model: VideoChannelModel.unscoped(),
401 include: [
402 {
403 required: true,
404 attributes: [ 'id' ],
405 model: VideoModel.unscoped(),
406 where: {
407 id: videoId
408 }
409 }
410 ]
411 }
412 ]
413 }
414 ]
415 }
416
417 return UserModel.findOne(query)
418 }
419
420 static loadByVideoImportId (videoImportId: number) {
421 const query = {
422 include: [
423 {
424 required: true,
425 attributes: [ 'id' ],
426 model: VideoImportModel.unscoped(),
427 where: {
428 id: videoImportId
429 }
430 }
431 ]
432 }
433
434 return UserModel.findOne(query)
435 }
436
437 static loadByChannelActorId (videoChannelActorId: number) {
438 const query = {
439 include: [
440 {
441 required: true,
442 attributes: [ 'id' ],
443 model: AccountModel.unscoped(),
444 include: [
445 {
446 required: true,
447 attributes: [ 'id' ],
448 model: VideoChannelModel.unscoped(),
449 where: {
450 actorId: videoChannelActorId
451 }
452 }
453 ]
454 }
455 ]
456 }
457
458 return UserModel.findOne(query)
459 }
460
461 static loadByAccountActorId (accountActorId: number) {
462 const query = {
463 include: [
464 {
465 required: true,
466 attributes: [ 'id' ],
467 model: AccountModel.unscoped(),
468 where: {
469 actorId: accountActorId
470 }
471 }
472 ]
473 }
474
475 return UserModel.findOne(query)
476 }
477
284 static getOriginalVideoFileTotalFromUser (user: UserModel) { 478 static getOriginalVideoFileTotalFromUser (user: UserModel) {
285 // Don't use sequelize because we need to use a sub query 479 // Don't use sequelize because we need to use a sub query
286 const query = UserModel.generateUserQuotaBaseSQL() 480 const query = UserModel.generateUserQuotaBaseSQL()
@@ -336,6 +530,8 @@ export class UserModel extends Model<UserModel> {
336 email: this.email, 530 email: this.email,
337 emailVerified: this.emailVerified, 531 emailVerified: this.emailVerified,
338 nsfwPolicy: this.nsfwPolicy, 532 nsfwPolicy: this.nsfwPolicy,
533 webTorrentEnabled: this.webTorrentEnabled,
534 videosHistoryEnabled: this.videosHistoryEnabled,
339 autoPlayVideo: this.autoPlayVideo, 535 autoPlayVideo: this.autoPlayVideo,
340 role: this.role, 536 role: this.role,
341 roleLabel: USER_ROLE_LABELS[ this.role ], 537 roleLabel: USER_ROLE_LABELS[ this.role ],
@@ -345,6 +541,7 @@ export class UserModel extends Model<UserModel> {
345 blocked: this.blocked, 541 blocked: this.blocked,
346 blockedReason: this.blockedReason, 542 blockedReason: this.blockedReason,
347 account: this.Account.toFormattedJSON(), 543 account: this.Account.toFormattedJSON(),
544 notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
348 videoChannels: [], 545 videoChannels: [],
349 videoQuotaUsed: videoQuotaUsed !== undefined 546 videoQuotaUsed: videoQuotaUsed !== undefined
350 ? parseInt(videoQuotaUsed, 10) 547 ? parseInt(videoQuotaUsed, 10)
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts
index 27bb43dae..796e07a42 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/activitypub/actor-follow.ts
@@ -127,22 +127,6 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
127 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved) 127 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
128 } 128 }
129 129
130 static updateActorFollowsScore (goodInboxes: string[], badInboxes: string[], t: Sequelize.Transaction | undefined) {
131 if (goodInboxes.length === 0 && badInboxes.length === 0) return
132
133 logger.info('Updating %d good actor follows and %d bad actor follows scores.', goodInboxes.length, badInboxes.length)
134
135 if (goodInboxes.length !== 0) {
136 ActorFollowModel.incrementScores(goodInboxes, ACTOR_FOLLOW_SCORE.BONUS, t)
137 .catch(err => logger.error('Cannot increment scores of good actor follows.', { err }))
138 }
139
140 if (badInboxes.length !== 0) {
141 ActorFollowModel.incrementScores(badInboxes, ACTOR_FOLLOW_SCORE.PENALTY, t)
142 .catch(err => logger.error('Cannot decrement scores of bad actor follows.', { err }))
143 }
144 }
145
146 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) { 130 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) {
147 const query = { 131 const query = {
148 where: { 132 where: {
@@ -280,7 +264,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
280 return ActorFollowModel.findAll(query) 264 return ActorFollowModel.findAll(query)
281 } 265 }
282 266
283 static listFollowingForApi (id: number, start: number, count: number, sort: string) { 267 static listFollowingForApi (id: number, start: number, count: number, sort: string, search?: string) {
284 const query = { 268 const query = {
285 distinct: true, 269 distinct: true,
286 offset: start, 270 offset: start,
@@ -299,7 +283,17 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
299 model: ActorModel, 283 model: ActorModel,
300 as: 'ActorFollowing', 284 as: 'ActorFollowing',
301 required: true, 285 required: true,
302 include: [ ServerModel ] 286 include: [
287 {
288 model: ServerModel,
289 required: true,
290 where: search ? {
291 host: {
292 [Sequelize.Op.iLike]: '%' + search + '%'
293 }
294 } : undefined
295 }
296 ]
303 } 297 }
304 ] 298 ]
305 } 299 }
@@ -313,7 +307,50 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
313 }) 307 })
314 } 308 }
315 309
316 static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { 310 static listFollowersForApi (actorId: number, start: number, count: number, sort: string, search?: string) {
311 const query = {
312 distinct: true,
313 offset: start,
314 limit: count,
315 order: getSort(sort),
316 include: [
317 {
318 model: ActorModel,
319 required: true,
320 as: 'ActorFollower',
321 include: [
322 {
323 model: ServerModel,
324 required: true,
325 where: search ? {
326 host: {
327 [ Sequelize.Op.iLike ]: '%' + search + '%'
328 }
329 } : undefined
330 }
331 ]
332 },
333 {
334 model: ActorModel,
335 as: 'ActorFollowing',
336 required: true,
337 where: {
338 id: actorId
339 }
340 }
341 ]
342 }
343
344 return ActorFollowModel.findAndCountAll(query)
345 .then(({ rows, count }) => {
346 return {
347 data: rows,
348 total: count
349 }
350 })
351 }
352
353 static listSubscriptionsForApi (actorId: number, start: number, count: number, sort: string) {
317 const query = { 354 const query = {
318 attributes: [], 355 attributes: [],
319 distinct: true, 356 distinct: true,
@@ -321,7 +358,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
321 limit: count, 358 limit: count,
322 order: getSort(sort), 359 order: getSort(sort),
323 where: { 360 where: {
324 actorId: id 361 actorId: actorId
325 }, 362 },
326 include: [ 363 include: [
327 { 364 {
@@ -370,39 +407,6 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
370 }) 407 })
371 } 408 }
372 409
373 static listFollowersForApi (id: number, start: number, count: number, sort: string) {
374 const query = {
375 distinct: true,
376 offset: start,
377 limit: count,
378 order: getSort(sort),
379 include: [
380 {
381 model: ActorModel,
382 required: true,
383 as: 'ActorFollower',
384 include: [ ServerModel ]
385 },
386 {
387 model: ActorModel,
388 as: 'ActorFollowing',
389 required: true,
390 where: {
391 id
392 }
393 }
394 ]
395 }
396
397 return ActorFollowModel.findAndCountAll(query)
398 .then(({ rows, count }) => {
399 return {
400 data: rows,
401 total: count
402 }
403 })
404 }
405
406 static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { 410 static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
407 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) 411 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
408 } 412 }
@@ -444,6 +448,22 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
444 } 448 }
445 } 449 }
446 450
451 static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) {
452 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
453 'WHERE id IN (' +
454 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
455 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
456 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
457 ')'
458
459 const options = {
460 type: Sequelize.QueryTypes.BULKUPDATE,
461 transaction: t
462 }
463
464 return ActorFollowModel.sequelize.query(query, options)
465 }
466
447 private static async createListAcceptedFollowForApiQuery ( 467 private static async createListAcceptedFollowForApiQuery (
448 type: 'followers' | 'following', 468 type: 'followers' | 'following',
449 actorIds: number[], 469 actorIds: number[],
@@ -489,33 +509,15 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
489 tasks.push(ActorFollowModel.sequelize.query(query, options)) 509 tasks.push(ActorFollowModel.sequelize.query(query, options))
490 } 510 }
491 511
492 const [ followers, [ { total } ] ] = await Promise.all(tasks) 512 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
493 const urls: string[] = followers.map(f => f.url) 513 const urls: string[] = followers.map(f => f.url)
494 514
495 return { 515 return {
496 data: urls, 516 data: urls,
497 total: parseInt(total, 10) 517 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
498 } 518 }
499 } 519 }
500 520
501 private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction | undefined) {
502 const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',')
503
504 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
505 'WHERE id IN (' +
506 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
507 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
508 'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' +
509 ')'
510
511 const options = t ? {
512 type: Sequelize.QueryTypes.BULKUPDATE,
513 transaction: t
514 } : undefined
515
516 return ActorFollowModel.sequelize.query(query, options)
517 }
518
519 private static listBadActorFollows () { 521 private static listBadActorFollows () {
520 const query = { 522 const query = {
521 where: { 523 where: {
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 12b83916e..dda57a8ba 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -219,6 +219,7 @@ export class ActorModel extends Model<ActorModel> {
219 name: 'actorId', 219 name: 'actorId',
220 allowNull: false 220 allowNull: false
221 }, 221 },
222 as: 'ActorFollowings',
222 onDelete: 'cascade' 223 onDelete: 'cascade'
223 }) 224 })
224 ActorFollowing: ActorFollowModel[] 225 ActorFollowing: ActorFollowModel[]
diff --git a/server/models/avatar/avatar.ts b/server/models/avatar/avatar.ts
index 5d73e24fa..303aebcc2 100644
--- a/server/models/avatar/avatar.ts
+++ b/server/models/avatar/avatar.ts
@@ -23,7 +23,10 @@ export class AvatarModel extends Model<AvatarModel> {
23 @AfterDestroy 23 @AfterDestroy
24 static removeFilesAndSendDelete (instance: AvatarModel) { 24 static removeFilesAndSendDelete (instance: AvatarModel) {
25 logger.info('Removing avatar file %s.', instance.filename) 25 logger.info('Removing avatar file %s.', instance.filename)
26 return instance.removeAvatar() 26
27 // Don't block the transaction
28 instance.removeAvatar()
29 .catch(err => logger.error('Cannot remove avatar file %s.', instance.filename, err))
27 } 30 }
28 31
29 toFormattedJSON (): Avatar { 32 toFormattedJSON (): Avatar {
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
index ef9592c04..08d892da4 100644
--- a/server/models/oauth/oauth-token.ts
+++ b/server/models/oauth/oauth-token.ts
@@ -1,5 +1,5 @@
1import { 1import {
2 AfterDelete, 2 AfterDestroy,
3 AfterUpdate, 3 AfterUpdate,
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -47,7 +47,7 @@ enum ScopeNames {
47 required: true, 47 required: true,
48 include: [ 48 include: [
49 { 49 {
50 attributes: [ 'id' ], 50 attributes: [ 'id', 'url' ],
51 model: () => ActorModel.unscoped(), 51 model: () => ActorModel.unscoped(),
52 required: true 52 required: true
53 } 53 }
@@ -126,7 +126,7 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
126 OAuthClients: OAuthClientModel[] 126 OAuthClients: OAuthClientModel[]
127 127
128 @AfterUpdate 128 @AfterUpdate
129 @AfterDelete 129 @AfterDestroy
130 static removeTokenCache (token: OAuthTokenModel) { 130 static removeTokenCache (token: OAuthTokenModel) {
131 return clearCacheByToken(token.accessToken) 131 return clearCacheByToken(token.accessToken)
132 } 132 }
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 2ebe23ef1..b722bed14 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -15,7 +15,7 @@ import {
15import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../activitypub/actor'
16import { getVideoSort, throwIfNotValid } from '../utils' 16import { getVideoSort, throwIfNotValid } from '../utils'
17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' 17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
18import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers' 18import { CONFIG, CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers'
19import { VideoFileModel } from '../video/video-file' 19import { VideoFileModel } from '../video/video-file'
20import { getServerActor } from '../../helpers/utils' 20import { getServerActor } from '../../helpers/utils'
21import { VideoModel } from '../video/video' 21import { VideoModel } from '../video/video'
@@ -28,6 +28,7 @@ import { sample } from 'lodash'
28import { isTestInstance } from '../../helpers/core-utils' 28import { isTestInstance } from '../../helpers/core-utils'
29import * as Bluebird from 'bluebird' 29import * as Bluebird from 'bluebird'
30import * as Sequelize from 'sequelize' 30import * as Sequelize from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
31 32
32export enum ScopeNames { 33export enum ScopeNames {
33 WITH_VIDEO = 'WITH_VIDEO' 34 WITH_VIDEO = 'WITH_VIDEO'
@@ -38,7 +39,17 @@ export enum ScopeNames {
38 include: [ 39 include: [
39 { 40 {
40 model: () => VideoFileModel, 41 model: () => VideoFileModel,
41 required: true, 42 required: false,
43 include: [
44 {
45 model: () => VideoModel,
46 required: true
47 }
48 ]
49 },
50 {
51 model: () => VideoStreamingPlaylistModel,
52 required: false,
42 include: [ 53 include: [
43 { 54 {
44 model: () => VideoModel, 55 model: () => VideoModel,
@@ -97,12 +108,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
97 108
98 @BelongsTo(() => VideoFileModel, { 109 @BelongsTo(() => VideoFileModel, {
99 foreignKey: { 110 foreignKey: {
100 allowNull: false 111 allowNull: true
101 }, 112 },
102 onDelete: 'cascade' 113 onDelete: 'cascade'
103 }) 114 })
104 VideoFile: VideoFileModel 115 VideoFile: VideoFileModel
105 116
117 @ForeignKey(() => VideoStreamingPlaylistModel)
118 @Column
119 videoStreamingPlaylistId: number
120
121 @BelongsTo(() => VideoStreamingPlaylistModel, {
122 foreignKey: {
123 allowNull: true
124 },
125 onDelete: 'cascade'
126 })
127 VideoStreamingPlaylist: VideoStreamingPlaylistModel
128
106 @ForeignKey(() => ActorModel) 129 @ForeignKey(() => ActorModel)
107 @Column 130 @Column
108 actorId: number 131 actorId: number
@@ -117,16 +140,27 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
117 140
118 @BeforeDestroy 141 @BeforeDestroy
119 static async removeFile (instance: VideoRedundancyModel) { 142 static async removeFile (instance: VideoRedundancyModel) {
120 // Not us 143 if (!instance.isOwned()) return
121 if (!instance.strategy) return
122 144
123 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) 145 if (instance.videoFileId) {
146 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
124 147
125 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 148 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
126 logger.info('Removing duplicated video file %s.', logIdentifier) 149 logger.info('Removing duplicated video file %s.', logIdentifier)
127 150
128 videoFile.Video.removeFile(videoFile) 151 videoFile.Video.removeFile(videoFile, true)
129 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 152 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
153 }
154
155 if (instance.videoStreamingPlaylistId) {
156 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
157
158 const videoUUID = videoStreamingPlaylist.Video.uuid
159 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
160
161 videoStreamingPlaylist.Video.removeStreamingPlaylist(true)
162 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
163 }
130 164
131 return undefined 165 return undefined
132 } 166 }
@@ -144,6 +178,19 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
144 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) 178 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
145 } 179 }
146 180
181 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) {
182 const actor = await getServerActor()
183
184 const query = {
185 where: {
186 actorId: actor.id,
187 videoStreamingPlaylistId
188 }
189 }
190
191 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
192 }
193
147 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 194 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
148 const query = { 195 const query = {
149 where: { 196 where: {
@@ -192,7 +239,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
192 const ids = rows.map(r => r.id) 239 const ids = rows.map(r => r.id)
193 const id = sample(ids) 240 const id = sample(ids)
194 241
195 return VideoModel.loadWithFile(id, undefined, !isTestInstance()) 242 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
196 } 243 }
197 244
198 static async findMostViewToDuplicate (randomizedFactor: number) { 245 static async findMostViewToDuplicate (randomizedFactor: number) {
@@ -293,6 +340,11 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
293 } 340 }
294 341
295 return VideoFileModel.sum('size', options as any) // FIXME: typings 342 return VideoFileModel.sum('size', options as any) // FIXME: typings
343 .then(v => {
344 if (!v || isNaN(v)) return 0
345
346 return v
347 })
296 } 348 }
297 349
298 static async listLocalExpired () { 350 static async listLocalExpired () {
@@ -329,40 +381,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
329 381
330 static async listLocalOfServer (serverId: number) { 382 static async listLocalOfServer (serverId: number) {
331 const actor = await getServerActor() 383 const actor = await getServerActor()
332 384 const buildVideoInclude = () => ({
333 const query = { 385 model: VideoModel,
334 where: { 386 required: true,
335 actorId: actor.id
336 },
337 include: [ 387 include: [
338 { 388 {
339 model: VideoFileModel, 389 attributes: [],
390 model: VideoChannelModel.unscoped(),
340 required: true, 391 required: true,
341 include: [ 392 include: [
342 { 393 {
343 model: VideoModel, 394 attributes: [],
395 model: ActorModel.unscoped(),
344 required: true, 396 required: true,
345 include: [ 397 where: {
346 { 398 serverId
347 attributes: [], 399 }
348 model: VideoChannelModel.unscoped(),
349 required: true,
350 include: [
351 {
352 attributes: [],
353 model: ActorModel.unscoped(),
354 required: true,
355 where: {
356 serverId
357 }
358 }
359 ]
360 }
361 ]
362 } 400 }
363 ] 401 ]
364 } 402 }
365 ] 403 ]
404 })
405
406 const query = {
407 where: {
408 actorId: actor.id
409 },
410 include: [
411 {
412 model: VideoFileModel,
413 required: false,
414 include: [ buildVideoInclude() ]
415 },
416 {
417 model: VideoStreamingPlaylistModel,
418 required: false,
419 include: [ buildVideoInclude() ]
420 }
421 ]
366 } 422 }
367 423
368 return VideoRedundancyModel.findAll(query) 424 return VideoRedundancyModel.findAll(query)
@@ -391,7 +447,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
391 ] 447 ]
392 } 448 }
393 449
394 return VideoRedundancyModel.find(query as any) // FIXME: typings 450 return VideoRedundancyModel.findOne(query as any) // FIXME: typings
395 .then((r: any) => ({ 451 .then((r: any) => ({
396 totalUsed: parseInt(r.totalUsed.toString(), 10), 452 totalUsed: parseInt(r.totalUsed.toString(), 10),
397 totalVideos: r.totalVideos, 453 totalVideos: r.totalVideos,
@@ -399,7 +455,32 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
399 })) 455 }))
400 } 456 }
401 457
458 getVideo () {
459 if (this.VideoFile) return this.VideoFile.Video
460
461 return this.VideoStreamingPlaylist.Video
462 }
463
464 isOwned () {
465 return !!this.strategy
466 }
467
402 toActivityPubObject (): CacheFileObject { 468 toActivityPubObject (): CacheFileObject {
469 if (this.VideoStreamingPlaylist) {
470 return {
471 id: this.url,
472 type: 'CacheFile' as 'CacheFile',
473 object: this.VideoStreamingPlaylist.Video.url,
474 expires: this.expiresOn.toISOString(),
475 url: {
476 type: 'Link',
477 mimeType: 'application/x-mpegURL',
478 mediaType: 'application/x-mpegURL',
479 href: this.fileUrl
480 }
481 }
482 }
483
403 return { 484 return {
404 id: this.url, 485 id: this.url,
405 type: 'CacheFile' as 'CacheFile', 486 type: 'CacheFile' as 'CacheFile',
@@ -407,7 +488,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
407 expires: this.expiresOn.toISOString(), 488 expires: this.expiresOn.toISOString(),
408 url: { 489 url: {
409 type: 'Link', 490 type: 'Link',
410 mimeType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any, 491 mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
492 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
411 href: this.fileUrl, 493 href: this.fileUrl,
412 height: this.VideoFile.resolution, 494 height: this.VideoFile.resolution,
413 size: this.VideoFile.size, 495 size: this.VideoFile.size,
@@ -422,7 +504,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
422 504
423 const notIn = Sequelize.literal( 505 const notIn = Sequelize.literal(
424 '(' + 506 '(' +
425 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` + 507 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` +
426 ')' 508 ')'
427 ) 509 )
428 510
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
new file mode 100644
index 000000000..450f27152
--- /dev/null
+++ b/server/models/server/server-blocklist.ts
@@ -0,0 +1,121 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from '../account/account'
3import { ServerModel } from './server'
4import { ServerBlock } from '../../../shared/models/blocklist'
5import { getSort } from '../utils'
6
7enum ScopeNames {
8 WITH_ACCOUNT = 'WITH_ACCOUNT',
9 WITH_SERVER = 'WITH_SERVER'
10}
11
12@Scopes({
13 [ScopeNames.WITH_ACCOUNT]: {
14 include: [
15 {
16 model: () => AccountModel,
17 required: true
18 }
19 ]
20 },
21 [ScopeNames.WITH_SERVER]: {
22 include: [
23 {
24 model: () => ServerModel,
25 required: true
26 }
27 ]
28 }
29})
30
31@Table({
32 tableName: 'serverBlocklist',
33 indexes: [
34 {
35 fields: [ 'accountId', 'targetServerId' ],
36 unique: true
37 },
38 {
39 fields: [ 'targetServerId' ]
40 }
41 ]
42})
43export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
44
45 @CreatedAt
46 createdAt: Date
47
48 @UpdatedAt
49 updatedAt: Date
50
51 @ForeignKey(() => AccountModel)
52 @Column
53 accountId: number
54
55 @BelongsTo(() => AccountModel, {
56 foreignKey: {
57 name: 'accountId',
58 allowNull: false
59 },
60 onDelete: 'CASCADE'
61 })
62 ByAccount: AccountModel
63
64 @ForeignKey(() => ServerModel)
65 @Column
66 targetServerId: number
67
68 @BelongsTo(() => ServerModel, {
69 foreignKey: {
70 name: 'targetServerId',
71 allowNull: false
72 },
73 onDelete: 'CASCADE'
74 })
75 BlockedServer: ServerModel
76
77 static loadByAccountAndHost (accountId: number, host: string) {
78 const query = {
79 where: {
80 accountId
81 },
82 include: [
83 {
84 model: ServerModel,
85 where: {
86 host
87 },
88 required: true
89 }
90 ]
91 }
92
93 return ServerBlocklistModel.findOne(query)
94 }
95
96 static listForApi (accountId: number, start: number, count: number, sort: string) {
97 const query = {
98 offset: start,
99 limit: count,
100 order: getSort(sort),
101 where: {
102 accountId
103 }
104 }
105
106 return ServerBlocklistModel
107 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ])
108 .findAndCountAll(query)
109 .then(({ rows, count }) => {
110 return { total: count, data: rows }
111 })
112 }
113
114 toFormattedJSON (): ServerBlock {
115 return {
116 byAccount: this.ByAccount.toFormattedJSON(),
117 blockedServer: this.BlockedServer.toFormattedJSON(),
118 createdAt: this.createdAt
119 }
120 }
121}
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index ca3b24d51..300d70938 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -49,4 +49,10 @@ export class ServerModel extends Model<ServerModel> {
49 49
50 return ServerModel.findOne(query) 50 return ServerModel.findOne(query)
51 } 51 }
52
53 toFormattedJSON () {
54 return {
55 host: this.host
56 }
57 }
52} 58}
diff --git a/server/models/utils.ts b/server/models/utils.ts
index e0bf091ad..5b4093aec 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -29,7 +29,11 @@ function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
29 ] 29 ]
30 } 30 }
31 31
32 return [ [ field, direction ], lastSort ] 32 const firstSort = typeof field === 'string' ?
33 field.split('.').concat([ direction ]) :
34 [ field, direction ]
35
36 return [ firstSort, lastSort ]
33} 37}
34 38
35function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) { 39function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
@@ -64,9 +68,25 @@ function createSimilarityAttribute (col: string, value: string) {
64 ) 68 )
65} 69}
66 70
71function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number) {
72 const blockerIds = [ serverAccountId ]
73 if (userAccountId) blockerIds.push(userAccountId)
74
75 const blockerIdsString = blockerIds.join(', ')
76
77 const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
78 ' UNION ALL ' +
79 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
80 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
81 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
82
83 return query
84}
85
67// --------------------------------------------------------------------------- 86// ---------------------------------------------------------------------------
68 87
69export { 88export {
89 buildBlockedAccountSQL,
70 SortType, 90 SortType,
71 getSort, 91 getSort,
72 getVideoSort, 92 getVideoSort,
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index dbb88ca45..cc47644f2 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -1,17 +1,4 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' 2import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
16import { VideoAbuse } from '../../../shared/models/videos' 3import { VideoAbuse } from '../../../shared/models/videos'
17import { 4import {
@@ -19,7 +6,6 @@ import {
19 isVideoAbuseReasonValid, 6 isVideoAbuseReasonValid,
20 isVideoAbuseStateValid 7 isVideoAbuseStateValid
21} from '../../helpers/custom-validators/video-abuses' 8} from '../../helpers/custom-validators/video-abuses'
22import { Emailer } from '../../lib/emailer'
23import { AccountModel } from '../account/account' 9import { AccountModel } from '../account/account'
24import { getSort, throwIfNotValid } from '../utils' 10import { getSort, throwIfNotValid } from '../utils'
25import { VideoModel } from './video' 11import { VideoModel } from './video'
@@ -40,8 +26,9 @@ import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers'
40export class VideoAbuseModel extends Model<VideoAbuseModel> { 26export class VideoAbuseModel extends Model<VideoAbuseModel> {
41 27
42 @AllowNull(false) 28 @AllowNull(false)
29 @Default(null)
43 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason')) 30 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
44 @Column 31 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
45 reason: string 32 reason: string
46 33
47 @AllowNull(false) 34 @AllowNull(false)
@@ -86,11 +73,6 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
86 }) 73 })
87 Video: VideoModel 74 Video: VideoModel
88 75
89 @AfterCreate
90 static sendEmailNotification (instance: VideoAbuseModel) {
91 return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
92 }
93
94 static loadByIdAndVideoId (id: number, videoId: number) { 76 static loadByIdAndVideoId (id: number, videoId: number) {
95 const query = { 77 const query = {
96 where: { 78 where: {
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 67f7cd487..3b567e488 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,21 +1,7 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AfterDestroy,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
16import { VideoModel } from './video' 3import { VideoModel } from './video'
17import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 4import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist'
18import { Emailer } from '../../lib/emailer'
19import { VideoBlacklist } from '../../../shared/models/videos' 5import { VideoBlacklist } from '../../../shared/models/videos'
20import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { CONSTRAINTS_FIELDS } from '../../initializers'
21 7
@@ -35,6 +21,10 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
35 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max)) 21 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
36 reason: string 22 reason: string
37 23
24 @AllowNull(false)
25 @Column
26 unfederated: boolean
27
38 @CreatedAt 28 @CreatedAt
39 createdAt: Date 29 createdAt: Date
40 30
@@ -53,16 +43,6 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
53 }) 43 })
54 Video: VideoModel 44 Video: VideoModel
55 45
56 @AfterCreate
57 static sendBlacklistEmailNotification (instance: VideoBlacklistModel) {
58 return Emailer.Instance.addVideoBlacklistReportJob(instance.videoId, instance.reason)
59 }
60
61 @AfterDestroy
62 static sendUnblacklistEmailNotification (instance: VideoBlacklistModel) {
63 return Emailer.Instance.addVideoUnblacklistReportJob(instance.videoId)
64 }
65
66 static listForApi (start: number, count: number, sort: SortType) { 46 static listForApi (start: number, count: number, sort: SortType) {
67 const query = { 47 const query = {
68 offset: start, 48 offset: start,
@@ -103,6 +83,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
103 createdAt: this.createdAt, 83 createdAt: this.createdAt,
104 updatedAt: this.updatedAt, 84 updatedAt: this.updatedAt,
105 reason: this.reason, 85 reason: this.reason,
86 unfederated: this.unfederated,
106 87
107 video: { 88 video: {
108 id: video.id, 89 id: video.id,
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index f4586917e..5598d80f6 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -233,6 +233,27 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
233 }) 233 })
234 } 234 }
235 235
236 static listLocalsForSitemap (sort: string) {
237 const query = {
238 attributes: [ ],
239 offset: 0,
240 order: getSort(sort),
241 include: [
242 {
243 attributes: [ 'preferredUsername', 'serverId' ],
244 model: ActorModel.unscoped(),
245 where: {
246 serverId: null
247 }
248 }
249 ]
250 }
251
252 return VideoChannelModel
253 .unscoped()
254 .findAll(query)
255 }
256
236 static searchForApi (options: { 257 static searchForApi (options: {
237 actorId: number 258 actorId: number
238 search: string 259 search: string
@@ -449,4 +470,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
449 getDisplayName () { 470 getDisplayName () {
450 return this.name 471 return this.name
451 } 472 }
473
474 isOutdated () {
475 return this.Actor.isOutdated()
476 }
452} 477}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index f84c1880c..cf6278da7 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,21 +1,37 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { 2import {
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table, 3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 IFindOptions,
11 Is,
12 Model,
13 Scopes,
14 Table,
4 UpdatedAt 15 UpdatedAt
5} from 'sequelize-typescript' 16} from 'sequelize-typescript'
6import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects' 17import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
7import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
8import { VideoComment } from '../../../shared/models/videos/video-comment.model' 19import { VideoComment } from '../../../shared/models/videos/video-comment.model'
9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10import { CONSTRAINTS_FIELDS } from '../../initializers' 21import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
11import { sendDeleteVideoComment } from '../../lib/activitypub/send' 22import { sendDeleteVideoComment } from '../../lib/activitypub/send'
12import { AccountModel } from '../account/account' 23import { AccountModel } from '../account/account'
13import { ActorModel } from '../activitypub/actor' 24import { ActorModel } from '../activitypub/actor'
14import { AvatarModel } from '../avatar/avatar' 25import { AvatarModel } from '../avatar/avatar'
15import { ServerModel } from '../server/server' 26import { ServerModel } from '../server/server'
16import { getSort, throwIfNotValid } from '../utils' 27import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
17import { VideoModel } from './video' 28import { VideoModel } from './video'
18import { VideoChannelModel } from './video-channel' 29import { VideoChannelModel } from './video-channel'
30import { getServerActor } from '../../helpers/utils'
31import { UserModel } from '../account/user'
32import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33import { regexpCapture } from '../../helpers/regexp'
34import { uniq } from 'lodash'
19 35
20enum ScopeNames { 36enum ScopeNames {
21 WITH_ACCOUNT = 'WITH_ACCOUNT', 37 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -25,18 +41,29 @@ enum ScopeNames {
25} 41}
26 42
27@Scopes({ 43@Scopes({
28 [ScopeNames.ATTRIBUTES_FOR_API]: { 44 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
29 attributes: { 45 return {
30 include: [ 46 attributes: {
31 [ 47 include: [
32 Sequelize.literal( 48 [
33 '(SELECT COUNT("replies"."id") ' + 49 Sequelize.literal(
34 'FROM "videoComment" AS "replies" ' + 50 '(' +
35 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")' 51 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
36 ), 52 'SELECT COUNT("replies"."id") - (' +
37 'totalReplies' 53 'SELECT COUNT("replies"."id") ' +
54 'FROM "videoComment" AS "replies" ' +
55 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
56 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
57 ')' +
58 'FROM "videoComment" AS "replies" ' +
59 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
60 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
61 ')'
62 ),
63 'totalReplies'
64 ]
38 ] 65 ]
39 ] 66 }
40 } 67 }
41 }, 68 },
42 [ScopeNames.WITH_ACCOUNT]: { 69 [ScopeNames.WITH_ACCOUNT]: {
@@ -267,26 +294,47 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
267 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query) 294 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
268 } 295 }
269 296
270 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) { 297 static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
298 const serverActor = await getServerActor()
299 const serverAccountId = serverActor.Account.id
300 const userAccountId = user ? user.Account.id : undefined
301
271 const query = { 302 const query = {
272 offset: start, 303 offset: start,
273 limit: count, 304 limit: count,
274 order: getSort(sort), 305 order: getSort(sort),
275 where: { 306 where: {
276 videoId, 307 videoId,
277 inReplyToCommentId: null 308 inReplyToCommentId: null,
309 accountId: {
310 [Sequelize.Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
312 )
313 }
278 } 314 }
279 } 315 }
280 316
317 // FIXME: typings
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT,
320 {
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
322 }
323 ]
324
281 return VideoCommentModel 325 return VideoCommentModel
282 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) 326 .scope(scopes)
283 .findAndCountAll(query) 327 .findAndCountAll(query)
284 .then(({ rows, count }) => { 328 .then(({ rows, count }) => {
285 return { total: count, data: rows } 329 return { total: count, data: rows }
286 }) 330 })
287 } 331 }
288 332
289 static listThreadCommentsForApi (videoId: number, threadId: number) { 333 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
334 const serverActor = await getServerActor()
335 const serverAccountId = serverActor.Account.id
336 const userAccountId = user ? user.Account.id : undefined
337
290 const query = { 338 const query = {
291 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ], 339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
292 where: { 340 where: {
@@ -294,12 +342,24 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
294 [ Sequelize.Op.or ]: [ 342 [ Sequelize.Op.or ]: [
295 { id: threadId }, 343 { id: threadId },
296 { originCommentId: threadId } 344 { originCommentId: threadId }
297 ] 345 ],
346 accountId: {
347 [Sequelize.Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
349 )
350 }
298 } 351 }
299 } 352 }
300 353
354 const scopes: any[] = [
355 ScopeNames.WITH_ACCOUNT,
356 {
357 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
358 }
359 ]
360
301 return VideoCommentModel 361 return VideoCommentModel
302 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) 362 .scope(scopes)
303 .findAndCountAll(query) 363 .findAndCountAll(query)
304 .then(({ rows, count }) => { 364 .then(({ rows, count }) => {
305 return { total: count, data: rows } 365 return { total: count, data: rows }
@@ -313,9 +373,11 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
313 id: { 373 id: {
314 [ Sequelize.Op.in ]: Sequelize.literal('(' + 374 [ Sequelize.Op.in ]: Sequelize.literal('(' +
315 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
316 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' + 376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
317 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' + 377 'UNION ' +
318 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' + 378 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
379 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
380 ') ' +
319 'SELECT id FROM children' + 381 'SELECT id FROM children' +
320 ')'), 382 ')'),
321 [ Sequelize.Op.ne ]: comment.id 383 [ Sequelize.Op.ne ]: comment.id
@@ -391,6 +453,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
391 } 453 }
392 } 454 }
393 455
456 getCommentStaticPath () {
457 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
458 }
459
394 getThreadId (): number { 460 getThreadId (): number {
395 return this.originCommentId || this.id 461 return this.originCommentId || this.id
396 } 462 }
@@ -399,6 +465,34 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
399 return this.Account.isOwned() 465 return this.Account.isOwned()
400 } 466 }
401 467
468 extractMentions () {
469 if (!this.text) return []
470
471 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
473
474 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
475 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
476 const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
477 const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
478
479 return uniq(
480 [].concat(
481 regexpCapture(this.text, remoteMentionsRegex)
482 .map(([ , username ]) => username),
483
484 regexpCapture(this.text, localMentionsRegex)
485 .map(([ , username ]) => username),
486
487 regexpCapture(this.text, firstMentionRegex)
488 .map(([ , username1, username2 ]) => username1 || username2),
489
490 regexpCapture(this.text, endMentionRegex)
491 .map(([ , username1, username2 ]) => username1 || username2)
492 )
493 )
494 }
495
402 toFormattedJSON () { 496 toFormattedJSON () {
403 return { 497 return {
404 id: this.id, 498 id: this.id,
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index adebdf0c7..7d1e371b9 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -1,4 +1,3 @@
1import { values } from 'lodash'
2import { 1import {
3 AllowNull, 2 AllowNull,
4 BelongsTo, 3 BelongsTo,
@@ -14,12 +13,12 @@ import {
14 UpdatedAt 13 UpdatedAt
15} from 'sequelize-typescript' 14} from 'sequelize-typescript'
16import { 15import {
16 isVideoFileExtnameValid,
17 isVideoFileInfoHashValid, 17 isVideoFileInfoHashValid,
18 isVideoFileResolutionValid, 18 isVideoFileResolutionValid,
19 isVideoFileSizeValid, 19 isVideoFileSizeValid,
20 isVideoFPSResolutionValid 20 isVideoFPSResolutionValid
21} from '../../helpers/custom-validators/videos' 21} from '../../helpers/custom-validators/videos'
22import { CONSTRAINTS_FIELDS } from '../../initializers'
23import { throwIfNotValid } from '../utils' 22import { throwIfNotValid } from '../utils'
24import { VideoModel } from './video' 23import { VideoModel } from './video'
25import * as Sequelize from 'sequelize' 24import * as Sequelize from 'sequelize'
@@ -58,11 +57,12 @@ export class VideoFileModel extends Model<VideoFileModel> {
58 size: number 57 size: number
59 58
60 @AllowNull(false) 59 @AllowNull(false)
61 @Column(DataType.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME))) 60 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
61 @Column
62 extname: string 62 extname: string
63 63
64 @AllowNull(false) 64 @AllowNull(false)
65 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 65 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
66 @Column 66 @Column
67 infoHash: string 67 infoHash: string
68 68
@@ -86,14 +86,14 @@ export class VideoFileModel extends Model<VideoFileModel> {
86 86
87 @HasMany(() => VideoRedundancyModel, { 87 @HasMany(() => VideoRedundancyModel, {
88 foreignKey: { 88 foreignKey: {
89 allowNull: false 89 allowNull: true
90 }, 90 },
91 onDelete: 'CASCADE', 91 onDelete: 'CASCADE',
92 hooks: true 92 hooks: true
93 }) 93 })
94 RedundancyVideos: VideoRedundancyModel[] 94 RedundancyVideos: VideoRedundancyModel[]
95 95
96 static isInfohashExists (infoHash: string) { 96 static doesInfohashExist (infoHash: string) {
97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
98 const options = { 98 const options = {
99 type: Sequelize.QueryTypes.SELECT, 99 type: Sequelize.QueryTypes.SELECT,
@@ -120,6 +120,26 @@ export class VideoFileModel extends Model<VideoFileModel> {
120 return VideoFileModel.findById(id, options) 120 return VideoFileModel.findById(id, options)
121 } 121 }
122 122
123 static async getStats () {
124 let totalLocalVideoFilesSize = await VideoFileModel.sum('size', {
125 include: [
126 {
127 attributes: [],
128 model: VideoModel.unscoped(),
129 where: {
130 remote: false
131 }
132 }
133 ]
134 } as any)
135 // Sequelize could return null...
136 if (!totalLocalVideoFilesSize) totalLocalVideoFilesSize = 0
137
138 return {
139 totalLocalVideoFilesSize
140 }
141 }
142
123 hasSameUniqueKeysThan (other: VideoFileModel) { 143 hasSameUniqueKeysThan (other: VideoFileModel) {
124 return this.fps === other.fps && 144 return this.fps === other.fps &&
125 this.resolution === other.resolution && 145 this.resolution === other.resolution &&
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index e7bff2ed7..76d0445d4 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -1,8 +1,13 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video' 2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file' 3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' 4import {
5import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers' 5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
10import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
7import { 12import {
8 getVideoCommentsActivityPubUrl, 13 getVideoCommentsActivityPubUrl,
@@ -11,6 +16,8 @@ import {
11 getVideoSharesActivityPubUrl 16 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub' 17} from '../../lib/activitypub'
13import { isArray } from '../../helpers/custom-validators/misc' 18import { isArray } from '../../helpers/custom-validators/misc'
19import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
20import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
14 21
15export type VideoFormattingJSONOptions = { 22export type VideoFormattingJSONOptions = {
16 completeDescription?: boolean 23 completeDescription?: boolean
@@ -120,7 +127,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
120 } 127 }
121 }) 128 })
122 129
130 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
131
123 const tags = video.Tags ? video.Tags.map(t => t.name) : [] 132 const tags = video.Tags ? video.Tags.map(t => t.name) : []
133
134 const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
135
124 const detailsJson = { 136 const detailsJson = {
125 support: video.support, 137 support: video.support,
126 descriptionPath: video.getDescriptionAPIPath(), 138 descriptionPath: video.getDescriptionAPIPath(),
@@ -134,7 +146,11 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
134 id: video.state, 146 id: video.state,
135 label: VideoModel.getStateLabel(video.state) 147 label: VideoModel.getStateLabel(video.state)
136 }, 148 },
137 files: [] 149
150 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
151
152 files: [],
153 streamingPlaylists
138 } 154 }
139 155
140 // Format and sort video files 156 // Format and sort video files
@@ -143,6 +159,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
143 return Object.assign(formattedJson, detailsJson) 159 return Object.assign(formattedJson, detailsJson)
144} 160}
145 161
162function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
163 if (isArray(playlists) === false) return []
164
165 return playlists
166 .map(playlist => {
167 const redundancies = isArray(playlist.RedundancyVideos)
168 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
169 : []
170
171 return {
172 id: playlist.id,
173 type: playlist.type,
174 playlistUrl: playlist.playlistUrl,
175 segmentsSha256Url: playlist.segmentsSha256Url,
176 redundancies
177 } as VideoStreamingPlaylist
178 })
179}
180
146function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 181function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
147 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 182 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
148 183
@@ -208,7 +243,8 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
208 for (const file of video.VideoFiles) { 243 for (const file of video.VideoFiles) {
209 url.push({ 244 url.push({
210 type: 'Link', 245 type: 'Link',
211 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any, 246 mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
247 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
212 href: video.getVideoFileUrl(file, baseUrlHttp), 248 href: video.getVideoFileUrl(file, baseUrlHttp),
213 height: file.resolution, 249 height: file.resolution,
214 size: file.size, 250 size: file.size,
@@ -218,6 +254,7 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
218 url.push({ 254 url.push({
219 type: 'Link', 255 type: 'Link',
220 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent', 256 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
257 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
221 href: video.getTorrentUrl(file, baseUrlHttp), 258 href: video.getTorrentUrl(file, baseUrlHttp),
222 height: file.resolution 259 height: file.resolution
223 }) 260 })
@@ -225,15 +262,39 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
225 url.push({ 262 url.push({
226 type: 'Link', 263 type: 'Link',
227 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', 264 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
265 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
228 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs), 266 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
229 height: file.resolution 267 height: file.resolution
230 }) 268 })
231 } 269 }
232 270
271 for (const playlist of (video.VideoStreamingPlaylists || [])) {
272 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
273
274 tag = playlist.p2pMediaLoaderInfohashes
275 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
276 tag.push({
277 type: 'Link',
278 name: 'sha256',
279 mimeType: 'application/json' as 'application/json',
280 mediaType: 'application/json' as 'application/json',
281 href: playlist.segmentsSha256Url
282 })
283
284 url.push({
285 type: 'Link',
286 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
287 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
288 href: playlist.playlistUrl,
289 tag
290 })
291 }
292
233 // Add video url too 293 // Add video url too
234 url.push({ 294 url.push({
235 type: 'Link', 295 type: 'Link',
236 mimeType: 'text/html', 296 mimeType: 'text/html',
297 mediaType: 'text/html',
237 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 298 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
238 }) 299 })
239 300
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index 8d442b3f8..c723e57c0 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -144,6 +144,10 @@ export class VideoImportModel extends Model<VideoImportModel> {
144 }) 144 })
145 } 145 }
146 146
147 getTargetIdentifier () {
148 return this.targetUrl || this.magnetUri || this.torrentName
149 }
150
147 toFormattedJSON (): VideoImport { 151 toFormattedJSON (): VideoImport {
148 const videoFormatOptions = { 152 const videoFormatOptions = {
149 completeDescription: true, 153 completeDescription: true,
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index fa9a70d50..c87f71277 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -88,7 +88,7 @@ export class VideoShareModel extends Model<VideoShareModel> {
88 }) 88 })
89 Video: VideoModel 89 Video: VideoModel
90 90
91 static load (actorId: number, videoId: number, t: Sequelize.Transaction) { 91 static load (actorId: number, videoId: number, t?: Sequelize.Transaction) {
92 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({ 92 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
93 where: { 93 where: {
94 actorId, 94 actorId,
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644
index 000000000..bf6f7b0c4
--- /dev/null
+++ b/server/models/video/video-streaming-playlist.ts
@@ -0,0 +1,158 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
3import { throwIfNotValid } from '../utils'
4import { VideoModel } from './video'
5import * as Sequelize from 'sequelize'
6import { VideoRedundancyModel } from '../redundancy/video-redundancy'
7import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9import { CONSTRAINTS_FIELDS, STATIC_PATHS } from '../../initializers'
10import { VideoFileModel } from './video-file'
11import { join } from 'path'
12import { sha1 } from '../../helpers/core-utils'
13import { isArrayOf } from '../../helpers/custom-validators/misc'
14
15@Table({
16 tableName: 'videoStreamingPlaylist',
17 indexes: [
18 {
19 fields: [ 'videoId' ]
20 },
21 {
22 fields: [ 'videoId', 'type' ],
23 unique: true
24 },
25 {
26 fields: [ 'p2pMediaLoaderInfohashes' ],
27 using: 'gin'
28 }
29 ]
30})
31export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistModel> {
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @AllowNull(false)
39 @Column
40 type: VideoStreamingPlaylistType
41
42 @AllowNull(false)
43 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url'))
44 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
45 playlistUrl: string
46
47 @AllowNull(false)
48 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
49 @Column(DataType.ARRAY(DataType.STRING))
50 p2pMediaLoaderInfohashes: string[]
51
52 @AllowNull(false)
53 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
54 @Column
55 segmentsSha256Url: string
56
57 @ForeignKey(() => VideoModel)
58 @Column
59 videoId: number
60
61 @BelongsTo(() => VideoModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'CASCADE'
66 })
67 Video: VideoModel
68
69 @HasMany(() => VideoRedundancyModel, {
70 foreignKey: {
71 allowNull: false
72 },
73 onDelete: 'CASCADE',
74 hooks: true
75 })
76 RedundancyVideos: VideoRedundancyModel[]
77
78 static doesInfohashExist (infoHash: string) {
79 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
80 const options = {
81 type: Sequelize.QueryTypes.SELECT,
82 bind: { infoHash },
83 raw: true
84 }
85
86 return VideoModel.sequelize.query(query, options)
87 .then(results => {
88 return results.length === 1
89 })
90 }
91
92 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
93 const hashes: string[] = []
94
95 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L97
96 for (let i = 0; i < videoFiles.length; i++) {
97 hashes.push(sha1(`1${playlistUrl}+V${i}`))
98 }
99
100 return hashes
101 }
102
103 static loadWithVideo (id: number) {
104 const options = {
105 include: [
106 {
107 model: VideoModel.unscoped(),
108 required: true
109 }
110 ]
111 }
112
113 return VideoStreamingPlaylistModel.findById(id, options)
114 }
115
116 static getHlsPlaylistFilename (resolution: number) {
117 return resolution + '.m3u8'
118 }
119
120 static getMasterHlsPlaylistFilename () {
121 return 'master.m3u8'
122 }
123
124 static getHlsSha256SegmentsFilename () {
125 return 'segments-sha256.json'
126 }
127
128 static getHlsVideoName (uuid: string, resolution: number) {
129 return `${uuid}-${resolution}-fragmented.mp4`
130 }
131
132 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
133 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
134 }
135
136 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
137 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
138 }
139
140 static getHlsSha256SegmentsStaticPath (videoUUID: string) {
141 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
142 }
143
144 getStringType () {
145 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
146
147 return 'unknown'
148 }
149
150 getVideoRedundancyUrl (baseUrlHttp: string) {
151 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
152 }
153
154 hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
155 return this.type === other.type &&
156 this.videoId === other.videoId
157 }
158}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index a9baaf1da..0feeed4f8 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -27,7 +27,7 @@ import {
27 Table, 27 Table,
28 UpdatedAt 28 UpdatedAt
29} from 'sequelize-typescript' 29} from 'sequelize-typescript'
30import { VideoPrivacy, VideoState } from '../../../shared' 30import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
33import { VideoFilter } from '../../../shared/models/videos/video-query.type' 33import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -52,7 +52,7 @@ import {
52 ACTIVITY_PUB, 52 ACTIVITY_PUB,
53 API_VERSION, 53 API_VERSION,
54 CONFIG, 54 CONFIG,
55 CONSTRAINTS_FIELDS, 55 CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
56 PREVIEWS_SIZE, 56 PREVIEWS_SIZE,
57 REMOTE_SCHEME, 57 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 58 STATIC_DOWNLOAD_PATHS,
@@ -70,7 +70,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
70import { ActorModel } from '../activitypub/actor' 70import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 71import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 72import { ServerModel } from '../server/server'
73import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
74import { TagModel } from './tag' 74import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 75import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 76import { VideoChannelModel } from './video-channel'
@@ -93,6 +93,9 @@ import {
93} from './video-format-utils' 93} from './video-format-utils'
94import * as validator from 'validator' 94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 95import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
96 99
97// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 100// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
98const indexes: Sequelize.DefineIndexesOptions[] = [ 101const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -101,17 +104,45 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
101 { fields: [ 'createdAt' ] }, 104 { fields: [ 'createdAt' ] },
102 { fields: [ 'publishedAt' ] }, 105 { fields: [ 'publishedAt' ] },
103 { fields: [ 'duration' ] }, 106 { fields: [ 'duration' ] },
104 { fields: [ 'category' ] },
105 { fields: [ 'licence' ] },
106 { fields: [ 'nsfw' ] },
107 { fields: [ 'language' ] },
108 { fields: [ 'waitTranscoding' ] },
109 { fields: [ 'state' ] },
110 { fields: [ 'remote' ] },
111 { fields: [ 'views' ] }, 107 { fields: [ 'views' ] },
112 { fields: [ 'likes' ] },
113 { fields: [ 'channelId' ] }, 108 { fields: [ 'channelId' ] },
114 { 109 {
110 fields: [ 'category' ], // We don't care videos with an unknown category
111 where: {
112 category: {
113 [Sequelize.Op.ne]: null
114 }
115 }
116 },
117 {
118 fields: [ 'licence' ], // We don't care videos with an unknown licence
119 where: {
120 licence: {
121 [Sequelize.Op.ne]: null
122 }
123 }
124 },
125 {
126 fields: [ 'language' ], // We don't care videos with an unknown language
127 where: {
128 language: {
129 [Sequelize.Op.ne]: null
130 }
131 }
132 },
133 {
134 fields: [ 'nsfw' ], // Most of the videos are not NSFW
135 where: {
136 nsfw: true
137 }
138 },
139 {
140 fields: [ 'remote' ], // Only index local videos
141 where: {
142 remote: false
143 }
144 },
145 {
115 fields: [ 'uuid' ], 146 fields: [ 'uuid' ],
116 unique: true 147 unique: true
117 }, 148 },
@@ -129,7 +160,9 @@ export enum ScopeNames {
129 WITH_FILES = 'WITH_FILES', 160 WITH_FILES = 'WITH_FILES',
130 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 161 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
131 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 162 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
132 WITH_USER_HISTORY = 'WITH_USER_HISTORY' 163 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
164 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
165 WITH_USER_ID = 'WITH_USER_ID'
133} 166}
134 167
135type ForAPIOptions = { 168type ForAPIOptions = {
@@ -138,7 +171,8 @@ type ForAPIOptions = {
138} 171}
139 172
140type AvailableForListIDsOptions = { 173type AvailableForListIDsOptions = {
141 actorId: number 174 serverAccountId: number
175 followerActorId: number
142 includeLocalVideos: boolean 176 includeLocalVideos: boolean
143 filter?: VideoFilter 177 filter?: VideoFilter
144 categoryOneOf?: number[] 178 categoryOneOf?: number[]
@@ -151,6 +185,8 @@ type AvailableForListIDsOptions = {
151 accountId?: number 185 accountId?: number
152 videoChannelId?: number 186 videoChannelId?: number
153 trendingDays?: number 187 trendingDays?: number
188 user?: UserModel,
189 historyOfUser?: UserModel
154} 190}
155 191
156@Scopes({ 192@Scopes({
@@ -236,6 +272,22 @@ type AvailableForListIDsOptions = {
236 } 272 }
237 ] 273 ]
238 }, 274 },
275 channelId: {
276 [ Sequelize.Op.notIn ]: Sequelize.literal(
277 '(' +
278 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
279 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
280 ')' +
281 ')'
282 )
283 }
284 },
285 include: []
286 }
287
288 // Only list public/published videos
289 if (!options.filter || options.filter !== 'all-local') {
290 const privacyWhere = {
239 // Always list public videos 291 // Always list public videos
240 privacy: VideoPrivacy.PUBLIC, 292 privacy: VideoPrivacy.PUBLIC,
241 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding 293 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
@@ -250,8 +302,9 @@ type AvailableForListIDsOptions = {
250 } 302 }
251 } 303 }
252 ] 304 ]
253 }, 305 }
254 include: [] 306
307 Object.assign(query.where, privacyWhere)
255 } 308 }
256 309
257 if (options.filter || options.accountId || options.videoChannelId) { 310 if (options.filter || options.accountId || options.videoChannelId) {
@@ -295,7 +348,7 @@ type AvailableForListIDsOptions = {
295 query.include.push(videoChannelInclude) 348 query.include.push(videoChannelInclude)
296 } 349 }
297 350
298 if (options.actorId) { 351 if (options.followerActorId) {
299 let localVideosReq = '' 352 let localVideosReq = ''
300 if (options.includeLocalVideos === true) { 353 if (options.includeLocalVideos === true) {
301 localVideosReq = ' UNION ALL ' + 354 localVideosReq = ' UNION ALL ' +
@@ -307,7 +360,7 @@ type AvailableForListIDsOptions = {
307 } 360 }
308 361
309 // Force actorId to be a number to avoid SQL injections 362 // Force actorId to be a number to avoid SQL injections
310 const actorIdNumber = parseInt(options.actorId.toString(), 10) 363 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
311 query.where[ 'id' ][ Sequelize.Op.and ].push({ 364 query.where[ 'id' ][ Sequelize.Op.and ].push({
312 [ Sequelize.Op.in ]: Sequelize.literal( 365 [ Sequelize.Op.in ]: Sequelize.literal(
313 '(' + 366 '(' +
@@ -396,8 +449,39 @@ type AvailableForListIDsOptions = {
396 query.subQuery = false 449 query.subQuery = false
397 } 450 }
398 451
452 if (options.historyOfUser) {
453 query.include.push({
454 model: UserVideoHistoryModel,
455 required: true,
456 where: {
457 userId: options.historyOfUser.id
458 }
459 })
460
461 // Even if the relation is n:m, we know that a user only have 0..1 video history
462 // So we won't have multiple rows for the same video
463 // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel
464 query.subQuery = false
465 }
466
399 return query 467 return query
400 }, 468 },
469 [ ScopeNames.WITH_USER_ID ]: {
470 include: [
471 {
472 attributes: [ 'accountId' ],
473 model: () => VideoChannelModel.unscoped(),
474 required: true,
475 include: [
476 {
477 attributes: [ 'userId' ],
478 model: () => AccountModel.unscoped(),
479 required: true
480 }
481 ]
482 }
483 ]
484 },
401 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 485 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
402 include: [ 486 include: [
403 { 487 {
@@ -462,22 +546,55 @@ type AvailableForListIDsOptions = {
462 } 546 }
463 ] 547 ]
464 }, 548 },
465 [ ScopeNames.WITH_FILES ]: { 549 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
466 include: [ 550 let subInclude: any[] = []
467 { 551
468 model: () => VideoFileModel.unscoped(), 552 if (withRedundancies === true) {
469 // FIXME: typings 553 subInclude = [
470 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 554 {
471 required: false, 555 attributes: [ 'fileUrl' ],
472 include: [ 556 model: VideoRedundancyModel.unscoped(),
473 { 557 required: false
474 attributes: [ 'fileUrl' ], 558 }
475 model: () => VideoRedundancyModel.unscoped(), 559 ]
476 required: false 560 }
477 } 561
478 ] 562 return {
479 } 563 include: [
480 ] 564 {
565 model: VideoFileModel.unscoped(),
566 // FIXME: typings
567 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
568 required: false,
569 include: subInclude
570 }
571 ]
572 }
573 },
574 [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
575 let subInclude: any[] = []
576
577 if (withRedundancies === true) {
578 subInclude = [
579 {
580 attributes: [ 'fileUrl' ],
581 model: VideoRedundancyModel.unscoped(),
582 required: false
583 }
584 ]
585 }
586
587 return {
588 include: [
589 {
590 model: VideoStreamingPlaylistModel.unscoped(),
591 // FIXME: typings
592 [ 'separate' as any ]: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
593 required: false,
594 include: subInclude
595 }
596 ]
597 }
481 }, 598 },
482 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 599 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
483 include: [ 600 include: [
@@ -661,6 +778,16 @@ export class VideoModel extends Model<VideoModel> {
661 }) 778 })
662 VideoFiles: VideoFileModel[] 779 VideoFiles: VideoFileModel[]
663 780
781 @HasMany(() => VideoStreamingPlaylistModel, {
782 foreignKey: {
783 name: 'videoId',
784 allowNull: false
785 },
786 hooks: true,
787 onDelete: 'cascade'
788 })
789 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
790
664 @HasMany(() => VideoShareModel, { 791 @HasMany(() => VideoShareModel, {
665 foreignKey: { 792 foreignKey: {
666 name: 'videoId', 793 name: 'videoId',
@@ -725,6 +852,15 @@ export class VideoModel extends Model<VideoModel> {
725 }) 852 })
726 VideoBlacklist: VideoBlacklistModel 853 VideoBlacklist: VideoBlacklistModel
727 854
855 @HasOne(() => VideoImportModel, {
856 foreignKey: {
857 name: 'videoId',
858 allowNull: true
859 },
860 onDelete: 'set null'
861 })
862 VideoImport: VideoImportModel
863
728 @HasMany(() => VideoCaptionModel, { 864 @HasMany(() => VideoCaptionModel, {
729 foreignKey: { 865 foreignKey: {
730 name: 'videoId', 866 name: 'videoId',
@@ -777,6 +913,9 @@ export class VideoModel extends Model<VideoModel> {
777 tasks.push(instance.removeFile(file)) 913 tasks.push(instance.removeFile(file))
778 tasks.push(instance.removeTorrent(file)) 914 tasks.push(instance.removeTorrent(file))
779 }) 915 })
916
917 // Remove playlists file
918 tasks.push(instance.removeStreamingPlaylist())
780 } 919 }
781 920
782 // Do not wait video deletion because we could be in a transaction 921 // Do not wait video deletion because we could be in a transaction
@@ -788,8 +927,14 @@ export class VideoModel extends Model<VideoModel> {
788 return undefined 927 return undefined
789 } 928 }
790 929
791 static list () { 930 static listLocal () {
792 return VideoModel.scope(ScopeNames.WITH_FILES).findAll() 931 const query = {
932 where: {
933 remote: false
934 }
935 }
936
937 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
793 } 938 }
794 939
795 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 940 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -959,10 +1104,15 @@ export class VideoModel extends Model<VideoModel> {
959 filter?: VideoFilter, 1104 filter?: VideoFilter,
960 accountId?: number, 1105 accountId?: number,
961 videoChannelId?: number, 1106 videoChannelId?: number,
962 actorId?: number 1107 followerActorId?: number
963 trendingDays?: number, 1108 trendingDays?: number,
964 userId?: number 1109 user?: UserModel,
1110 historyOfUser?: UserModel
965 }, countVideos = true) { 1111 }, countVideos = true) {
1112 if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1113 throw new Error('Try to filter all-local but no user has not the see all videos right')
1114 }
1115
966 const query: IFindOptions<VideoModel> = { 1116 const query: IFindOptions<VideoModel> = {
967 offset: options.start, 1117 offset: options.start,
968 limit: options.count, 1118 limit: options.count,
@@ -976,11 +1126,14 @@ export class VideoModel extends Model<VideoModel> {
976 query.group = 'VideoModel.id' 1126 query.group = 'VideoModel.id'
977 } 1127 }
978 1128
979 // actorId === null has a meaning, so just check undefined 1129 const serverActor = await getServerActor()
980 const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id 1130
1131 // followerActorId === null has a meaning, so just check undefined
1132 const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
981 1133
982 const queryOptions = { 1134 const queryOptions = {
983 actorId, 1135 followerActorId,
1136 serverAccountId: serverActor.Account.id,
984 nsfw: options.nsfw, 1137 nsfw: options.nsfw,
985 categoryOneOf: options.categoryOneOf, 1138 categoryOneOf: options.categoryOneOf,
986 licenceOneOf: options.licenceOneOf, 1139 licenceOneOf: options.licenceOneOf,
@@ -992,7 +1145,8 @@ export class VideoModel extends Model<VideoModel> {
992 accountId: options.accountId, 1145 accountId: options.accountId,
993 videoChannelId: options.videoChannelId, 1146 videoChannelId: options.videoChannelId,
994 includeLocalVideos: options.includeLocalVideos, 1147 includeLocalVideos: options.includeLocalVideos,
995 userId: options.userId, 1148 user: options.user,
1149 historyOfUser: options.historyOfUser,
996 trendingDays 1150 trendingDays
997 } 1151 }
998 1152
@@ -1015,7 +1169,8 @@ export class VideoModel extends Model<VideoModel> {
1015 tagsAllOf?: string[] 1169 tagsAllOf?: string[]
1016 durationMin?: number // seconds 1170 durationMin?: number // seconds
1017 durationMax?: number // seconds 1171 durationMax?: number // seconds
1018 userId?: number 1172 user?: UserModel,
1173 filter?: VideoFilter
1019 }) { 1174 }) {
1020 const whereAnd = [] 1175 const whereAnd = []
1021 1176
@@ -1084,7 +1239,8 @@ export class VideoModel extends Model<VideoModel> {
1084 1239
1085 const serverActor = await getServerActor() 1240 const serverActor = await getServerActor()
1086 const queryOptions = { 1241 const queryOptions = {
1087 actorId: serverActor.id, 1242 followerActorId: serverActor.id,
1243 serverAccountId: serverActor.Account.id,
1088 includeLocalVideos: options.includeLocalVideos, 1244 includeLocalVideos: options.includeLocalVideos,
1089 nsfw: options.nsfw, 1245 nsfw: options.nsfw,
1090 categoryOneOf: options.categoryOneOf, 1246 categoryOneOf: options.categoryOneOf,
@@ -1092,7 +1248,8 @@ export class VideoModel extends Model<VideoModel> {
1092 languageOneOf: options.languageOneOf, 1248 languageOneOf: options.languageOneOf,
1093 tagsOneOf: options.tagsOneOf, 1249 tagsOneOf: options.tagsOneOf,
1094 tagsAllOf: options.tagsAllOf, 1250 tagsAllOf: options.tagsAllOf,
1095 userId: options.userId 1251 user: options.user,
1252 filter: options.filter
1096 } 1253 }
1097 1254
1098 return VideoModel.getAvailableForApi(query, queryOptions) 1255 return VideoModel.getAvailableForApi(query, queryOptions)
@@ -1108,6 +1265,16 @@ export class VideoModel extends Model<VideoModel> {
1108 return VideoModel.findOne(options) 1265 return VideoModel.findOne(options)
1109 } 1266 }
1110 1267
1268 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
1269 const where = VideoModel.buildWhereIdOrUUID(id)
1270 const options = {
1271 where,
1272 transaction: t
1273 }
1274
1275 return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
1276 }
1277
1111 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1278 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1112 const where = VideoModel.buildWhereIdOrUUID(id) 1279 const where = VideoModel.buildWhereIdOrUUID(id)
1113 1280
@@ -1120,8 +1287,8 @@ export class VideoModel extends Model<VideoModel> {
1120 return VideoModel.findOne(options) 1287 return VideoModel.findOne(options)
1121 } 1288 }
1122 1289
1123 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1290 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1124 return VideoModel.scope(ScopeNames.WITH_FILES) 1291 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
1125 .findById(id, { transaction: t, logging }) 1292 .findById(id, { transaction: t, logging })
1126 } 1293 }
1127 1294
@@ -1132,9 +1299,7 @@ export class VideoModel extends Model<VideoModel> {
1132 } 1299 }
1133 } 1300 }
1134 1301
1135 return VideoModel 1302 return VideoModel.findOne(options)
1136 .scope([ ScopeNames.WITH_FILES ])
1137 .findOne(options)
1138 } 1303 }
1139 1304
1140 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1305 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1156,7 +1321,11 @@ export class VideoModel extends Model<VideoModel> {
1156 transaction 1321 transaction
1157 } 1322 }
1158 1323
1159 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1324 return VideoModel.scope([
1325 ScopeNames.WITH_ACCOUNT_DETAILS,
1326 ScopeNames.WITH_FILES,
1327 ScopeNames.WITH_STREAMING_PLAYLISTS
1328 ]).findOne(query)
1160 } 1329 }
1161 1330
1162 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1331 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
@@ -1171,9 +1340,37 @@ export class VideoModel extends Model<VideoModel> {
1171 const scopes = [ 1340 const scopes = [
1172 ScopeNames.WITH_TAGS, 1341 ScopeNames.WITH_TAGS,
1173 ScopeNames.WITH_BLACKLISTED, 1342 ScopeNames.WITH_BLACKLISTED,
1343 ScopeNames.WITH_ACCOUNT_DETAILS,
1344 ScopeNames.WITH_SCHEDULED_UPDATE,
1174 ScopeNames.WITH_FILES, 1345 ScopeNames.WITH_FILES,
1346 ScopeNames.WITH_STREAMING_PLAYLISTS
1347 ]
1348
1349 if (userId) {
1350 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
1351 }
1352
1353 return VideoModel
1354 .scope(scopes)
1355 .findOne(options)
1356 }
1357
1358 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1359 const where = VideoModel.buildWhereIdOrUUID(id)
1360
1361 const options = {
1362 order: [ [ 'Tags', 'name', 'ASC' ] ],
1363 where,
1364 transaction: t
1365 }
1366
1367 const scopes = [
1368 ScopeNames.WITH_TAGS,
1369 ScopeNames.WITH_BLACKLISTED,
1175 ScopeNames.WITH_ACCOUNT_DETAILS, 1370 ScopeNames.WITH_ACCOUNT_DETAILS,
1176 ScopeNames.WITH_SCHEDULED_UPDATE 1371 ScopeNames.WITH_SCHEDULED_UPDATE,
1372 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1373 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
1177 ] 1374 ]
1178 1375
1179 if (userId) { 1376 if (userId) {
@@ -1217,12 +1414,31 @@ export class VideoModel extends Model<VideoModel> {
1217 }) 1414 })
1218 } 1415 }
1219 1416
1417 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1418 // Instances only share videos
1419 const query = 'SELECT 1 FROM "videoShare" ' +
1420 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1421 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1422 'LIMIT 1'
1423
1424 const options = {
1425 type: Sequelize.QueryTypes.SELECT,
1426 bind: { followerActorId, videoId },
1427 raw: true
1428 }
1429
1430 return VideoModel.sequelize.query(query, options)
1431 .then(results => results.length === 1)
1432 }
1433
1220 // threshold corresponds to how many video the field should have to be returned 1434 // threshold corresponds to how many video the field should have to be returned
1221 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { 1435 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1222 const actorId = (await getServerActor()).id 1436 const serverActor = await getServerActor()
1437 const followerActorId = serverActor.id
1223 1438
1224 const scopeOptions = { 1439 const scopeOptions: AvailableForListIDsOptions = {
1225 actorId, 1440 serverAccountId: serverActor.Account.id,
1441 followerActorId,
1226 includeLocalVideos: true 1442 includeLocalVideos: true
1227 } 1443 }
1228 1444
@@ -1256,7 +1472,7 @@ export class VideoModel extends Model<VideoModel> {
1256 } 1472 }
1257 1473
1258 private static buildActorWhereWithFilter (filter?: VideoFilter) { 1474 private static buildActorWhereWithFilter (filter?: VideoFilter) {
1259 if (filter && filter === 'local') { 1475 if (filter && (filter === 'local' || filter === 'all-local')) {
1260 return { 1476 return {
1261 serverId: null 1477 serverId: null
1262 } 1478 }
@@ -1267,7 +1483,7 @@ export class VideoModel extends Model<VideoModel> {
1267 1483
1268 private static async getAvailableForApi ( 1484 private static async getAvailableForApi (
1269 query: IFindOptions<VideoModel>, 1485 query: IFindOptions<VideoModel>,
1270 options: AvailableForListIDsOptions & { userId?: number}, 1486 options: AvailableForListIDsOptions,
1271 countVideos = true 1487 countVideos = true
1272 ) { 1488 ) {
1273 const idsScope = { 1489 const idsScope = {
@@ -1286,7 +1502,7 @@ export class VideoModel extends Model<VideoModel> {
1286 } 1502 }
1287 1503
1288 const [ count, rowsId ] = await Promise.all([ 1504 const [ count, rowsId ] = await Promise.all([
1289 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined), 1505 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
1290 VideoModel.scope(idsScope).findAll(query) 1506 VideoModel.scope(idsScope).findAll(query)
1291 ]) 1507 ])
1292 const ids = rowsId.map(r => r.id) 1508 const ids = rowsId.map(r => r.id)
@@ -1300,8 +1516,8 @@ export class VideoModel extends Model<VideoModel> {
1300 } 1516 }
1301 ] 1517 ]
1302 1518
1303 if (options.userId) { 1519 if (options.user) {
1304 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] }) 1520 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1305 } 1521 }
1306 1522
1307 const secondQuery = { 1523 const secondQuery = {
@@ -1426,6 +1642,10 @@ export class VideoModel extends Model<VideoModel> {
1426 videoFile.infoHash = parsedTorrent.infoHash 1642 videoFile.infoHash = parsedTorrent.infoHash
1427 } 1643 }
1428 1644
1645 getWatchStaticPath () {
1646 return '/videos/watch/' + this.uuid
1647 }
1648
1429 getEmbedStaticPath () { 1649 getEmbedStaticPath () {
1430 return '/videos/embed/' + this.uuid 1650 return '/videos/embed/' + this.uuid
1431 } 1651 }
@@ -1483,8 +1703,10 @@ export class VideoModel extends Model<VideoModel> {
1483 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err })) 1703 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1484 } 1704 }
1485 1705
1486 removeFile (videoFile: VideoFileModel) { 1706 removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1487 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) 1707 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1708
1709 const filePath = join(baseDir, this.getVideoFilename(videoFile))
1488 return remove(filePath) 1710 return remove(filePath)
1489 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err })) 1711 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1490 } 1712 }
@@ -1495,6 +1717,14 @@ export class VideoModel extends Model<VideoModel> {
1495 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1717 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1496 } 1718 }
1497 1719
1720 removeStreamingPlaylist (isRedundancy = false) {
1721 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY
1722
1723 const filePath = join(baseDir, this.uuid)
1724 return remove(filePath)
1725 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1726 }
1727
1498 isOutdated () { 1728 isOutdated () {
1499 if (this.isOwned()) return false 1729 if (this.isOwned()) return false
1500 1730
@@ -1506,6 +1736,12 @@ export class VideoModel extends Model<VideoModel> {
1506 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL 1736 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
1507 } 1737 }
1508 1738
1739 setAsRefreshed () {
1740 this.changed('updatedAt', true)
1741
1742 return this.save()
1743 }
1744
1509 getBaseUrls () { 1745 getBaseUrls () {
1510 let baseUrlHttp 1746 let baseUrlHttp
1511 let baseUrlWs 1747 let baseUrlWs
@@ -1523,7 +1759,7 @@ export class VideoModel extends Model<VideoModel> {
1523 1759
1524 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1760 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1525 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1761 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1526 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1762 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1527 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1763 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1528 1764
1529 const redundancies = videoFile.RedundancyVideos 1765 const redundancies = videoFile.RedundancyVideos
@@ -1540,6 +1776,10 @@ export class VideoModel extends Model<VideoModel> {
1540 return magnetUtil.encode(magnetHash) 1776 return magnetUtil.encode(magnetHash)
1541 } 1777 }
1542 1778
1779 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1780 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1781 }
1782
1543 getThumbnailUrl (baseUrlHttp: string) { 1783 getThumbnailUrl (baseUrlHttp: string) {
1544 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1784 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1545 } 1785 }
@@ -1556,7 +1796,15 @@ export class VideoModel extends Model<VideoModel> {
1556 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) 1796 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1557 } 1797 }
1558 1798
1799 getVideoRedundancyUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1800 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getVideoFilename(videoFile)
1801 }
1802
1559 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1803 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1560 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1804 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1561 } 1805 }
1806
1807 getBandwidthBits (videoFile: VideoFileModel) {
1808 return Math.ceil((videoFile.size * 8) / this.duration)
1809 }
1562} 1810}
diff --git a/server/tests/activitypub.ts b/server/tests/activitypub.ts
deleted file mode 100644
index 53a04d363..000000000
--- a/server/tests/activitypub.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { flushTests, killallServers, makeActivityPubGetRequest, runServer, ServerInfo, setAccessTokensToServers } from './utils'
6
7const expect = chai.expect
8
9describe('Test activitypub', function () {
10 let server: ServerInfo = null
11
12 before(async function () {
13 this.timeout(30000)
14
15 await flushTests()
16
17 server = await runServer(1)
18
19 await setAccessTokensToServers([ server ])
20 })
21
22 it('Should return the account object', async function () {
23 const res = await makeActivityPubGetRequest(server.url, '/accounts/root')
24 const object = res.body
25
26 expect(object.type).to.equal('Person')
27 expect(object.id).to.equal('http://localhost:9001/accounts/root')
28 expect(object.name).to.equal('root')
29 expect(object.preferredUsername).to.equal('root')
30 })
31
32 after(async function () {
33 killallServers([ server ])
34 })
35})
diff --git a/server/tests/api/activitypub/client.ts b/server/tests/api/activitypub/client.ts
new file mode 100644
index 000000000..6d90d8643
--- /dev/null
+++ b/server/tests/api/activitypub/client.ts
@@ -0,0 +1,67 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 doubleFollow,
7 flushAndRunMultipleServers,
8 flushTests,
9 killallServers,
10 makeActivityPubGetRequest,
11 ServerInfo,
12 setAccessTokensToServers,
13 uploadVideo
14} from '../../../../shared/utils'
15
16const expect = chai.expect
17
18describe('Test activitypub', function () {
19 let servers: ServerInfo[] = []
20 let videoUUID: string
21
22 before(async function () {
23 this.timeout(30000)
24
25 await flushTests()
26
27 servers = await flushAndRunMultipleServers(2)
28
29 await setAccessTokensToServers(servers)
30
31 {
32 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video' })
33 videoUUID = res.body.video.uuid
34 }
35
36 await doubleFollow(servers[0], servers[1])
37 })
38
39 it('Should return the account object', async function () {
40 const res = await makeActivityPubGetRequest(servers[0].url, '/accounts/root')
41 const object = res.body
42
43 expect(object.type).to.equal('Person')
44 expect(object.id).to.equal('http://localhost:9001/accounts/root')
45 expect(object.name).to.equal('root')
46 expect(object.preferredUsername).to.equal('root')
47 })
48
49 it('Should return the video object', async function () {
50 const res = await makeActivityPubGetRequest(servers[0].url, '/videos/watch/' + videoUUID)
51 const object = res.body
52
53 expect(object.type).to.equal('Video')
54 expect(object.id).to.equal('http://localhost:9001/videos/watch/' + videoUUID)
55 expect(object.name).to.equal('video')
56 })
57
58 it('Should redirect to the origin video object', async function () {
59 const res = await makeActivityPubGetRequest(servers[1].url, '/videos/watch/' + videoUUID, 302)
60
61 expect(res.header.location).to.equal('http://localhost:9001/videos/watch/' + videoUUID)
62 })
63
64 after(async function () {
65 killallServers(servers)
66 })
67})
diff --git a/server/tests/api/activitypub/fetch.ts b/server/tests/api/activitypub/fetch.ts
new file mode 100644
index 000000000..03609c1a9
--- /dev/null
+++ b/server/tests/api/activitypub/fetch.ts
@@ -0,0 +1,87 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4
5import {
6 createUser,
7 doubleFollow,
8 flushAndRunMultipleServers,
9 flushTests,
10 getVideosListSort,
11 killallServers,
12 ServerInfo,
13 setAccessTokensToServers,
14 setActorField,
15 setVideoField,
16 uploadVideo,
17 userLogin,
18 waitJobs
19} from '../../../../shared/utils'
20import * as chai from 'chai'
21import { Video } from '../../../../shared/models/videos'
22
23const expect = chai.expect
24
25describe('Test ActivityPub fetcher', function () {
26 let servers: ServerInfo[]
27
28 // ---------------------------------------------------------------
29
30 before(async function () {
31 this.timeout(60000)
32
33 servers = await flushAndRunMultipleServers(3)
34
35 // Get the access tokens
36 await setAccessTokensToServers(servers)
37
38 const user = { username: 'user1', password: 'password' }
39 for (const server of servers) {
40 await createUser(server.url, server.accessToken, user.username, user.password)
41 }
42
43 const userAccessToken = await userLogin(servers[0], user)
44
45 await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video root' })
46 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'bad video root' })
47 const badVideoUUID = res.body.video.uuid
48 await uploadVideo(servers[0].url, userAccessToken, { name: 'video user' })
49
50 await setActorField(1, 'http://localhost:9001/accounts/user1', 'url', 'http://localhost:9002/accounts/user1')
51 await setVideoField(1, badVideoUUID, 'url', 'http://localhost:9003/videos/watch/' + badVideoUUID)
52 })
53
54 it('Should add only the video with a valid actor URL', async function () {
55 this.timeout(60000)
56
57 await doubleFollow(servers[0], servers[1])
58 await waitJobs(servers)
59
60 {
61 const res = await getVideosListSort(servers[0].url, 'createdAt')
62 expect(res.body.total).to.equal(3)
63
64 const data: Video[] = res.body.data
65 expect(data[0].name).to.equal('video root')
66 expect(data[1].name).to.equal('bad video root')
67 expect(data[2].name).to.equal('video user')
68 }
69
70 {
71 const res = await getVideosListSort(servers[1].url, 'createdAt')
72 expect(res.body.total).to.equal(1)
73
74 const data: Video[] = res.body.data
75 expect(data[0].name).to.equal('video root')
76 }
77 })
78
79 after(async function () {
80 killallServers(servers)
81
82 // Keep the logs if the test failed
83 if (this['ok']) {
84 await flushTests()
85 }
86 })
87})
diff --git a/server/tests/api/activitypub/helpers.ts b/server/tests/api/activitypub/helpers.ts
new file mode 100644
index 000000000..ac6e755c3
--- /dev/null
+++ b/server/tests/api/activitypub/helpers.ts
@@ -0,0 +1,182 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4import { expect } from 'chai'
5import { buildRequestStub } from '../../../../shared/utils/miscs/stubs'
6import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../../../helpers/peertube-crypto'
7import { cloneDeep } from 'lodash'
8import { buildSignedActivity } from '../../../helpers/activitypub'
9
10describe('Test activity pub helpers', function () {
11 describe('When checking the Linked Signature', function () {
12
13 it('Should fail with an invalid Mastodon signature', async function () {
14 const body = require('./json/mastodon/create-bad-signature.json')
15 const publicKey = require('./json/mastodon/public-key.json').publicKey
16 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
17
18 const result = await isJsonLDSignatureVerified(fromActor as any, body)
19
20 expect(result).to.be.false
21 })
22
23 it('Should fail with an invalid public key', async function () {
24 const body = require('./json/mastodon/create.json')
25 const publicKey = require('./json/mastodon/bad-public-key.json').publicKey
26 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
27
28 const result = await isJsonLDSignatureVerified(fromActor as any, body)
29
30 expect(result).to.be.false
31 })
32
33 it('Should succeed with a valid Mastodon signature', async function () {
34 const body = require('./json/mastodon/create.json')
35 const publicKey = require('./json/mastodon/public-key.json').publicKey
36 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
37
38 const result = await isJsonLDSignatureVerified(fromActor as any, body)
39
40 expect(result).to.be.true
41 })
42
43 it('Should fail with an invalid PeerTube signature', async function () {
44 const keys = require('./json/peertube/invalid-keys.json')
45 const body = require('./json/peertube/announce-without-context.json')
46
47 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
48 const signedBody = await buildSignedActivity(actorSignature as any, body)
49
50 const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' }
51 const result = await isJsonLDSignatureVerified(fromActor as any, signedBody)
52
53 expect(result).to.be.false
54 })
55
56 it('Should fail with an invalid PeerTube URL', async function () {
57 const keys = require('./json/peertube/keys.json')
58 const body = require('./json/peertube/announce-without-context.json')
59
60 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
61 const signedBody = await buildSignedActivity(actorSignature as any, body)
62
63 const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9003/accounts/peertube' }
64 const result = await isJsonLDSignatureVerified(fromActor as any, signedBody)
65
66 expect(result).to.be.false
67 })
68
69 it('Should succeed with a valid PeerTube signature', async function () {
70 const keys = require('./json/peertube/keys.json')
71 const body = require('./json/peertube/announce-without-context.json')
72
73 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
74 const signedBody = await buildSignedActivity(actorSignature as any, body)
75
76 const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' }
77 const result = await isJsonLDSignatureVerified(fromActor as any, signedBody)
78
79 expect(result).to.be.true
80 })
81 })
82
83 describe('When checking HTTP signature', function () {
84 it('Should fail with an invalid http signature', async function () {
85 const req = buildRequestStub()
86 req.method = 'POST'
87 req.url = '/accounts/ronan/inbox'
88
89 const mastodonObject = cloneDeep(require('./json/mastodon/bad-http-signature.json'))
90 req.body = mastodonObject.body
91 req.headers = mastodonObject.headers
92 req.headers.signature = 'Signature ' + req.headers.signature
93
94 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
95 const publicKey = require('./json/mastodon/public-key.json').publicKey
96
97 const actor = { publicKey }
98 const verified = isHTTPSignatureVerified(parsed, actor as any)
99
100 expect(verified).to.be.false
101 })
102
103 it('Should fail with an invalid public key', async function () {
104 const req = buildRequestStub()
105 req.method = 'POST'
106 req.url = '/accounts/ronan/inbox'
107
108 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
109 req.body = mastodonObject.body
110 req.headers = mastodonObject.headers
111 req.headers.signature = 'Signature ' + req.headers.signature
112
113 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
114 const publicKey = require('./json/mastodon/bad-public-key.json').publicKey
115
116 const actor = { publicKey }
117 const verified = isHTTPSignatureVerified(parsed, actor as any)
118
119 expect(verified).to.be.false
120 })
121
122 it('Should fail because of clock skew', async function () {
123 const req = buildRequestStub()
124 req.method = 'POST'
125 req.url = '/accounts/ronan/inbox'
126
127 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
128 req.body = mastodonObject.body
129 req.headers = mastodonObject.headers
130 req.headers.signature = 'Signature ' + req.headers.signature
131
132 let errored = false
133 try {
134 parseHTTPSignature(req)
135 } catch {
136 errored = true
137 }
138
139 expect(errored).to.be.true
140 })
141
142 it('Should fail without scheme', async function () {
143 const req = buildRequestStub()
144 req.method = 'POST'
145 req.url = '/accounts/ronan/inbox'
146
147 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
148 req.body = mastodonObject.body
149 req.headers = mastodonObject.headers
150
151 let errored = false
152 try {
153 parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
154 } catch {
155 errored = true
156 }
157
158 expect(errored).to.be.true
159 })
160
161 it('Should succeed with a valid signature', async function () {
162 const req = buildRequestStub()
163 req.method = 'POST'
164 req.url = '/accounts/ronan/inbox'
165
166 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
167 req.body = mastodonObject.body
168 req.headers = mastodonObject.headers
169 req.headers.signature = 'Signature ' + req.headers.signature
170
171 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
172 const publicKey = require('./json/mastodon/public-key.json').publicKey
173
174 const actor = { publicKey }
175 const verified = isHTTPSignatureVerified(parsed, actor as any)
176
177 expect(verified).to.be.true
178 })
179
180 })
181
182})
diff --git a/server/tests/api/activitypub/index.ts b/server/tests/api/activitypub/index.ts
new file mode 100644
index 000000000..450053309
--- /dev/null
+++ b/server/tests/api/activitypub/index.ts
@@ -0,0 +1,5 @@
1import './client'
2import './fetch'
3import './helpers'
4import './refresher'
5import './security'
diff --git a/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json b/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json
new file mode 100644
index 000000000..4e7bc3af5
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json
@@ -0,0 +1,93 @@
1{
2 "headers": {
3 "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)",
4 "host": "localhost",
5 "date": "Mon, 22 Oct 2018 13:34:22 GMT",
6 "accept-encoding": "gzip",
7 "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=",
8 "content-type": "application/activity+json",
9 "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl5wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"",
10 "content-length": "2815"
11 },
12 "body": {
13 "@context": [
14 "https://www.w3.org/ns/activitystreams",
15 "https://w3id.org/security/v1",
16 {
17 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
18 "sensitive": "as:sensitive",
19 "movedTo": {
20 "@id": "as:movedTo",
21 "@type": "@id"
22 },
23 "Hashtag": "as:Hashtag",
24 "ostatus": "http://ostatus.org#",
25 "atomUri": "ostatus:atomUri",
26 "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
27 "conversation": "ostatus:conversation",
28 "toot": "http://joinmastodon.org/ns#",
29 "Emoji": "toot:Emoji",
30 "focalPoint": {
31 "@container": "@list",
32 "@id": "toot:focalPoint"
33 },
34 "featured": {
35 "@id": "toot:featured",
36 "@type": "@id"
37 },
38 "schema": "http://schema.org#",
39 "PropertyValue": "schema:PropertyValue",
40 "value": "schema:value"
41 }
42 ],
43 "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity",
44 "type": "Create",
45 "actor": "http://localhost:3000/users/ronan2",
46 "published": "2018-10-22T13:34:18Z",
47 "to": [
48 "https://www.w3.org/ns/activitystreams#Public"
49 ],
50 "cc": [
51 "http://localhost:3000/users/ronan2/followers",
52 "http://localhost:9000/accounts/ronan"
53 ],
54 "object": {
55 "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
56 "type": "Note",
57 "summary": null,
58 "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
59 "published": "2018-10-22T13:34:18Z",
60 "url": "http://localhost:3000/@ronan2/100939547203370948",
61 "attributedTo": "http://localhost:3000/users/ronan2",
62 "to": [
63 "https://www.w3.org/ns/activitystreams#Public"
64 ],
65 "cc": [
66 "http://localhost:3000/users/ronan2/followers",
67 "http://localhost:9000/accounts/ronan"
68 ],
69 "sensitive": false,
70 "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
71 "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
72 "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
73 "content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>",
74 "contentMap": {
75 "en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>"
76 },
77 "attachment": [],
78 "tag": [
79 {
80 "type": "Mention",
81 "href": "http://localhost:9000/accounts/ronan",
82 "name": "@ronan@localhost:9000"
83 }
84 ]
85 },
86 "signature": {
87 "type": "RsaSignature2017",
88 "creator": "http://localhost:3000/users/ronan2#main-key",
89 "created": "2018-10-22T13:34:19Z",
90 "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q=="
91 }
92 }
93}
diff --git a/server/tests/api/activitypub/json/mastodon/bad-http-signature.json b/server/tests/api/activitypub/json/mastodon/bad-http-signature.json
new file mode 100644
index 000000000..098597db0
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/bad-http-signature.json
@@ -0,0 +1,93 @@
1{
2 "headers": {
3 "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)",
4 "host": "localhost",
5 "date": "Mon, 22 Oct 2018 13:34:22 GMT",
6 "accept-encoding": "gzip",
7 "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=",
8 "content-type": "application/activity+json",
9 "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl4wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"",
10 "content-length": "2815"
11 },
12 "body": {
13 "@context": [
14 "https://www.w3.org/ns/activitystreams",
15 "https://w3id.org/security/v1",
16 {
17 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
18 "sensitive": "as:sensitive",
19 "movedTo": {
20 "@id": "as:movedTo",
21 "@type": "@id"
22 },
23 "Hashtag": "as:Hashtag",
24 "ostatus": "http://ostatus.org#",
25 "atomUri": "ostatus:atomUri",
26 "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
27 "conversation": "ostatus:conversation",
28 "toot": "http://joinmastodon.org/ns#",
29 "Emoji": "toot:Emoji",
30 "focalPoint": {
31 "@container": "@list",
32 "@id": "toot:focalPoint"
33 },
34 "featured": {
35 "@id": "toot:featured",
36 "@type": "@id"
37 },
38 "schema": "http://schema.org#",
39 "PropertyValue": "schema:PropertyValue",
40 "value": "schema:value"
41 }
42 ],
43 "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity",
44 "type": "Create",
45 "actor": "http://localhost:3000/users/ronan2",
46 "published": "2018-10-22T13:34:18Z",
47 "to": [
48 "https://www.w3.org/ns/activitystreams#Public"
49 ],
50 "cc": [
51 "http://localhost:3000/users/ronan2/followers",
52 "http://localhost:9000/accounts/ronan"
53 ],
54 "object": {
55 "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
56 "type": "Note",
57 "summary": null,
58 "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
59 "published": "2018-10-22T13:34:18Z",
60 "url": "http://localhost:3000/@ronan2/100939547203370948",
61 "attributedTo": "http://localhost:3000/users/ronan2",
62 "to": [
63 "https://www.w3.org/ns/activitystreams#Public"
64 ],
65 "cc": [
66 "http://localhost:3000/users/ronan2/followers",
67 "http://localhost:9000/accounts/ronan"
68 ],
69 "sensitive": false,
70 "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
71 "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
72 "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
73 "content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>",
74 "contentMap": {
75 "en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>"
76 },
77 "attachment": [],
78 "tag": [
79 {
80 "type": "Mention",
81 "href": "http://localhost:9000/accounts/ronan",
82 "name": "@ronan@localhost:9000"
83 }
84 ]
85 },
86 "signature": {
87 "type": "RsaSignature2017",
88 "creator": "http://localhost:3000/users/ronan2#main-key",
89 "created": "2018-10-22T13:34:19Z",
90 "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q=="
91 }
92 }
93}
diff --git a/server/tests/api/activitypub/json/mastodon/bad-public-key.json b/server/tests/api/activitypub/json/mastodon/bad-public-key.json
new file mode 100644
index 000000000..73d18b3ad
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/bad-public-key.json
@@ -0,0 +1,3 @@
1{
2 "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YyuthHtWWgDe0Fdgdp2\ndC5dTJsRqW6pFw5omIYYYjoES/WRewhVxEA54BhmxD3L1zChfx131N1TS8jVowhW\nm999jpUffKCCvLgYKIXETJDHiDeMONVx8wp7v9fS1HiFXo/E5und39gUMs14CMFZ\n6PE5jRV3r4XIKQJHQl7/X5n5FOb2934K+1TKUeBkbft/AushlKatYQakt3qHxpwx\nFvE+JjGo7QTnzdjaOx/e5QvojdGi2Kx4+jl77j2WVcSo5lOBz04OAVJtChtn82vS\nulPdDh3hZcDn+WK67yAhGP6AnzvOybZZS4zowlKiQ3kqjVVXKdl8gAsL4Y7MZ40R\nJQIDAQAB\n-----END PUBLIC KEY-----\n"
3}
diff --git a/server/tests/api/activitypub/json/mastodon/create-bad-signature.json b/server/tests/api/activitypub/json/mastodon/create-bad-signature.json
new file mode 100644
index 000000000..2cd037241
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/create-bad-signature.json
@@ -0,0 +1,81 @@
1{
2 "@context": [
3 "https://www.w3.org/ns/activitystreams",
4 "https://w3id.org/security/v1",
5 {
6 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
7 "sensitive": "as:sensitive",
8 "movedTo": {
9 "@id": "as:movedTo",
10 "@type": "@id"
11 },
12 "Hashtag": "as:Hashtag",
13 "ostatus": "http://ostatus.org#",
14 "atomUri": "ostatus:atomUri",
15 "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
16 "conversation": "ostatus:conversation",
17 "toot": "http://joinmastodon.org/ns#",
18 "Emoji": "toot:Emoji",
19 "focalPoint": {
20 "@container": "@list",
21 "@id": "toot:focalPoint"
22 },
23 "featured": {
24 "@id": "toot:featured",
25 "@type": "@id"
26 },
27 "schema": "http://schema.org#",
28 "PropertyValue": "schema:PropertyValue",
29 "value": "schema:value"
30 }
31 ],
32 "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity",
33 "type": "Create",
34 "actor": "http://localhost:3000/users/ronan2",
35 "published": "2018-10-22T12:43:07Z",
36 "to": [
37 "https://www.w3.org/ns/activitystreams#Public"
38 ],
39 "cc": [
40 "http://localhost:3000/users/ronan2/followers",
41 "http://localhost:9000/accounts/ronan"
42 ],
43 "object": {
44 "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
45 "type": "Note",
46 "summary": null,
47 "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
48 "published": "2018-10-22T12:43:07Z",
49 "url": "http://localhost:3000/@ronan2/100939345950887698",
50 "attributedTo": "http://localhost:3000/users/ronan2",
51 "to": [
52 "https://www.w3.org/ns/activitystreams#Public"
53 ],
54 "cc": [
55 "http://localhost:3000/users/ronan2/followers",
56 "http://localhost:9000/accounts/ronan"
57 ],
58 "sensitive": false,
59 "atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
60 "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
61 "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
62 "content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>",
63 "contentMap": {
64 "en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>"
65 },
66 "attachment": [],
67 "tag": [
68 {
69 "type": "Mention",
70 "href": "http://localhost:9000/accounts/ronan",
71 "name": "@ronan@localhost:9000"
72 }
73 ]
74 },
75 "signature": {
76 "type": "RsaSignature2017",
77 "creator": "http://localhost:3000/users/ronan2#main-key",
78 "created": "2018-10-22T12:43:08Z",
79 "signatureValue": "Vgr8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ=="
80 }
81}
diff --git a/server/tests/api/activitypub/json/mastodon/create.json b/server/tests/api/activitypub/json/mastodon/create.json
new file mode 100644
index 000000000..0be271bb8
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/create.json
@@ -0,0 +1,81 @@
1{
2 "@context": [
3 "https://www.w3.org/ns/activitystreams",
4 "https://w3id.org/security/v1",
5 {
6 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
7 "sensitive": "as:sensitive",
8 "movedTo": {
9 "@id": "as:movedTo",
10 "@type": "@id"
11 },
12 "Hashtag": "as:Hashtag",
13 "ostatus": "http://ostatus.org#",
14 "atomUri": "ostatus:atomUri",
15 "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
16 "conversation": "ostatus:conversation",
17 "toot": "http://joinmastodon.org/ns#",
18 "Emoji": "toot:Emoji",
19 "focalPoint": {
20 "@container": "@list",
21 "@id": "toot:focalPoint"
22 },
23 "featured": {
24 "@id": "toot:featured",
25 "@type": "@id"
26 },
27 "schema": "http://schema.org#",
28 "PropertyValue": "schema:PropertyValue",
29 "value": "schema:value"
30 }
31 ],
32 "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity",
33 "type": "Create",
34 "actor": "http://localhost:3000/users/ronan2",
35 "published": "2018-10-22T12:43:07Z",
36 "to": [
37 "https://www.w3.org/ns/activitystreams#Public"
38 ],
39 "cc": [
40 "http://localhost:3000/users/ronan2/followers",
41 "http://localhost:9000/accounts/ronan"
42 ],
43 "object": {
44 "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
45 "type": "Note",
46 "summary": null,
47 "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
48 "published": "2018-10-22T12:43:07Z",
49 "url": "http://localhost:3000/@ronan2/100939345950887698",
50 "attributedTo": "http://localhost:3000/users/ronan2",
51 "to": [
52 "https://www.w3.org/ns/activitystreams#Public"
53 ],
54 "cc": [
55 "http://localhost:3000/users/ronan2/followers",
56 "http://localhost:9000/accounts/ronan"
57 ],
58 "sensitive": false,
59 "atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
60 "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
61 "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
62 "content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>",
63 "contentMap": {
64 "en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>"
65 },
66 "attachment": [],
67 "tag": [
68 {
69 "type": "Mention",
70 "href": "http://localhost:9000/accounts/ronan",
71 "name": "@ronan@localhost:9000"
72 }
73 ]
74 },
75 "signature": {
76 "type": "RsaSignature2017",
77 "creator": "http://localhost:3000/users/ronan2#main-key",
78 "created": "2018-10-22T12:43:08Z",
79 "signatureValue": "VgR8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ=="
80 }
81}
diff --git a/server/tests/api/activitypub/json/mastodon/http-signature.json b/server/tests/api/activitypub/json/mastodon/http-signature.json
new file mode 100644
index 000000000..4e7bc3af5
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/http-signature.json
@@ -0,0 +1,93 @@
1{
2 "headers": {
3 "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)",
4 "host": "localhost",
5 "date": "Mon, 22 Oct 2018 13:34:22 GMT",
6 "accept-encoding": "gzip",
7 "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=",
8 "content-type": "application/activity+json",
9 "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl5wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"",
10 "content-length": "2815"
11 },
12 "body": {
13 "@context": [
14 "https://www.w3.org/ns/activitystreams",
15 "https://w3id.org/security/v1",
16 {
17 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
18 "sensitive": "as:sensitive",
19 "movedTo": {
20 "@id": "as:movedTo",
21 "@type": "@id"
22 },
23 "Hashtag": "as:Hashtag",
24 "ostatus": "http://ostatus.org#",
25 "atomUri": "ostatus:atomUri",
26 "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
27 "conversation": "ostatus:conversation",
28 "toot": "http://joinmastodon.org/ns#",
29 "Emoji": "toot:Emoji",
30 "focalPoint": {
31 "@container": "@list",
32 "@id": "toot:focalPoint"
33 },
34 "featured": {
35 "@id": "toot:featured",
36 "@type": "@id"
37 },
38 "schema": "http://schema.org#",
39 "PropertyValue": "schema:PropertyValue",
40 "value": "schema:value"
41 }
42 ],
43 "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity",
44 "type": "Create",
45 "actor": "http://localhost:3000/users/ronan2",
46 "published": "2018-10-22T13:34:18Z",
47 "to": [
48 "https://www.w3.org/ns/activitystreams#Public"
49 ],
50 "cc": [
51 "http://localhost:3000/users/ronan2/followers",
52 "http://localhost:9000/accounts/ronan"
53 ],
54 "object": {
55 "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
56 "type": "Note",
57 "summary": null,
58 "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
59 "published": "2018-10-22T13:34:18Z",
60 "url": "http://localhost:3000/@ronan2/100939547203370948",
61 "attributedTo": "http://localhost:3000/users/ronan2",
62 "to": [
63 "https://www.w3.org/ns/activitystreams#Public"
64 ],
65 "cc": [
66 "http://localhost:3000/users/ronan2/followers",
67 "http://localhost:9000/accounts/ronan"
68 ],
69 "sensitive": false,
70 "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
71 "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
72 "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
73 "content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>",
74 "contentMap": {
75 "en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>"
76 },
77 "attachment": [],
78 "tag": [
79 {
80 "type": "Mention",
81 "href": "http://localhost:9000/accounts/ronan",
82 "name": "@ronan@localhost:9000"
83 }
84 ]
85 },
86 "signature": {
87 "type": "RsaSignature2017",
88 "creator": "http://localhost:3000/users/ronan2#main-key",
89 "created": "2018-10-22T13:34:19Z",
90 "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q=="
91 }
92 }
93}
diff --git a/server/tests/api/activitypub/json/mastodon/public-key.json b/server/tests/api/activitypub/json/mastodon/public-key.json
new file mode 100644
index 000000000..b7b9b8308
--- /dev/null
+++ b/server/tests/api/activitypub/json/mastodon/public-key.json
@@ -0,0 +1,3 @@
1{
2 "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YyuthHtWWgDe0Fdgdp2\ndC5dTJsRqW6pFw5omIYYYjoES/WRewhVxEA54BhmxD3L1zChfx131N1TS8jVowhW\nm999jpUffKCCvLgYKIXETJDHiDeMONVx8wp7v9fS1HiFXo/E5und39gUMs14CMFZ\n6PE5jRV3r4XIKQJHQl7/X5n5FOb2934K+1TKUeBkbft/AushlKatYQakt3qHxpwx\nFvE+JjGo7QTnzdjaOx/e5QvojdGi2Kx4+jl87j2WVcSo5lOBz04OAVJtChtn82vS\nulPdDh3hZcDn+WK67yAhGP6AnzvOybZZS4zowlKiQ3kqjVVXKdl8gAsL4Y7MZ40R\nJQIDAQAB\n-----END PUBLIC KEY-----\n"
3}
diff --git a/server/tests/api/activitypub/json/peertube/announce-without-context.json b/server/tests/api/activitypub/json/peertube/announce-without-context.json
new file mode 100644
index 000000000..5f2af0cde
--- /dev/null
+++ b/server/tests/api/activitypub/json/peertube/announce-without-context.json
@@ -0,0 +1,13 @@
1{
2 "type": "Announce",
3 "id": "http://localhost:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05/announces/1",
4 "actor": "http://localhost:9002/accounts/peertube",
5 "object": "http://localhost:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05",
6 "to": [
7 "https://www.w3.org/ns/activitystreams#Public",
8 "http://localhost:9002/accounts/peertube/followers",
9 "http://localhost:9002/video-channels/root_channel/followers",
10 "http://localhost:9002/accounts/root/followers"
11 ],
12 "cc": []
13}
diff --git a/server/tests/api/activitypub/json/peertube/invalid-keys.json b/server/tests/api/activitypub/json/peertube/invalid-keys.json
new file mode 100644
index 000000000..0544e96b9
--- /dev/null
+++ b/server/tests/api/activitypub/json/peertube/invalid-keys.json
@@ -0,0 +1,6 @@
1{
2 "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw2Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n",
3 "privateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAqjQGdH6D3naKmSbbr/DfEh1H42F3WlHYXuxKLkm5Bemjdde+\nGwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYOwAyc3Zoy7afPNa4bZXqhJ1Im41rM\nGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55sIkczDkseJuadTvG+A1e4uNY2lnRm\nVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/FpP5S75TS5l1DfJQIq2lp8RwrH6Fv\nGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM7mS7eP8zF8lKXYUu8cjIscKm+XqG\nmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKddaQIDAQABAoIBAQCnBZawCtbtH/ay\ng+dhqEW/SOyavbKZ92cU/1tsQPxISRYXNjdf2VfK7HmVqC2S7NqBanz+AVZPHmda\n7OfamkSvQbFN5VvEy8ATNV+9HbG3HG78/MT9hZcGigmyJkcZuy4wILgoXCxfpxlD\netla60PB/4yioiRcmEIWjjOgpByphDJ7RuuuptyEvgjUjpPtvHK47O/loaD2HFJk\nbIYbRirbjUjITRjQxGVIvanqiwPG9pB26YDLxDOoXEumcnzRcEFWNdvoleaLgquS\nn/zVsXWEq4+1i7t44DDstWUt/2Bw5ksIkSdayQ6oy3vzre3YFHwvbVZ7qtQQgpru\nx+NIolZhAoGBAN1RgNj8zy9Py3SJdsoXtnuCItfD7eo7LWXUa06cM/NS695Q+/to\naa5i3cJnRlv+b+b3VvnhkhIBLfFQW+hWwPnnxJEehcm09ddN9zbWrZ4Yv9yYu+8d\nTLGyWL8kPFF1dz+29DcrSv3tXEOwxByX/O4U/X/i3wl2WhkybxVFnCuvAoGBAMTf\n91BgLzvcYKOxH+vRPOJY7g2HKGFe35R91M4E+9Eq1rq4LUQHBb3fhRh4+scNu0yb\nNfN1Zdx2nbgCXdTKomF1Ahxp58/A2iU65vVzL6hYfWXEGSmoBqsGCIpIxQ9jgB9k\nCl7t/Ban8Z/ORHTjI9fpHlSZyCWJ3ajepiM2a1ZnAoGAPpDO6wi1DXvyWVSPF1yS\nwuGsNfD2rjPihpoBZ+yypwP3GBcu1QjUb28Vn+KQOmt4eQPNO8DwCVT6BvEfulPk\nJAHISPom+jnFEgPBcmhIFpyKiLNI1bUjvExd2FNHFgQuHP38ligQAC782Un8dtTk\ntO2MKH4bbVJe8CaYzpuqJZMCgYABZyMpBHZxs8FQiUuT75rCdiXEHOlxwC5RrY/d\no/VzaR28mOFhsbcdwkD9iqcm0fc6tYRt5rFCH+pBzGqEwKjljuLj9vE67sHfMAtD\nRn3Zcj/6gKo5PMRHZbSb36bf1DKuhpT4VjPMqYe0PtEIEDJKMJQRwELH2bKlqGiA\nqbucEwKBgQCkS85JnpHEV/tSylsEEn2W3CQCx58zl7iZNV7h/tWMR4AyrcI0HqP6\nllJ7V/Cfw66MgelPnosKgagwLVI6gsqDtjnzYo3XuMRVlYIySJ/jV3eiUNkV2Ky2\nfp/gA9sVgp38QSr+xB9E0LNStcbqDzoCCcDRws/SK7PbkQH9KV47tQ==\n-----END RSA PRIVATE KEY-----"
4}
5
6
diff --git a/server/tests/api/activitypub/json/peertube/keys.json b/server/tests/api/activitypub/json/peertube/keys.json
new file mode 100644
index 000000000..1a7700865
--- /dev/null
+++ b/server/tests/api/activitypub/json/peertube/keys.json
@@ -0,0 +1,4 @@
1{
2 "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n",
3 "privateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAqjQGdH6D3naKmSbbr/DfEh1H42F3WlHYXuxKLkm5Bemjdde+\nGwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYOwAyc3Zoy7afPNa4bZXqhJ1Im41rM\nGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55sIkczDkseJuadTvG+A1e4uNY2lnRm\nVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/FpP5S75TS5l1DfJQIq2lp8RwrH6Fv\nGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM7mS7eP8zF8lKXYUu8cjIscKm+XqG\nmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKddaQIDAQABAoIBAQCnBZawCtbtH/ay\ng+dhqEW/SOyavbKZ92cU/1tsQPxISRYXNjdf2VfK7HmVqC2S7NqBanz+AVZPHmda\n7OfamkSvQbFN5VvEy8ATNV+9HbG3HG78/MT9hZcGigmyJkcZuy4wILgoXCxfpxlD\netla60PB/4yioiRcmEIWjjOgpByphDJ7RuuuptyEvgjUjpPtvHK47O/loaD2HFJk\nbIYbRirbjUjITRjQxGVIvanqiwPG9pB26YDLxDOoXEumcnzRcEFWNdvoleaLgquS\nn/zVsXWEq4+1i7t44DDstWUt/2Bw5ksIkSdayQ6oy3vzre3YFHwvbVZ7qtQQgpru\nx+NIolZhAoGBAN1RgNj8zy9Py3SJdsoXtnuCItfD7eo7LWXUa06cM/NS695Q+/to\naa5i3cJnRlv+b+b3VvnhkhIBLfFQW+hWwPnnxJEehcm09ddN9zbWrZ4Yv9yYu+8d\nTLGyWL8kPFF1dz+29DcrSv3tXEOwxByX/O4U/X/i3wl2WhkybxVFnCuvAoGBAMTf\n91BgLzvcYKOxH+vRPOJY7g2HKGFe35R91M4E+9Eq1rq4LUQHBb3fhRh4+scNu0yb\nNfN1Zdx2nbgCXdTKomF1Ahxp58/A2iU65vVzL6hYfWXEGSmoBqsGCIpIxQ9jgB9k\nCl7t/Ban8Z/ORHTjI9fpHlSZyCWJ3ajepiM2a1ZnAoGAPpDO6wi1DXvyWVSPF1yS\nwuGsNfD2rjPihpoBZ+yypwP3GBcu1QjUb28Vn+KQOmt4eQPNO8DwCVT6BvEfulPk\nJAHISPom+jnFEgPBcmhIFpyKiLNI1bUjvExd2FNHFgQuHP38ligQAC782Un8dtTk\ntO2MKH4bbVJe8CaYzpuqJZMCgYABZyMpBHZxs8FQiUuT75rCdiXEHOlxwC5RrY/d\no/VzaR28mOFhsbcdwkD9iqcm0fc6tYRt5rFCH+pBzGqEwKjljuLj9vE67sHfMAtD\nRn3Zcj/6gKo5PMRHZbSb36bf1DKuhpT4VjPMqYe0PtEIEDJKMJQRwELH2bKlqGiA\nqbucEwKBgQCkS85JnpHEV/tSylsEEn2W3CQCx58zl7iZNV7h/tWMR4AyrcI0HqP6\nllJ7V/Cfw66MgelPnosKgagwLVI6gsqDtjnzYo3XuMRVlYIySJ/jV3eiUNkV2Ky2\nfp/gA9sVgp38QSr+xB9E0LNStcbqDzoCCcDRws/SK7PbkQH9KV47tQ==\n-----END RSA PRIVATE KEY-----"
4}
diff --git a/server/tests/api/activitypub/refresher.ts b/server/tests/api/activitypub/refresher.ts
new file mode 100644
index 000000000..62ad8a0b5
--- /dev/null
+++ b/server/tests/api/activitypub/refresher.ts
@@ -0,0 +1,93 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4import {
5 doubleFollow,
6 flushAndRunMultipleServers,
7 getVideo,
8 killallServers,
9 reRunServer,
10 ServerInfo,
11 setAccessTokensToServers,
12 uploadVideo,
13 wait,
14 setVideoField,
15 waitJobs
16} from '../../../../shared/utils'
17
18describe('Test AP refresher', function () {
19 let servers: ServerInfo[] = []
20 let videoUUID1: string
21 let videoUUID2: string
22 let videoUUID3: string
23
24 before(async function () {
25 this.timeout(60000)
26
27 servers = await flushAndRunMultipleServers(2)
28
29 // Get the access tokens
30 await setAccessTokensToServers(servers)
31
32 {
33 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video1' })
34 videoUUID1 = res.body.video.uuid
35 }
36
37 {
38 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video2' })
39 videoUUID2 = res.body.video.uuid
40 }
41
42 {
43 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video3' })
44 videoUUID3 = res.body.video.uuid
45 }
46
47 await doubleFollow(servers[0], servers[1])
48 })
49
50 it('Should remove a deleted remote video', async function () {
51 this.timeout(60000)
52
53 await wait(10000)
54
55 // Change UUID so the remote server returns a 404
56 await setVideoField(2, videoUUID1, 'uuid', '304afe4f-39f9-4d49-8ed7-ac57b86b174f')
57
58 await getVideo(servers[0].url, videoUUID1)
59 await getVideo(servers[0].url, videoUUID2)
60
61 await waitJobs(servers)
62
63 await getVideo(servers[0].url, videoUUID1, 404)
64 await getVideo(servers[0].url, videoUUID2, 200)
65 })
66
67 it('Should not update a remote video if the remote instance is down', async function () {
68 this.timeout(60000)
69
70 killallServers([ servers[1] ])
71
72 await setVideoField(2, videoUUID3, 'uuid', '304afe4f-39f9-4d49-8ed7-ac57b86b174e')
73
74 // Video will need a refresh
75 await wait(10000)
76
77 await getVideo(servers[0].url, videoUUID3)
78 // The refresh should fail
79 await waitJobs([ servers[0] ])
80
81 await reRunServer(servers[1])
82
83 // Should not refresh the video, even if the last refresh failed (to avoir a loop on dead instances)
84 await getVideo(servers[0].url, videoUUID3)
85 await waitJobs(servers)
86
87 await getVideo(servers[0].url, videoUUID3, 200)
88 })
89
90 after(async function () {
91 killallServers(servers)
92 })
93})
diff --git a/server/tests/api/activitypub/security.ts b/server/tests/api/activitypub/security.ts
new file mode 100644
index 000000000..342ae0fa1
--- /dev/null
+++ b/server/tests/api/activitypub/security.ts
@@ -0,0 +1,187 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4
5import {
6 flushAndRunMultipleServers,
7 flushTests,
8 killallServers,
9 makeFollowRequest,
10 makePOSTAPRequest,
11 ServerInfo,
12 setActorField
13} from '../../../../shared/utils'
14import { HTTP_SIGNATURE } from '../../../initializers'
15import { buildDigest, buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils'
16import * as chai from 'chai'
17import { activityPubContextify, buildSignedActivity } from '../../../helpers/activitypub'
18
19const expect = chai.expect
20
21function setKeysOfServer2 (serverNumber: number, publicKey: string, privateKey: string) {
22 return Promise.all([
23 setActorField(serverNumber, 'http://localhost:9002/accounts/peertube', 'publicKey', publicKey),
24 setActorField(serverNumber, 'http://localhost:9002/accounts/peertube', 'privateKey', privateKey)
25 ])
26}
27
28function setKeysOfServer3 (serverNumber: number, publicKey: string, privateKey: string) {
29 return Promise.all([
30 setActorField(serverNumber, 'http://localhost:9003/accounts/peertube', 'publicKey', publicKey),
31 setActorField(serverNumber, 'http://localhost:9003/accounts/peertube', 'privateKey', privateKey)
32 ])
33}
34
35describe('Test ActivityPub security', function () {
36 let servers: ServerInfo[]
37 let url: string
38
39 const keys = require('./json/peertube/keys.json')
40 const invalidKeys = require('./json/peertube/invalid-keys.json')
41 const baseHttpSignature = {
42 algorithm: HTTP_SIGNATURE.ALGORITHM,
43 authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
44 keyId: 'acct:peertube@localhost:9002',
45 key: keys.privateKey,
46 headers: HTTP_SIGNATURE.HEADERS_TO_SIGN
47 }
48
49 // ---------------------------------------------------------------
50
51 before(async function () {
52 this.timeout(60000)
53
54 servers = await flushAndRunMultipleServers(3)
55
56 url = servers[0].url + '/inbox'
57
58 await setKeysOfServer2(1, keys.publicKey, keys.privateKey)
59
60 const to = { url: 'http://localhost:9001/accounts/peertube' }
61 const by = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
62 await makeFollowRequest(to, by)
63 })
64
65 describe('When checking HTTP signature', function () {
66
67 it('Should fail with an invalid digest', async function () {
68 const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
69 const headers = {
70 Digest: buildDigest({ hello: 'coucou' })
71 }
72
73 const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
74
75 expect(response.statusCode).to.equal(403)
76 })
77
78 it('Should fail with an invalid date', async function () {
79 const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
80 const headers = buildGlobalHeaders(body)
81 headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT'
82
83 const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
84
85 expect(response.statusCode).to.equal(403)
86 })
87
88 it('Should fail with bad keys', async function () {
89 await setKeysOfServer2(1, invalidKeys.publicKey, invalidKeys.privateKey)
90 await setKeysOfServer2(2, invalidKeys.publicKey, invalidKeys.privateKey)
91
92 const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
93 const headers = buildGlobalHeaders(body)
94
95 const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
96
97 expect(response.statusCode).to.equal(403)
98 })
99
100 it('Should succeed with a valid HTTP signature', async function () {
101 await setKeysOfServer2(1, keys.publicKey, keys.privateKey)
102 await setKeysOfServer2(2, keys.publicKey, keys.privateKey)
103
104 const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
105 const headers = buildGlobalHeaders(body)
106
107 const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
108
109 expect(response.statusCode).to.equal(204)
110 })
111 })
112
113 describe('When checking Linked Data Signature', function () {
114 before(async () => {
115 await setKeysOfServer3(3, keys.publicKey, keys.privateKey)
116
117 const to = { url: 'http://localhost:9001/accounts/peertube' }
118 const by = { url: 'http://localhost:9003/accounts/peertube', privateKey: keys.privateKey }
119 await makeFollowRequest(to, by)
120 })
121
122 it('Should fail with bad keys', async function () {
123 this.timeout(10000)
124
125 await setKeysOfServer3(1, invalidKeys.publicKey, invalidKeys.privateKey)
126 await setKeysOfServer3(3, invalidKeys.publicKey, invalidKeys.privateKey)
127
128 const body = require('./json/peertube/announce-without-context.json')
129 body.actor = 'http://localhost:9003/accounts/peertube'
130
131 const signer: any = { privateKey: invalidKeys.privateKey, url: 'http://localhost:9003/accounts/peertube' }
132 const signedBody = await buildSignedActivity(signer, body)
133
134 const headers = buildGlobalHeaders(signedBody)
135
136 const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers)
137
138 expect(response.statusCode).to.equal(403)
139 })
140
141 it('Should fail with an altered body', async function () {
142 this.timeout(10000)
143
144 await setKeysOfServer3(1, keys.publicKey, keys.privateKey)
145 await setKeysOfServer3(3, keys.publicKey, keys.privateKey)
146
147 const body = require('./json/peertube/announce-without-context.json')
148 body.actor = 'http://localhost:9003/accounts/peertube'
149
150 const signer: any = { privateKey: keys.privateKey, url: 'http://localhost:9003/accounts/peertube' }
151 const signedBody = await buildSignedActivity(signer, body)
152
153 signedBody.actor = 'http://localhost:9003/account/peertube'
154
155 const headers = buildGlobalHeaders(signedBody)
156
157 const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers)
158
159 expect(response.statusCode).to.equal(403)
160 })
161
162 it('Should succeed with a valid signature', async function () {
163 this.timeout(10000)
164
165 const body = require('./json/peertube/announce-without-context.json')
166 body.actor = 'http://localhost:9003/accounts/peertube'
167
168 const signer: any = { privateKey: keys.privateKey, url: 'http://localhost:9003/accounts/peertube' }
169 const signedBody = await buildSignedActivity(signer, body)
170
171 const headers = buildGlobalHeaders(signedBody)
172
173 const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers)
174
175 expect(response.statusCode).to.equal(204)
176 })
177 })
178
179 after(async function () {
180 killallServers(servers)
181
182 // Keep the logs if the test failed
183 if (this['ok']) {
184 await flushTests()
185 }
186 })
187})
diff --git a/server/tests/api/check-params/accounts.ts b/server/tests/api/check-params/accounts.ts
index 9e0b1e35c..68f9519c6 100644
--- a/server/tests/api/check-params/accounts.ts
+++ b/server/tests/api/check-params/accounts.ts
@@ -2,11 +2,15 @@
2 2
3import 'mocha' 3import 'mocha'
4 4
5import { flushTests, killallServers, runServer, ServerInfo } from '../../utils' 5import { flushTests, killallServers, runServer, ServerInfo } from '../../../../shared/utils'
6import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 6import {
7import { getAccount } from '../../utils/users/accounts' 7 checkBadCountPagination,
8 8 checkBadSortPagination,
9describe('Test users API validators', function () { 9 checkBadStartPagination
10} from '../../../../shared/utils/requests/check-api-params'
11import { getAccount } from '../../../../shared/utils/users/accounts'
12
13describe('Test accounts API validators', function () {
10 const path = '/api/v1/accounts/' 14 const path = '/api/v1/accounts/'
11 let server: ServerInfo 15 let server: ServerInfo
12 16
diff --git a/server/tests/api/check-params/blocklist.ts b/server/tests/api/check-params/blocklist.ts
new file mode 100644
index 000000000..c20453c16
--- /dev/null
+++ b/server/tests/api/check-params/blocklist.ts
@@ -0,0 +1,498 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4
5import {
6 createUser,
7 doubleFollow,
8 flushAndRunMultipleServers,
9 flushTests,
10 killallServers,
11 makeDeleteRequest,
12 makeGetRequest,
13 makePostBodyRequest,
14 ServerInfo,
15 setAccessTokensToServers, userLogin
16} from '../../../../shared/utils'
17import {
18 checkBadCountPagination,
19 checkBadSortPagination,
20 checkBadStartPagination
21} from '../../../../shared/utils/requests/check-api-params'
22
23describe('Test blocklist API validators', function () {
24 let servers: ServerInfo[]
25 let server: ServerInfo
26 let userAccessToken: string
27
28 before(async function () {
29 this.timeout(60000)
30
31 await flushTests()
32
33 servers = await flushAndRunMultipleServers(2)
34 await setAccessTokensToServers(servers)
35
36 server = servers[0]
37
38 const user = { username: 'user1', password: 'password' }
39 await createUser(server.url, server.accessToken, user.username, user.password)
40
41 userAccessToken = await userLogin(server, user)
42
43 await doubleFollow(servers[0], servers[1])
44 })
45
46 // ---------------------------------------------------------------
47
48 describe('When managing user blocklist', function () {
49
50 describe('When managing user accounts blocklist', function () {
51 const path = '/api/v1/users/me/blocklist/accounts'
52
53 describe('When listing blocked accounts', function () {
54 it('Should fail with an unauthenticated user', async function () {
55 await makeGetRequest({
56 url: server.url,
57 path,
58 statusCodeExpected: 401
59 })
60 })
61
62 it('Should fail with a bad start pagination', async function () {
63 await checkBadStartPagination(server.url, path, server.accessToken)
64 })
65
66 it('Should fail with a bad count pagination', async function () {
67 await checkBadCountPagination(server.url, path, server.accessToken)
68 })
69
70 it('Should fail with an incorrect sort', async function () {
71 await checkBadSortPagination(server.url, path, server.accessToken)
72 })
73 })
74
75 describe('When blocking an account', function () {
76 it('Should fail with an unauthenticated user', async function () {
77 await makePostBodyRequest({
78 url: server.url,
79 path,
80 fields: { accountName: 'user1' },
81 statusCodeExpected: 401
82 })
83 })
84
85 it('Should fail with an unknown account', async function () {
86 await makePostBodyRequest({
87 url: server.url,
88 token: server.accessToken,
89 path,
90 fields: { accountName: 'user2' },
91 statusCodeExpected: 404
92 })
93 })
94
95 it('Should fail to block ourselves', async function () {
96 await makePostBodyRequest({
97 url: server.url,
98 token: server.accessToken,
99 path,
100 fields: { accountName: 'root' },
101 statusCodeExpected: 409
102 })
103 })
104
105 it('Should succeed with the correct params', async function () {
106 await makePostBodyRequest({
107 url: server.url,
108 token: server.accessToken,
109 path,
110 fields: { accountName: 'user1' },
111 statusCodeExpected: 204
112 })
113 })
114 })
115
116 describe('When unblocking an account', function () {
117 it('Should fail with an unauthenticated user', async function () {
118 await makeDeleteRequest({
119 url: server.url,
120 path: path + '/user1',
121 statusCodeExpected: 401
122 })
123 })
124
125 it('Should fail with an unknown account block', async function () {
126 await makeDeleteRequest({
127 url: server.url,
128 path: path + '/user2',
129 token: server.accessToken,
130 statusCodeExpected: 404
131 })
132 })
133
134 it('Should succeed with the correct params', async function () {
135 await makeDeleteRequest({
136 url: server.url,
137 path: path + '/user1',
138 token: server.accessToken,
139 statusCodeExpected: 204
140 })
141 })
142 })
143 })
144
145 describe('When managing user servers blocklist', function () {
146 const path = '/api/v1/users/me/blocklist/servers'
147
148 describe('When listing blocked servers', function () {
149 it('Should fail with an unauthenticated user', async function () {
150 await makeGetRequest({
151 url: server.url,
152 path,
153 statusCodeExpected: 401
154 })
155 })
156
157 it('Should fail with a bad start pagination', async function () {
158 await checkBadStartPagination(server.url, path, server.accessToken)
159 })
160
161 it('Should fail with a bad count pagination', async function () {
162 await checkBadCountPagination(server.url, path, server.accessToken)
163 })
164
165 it('Should fail with an incorrect sort', async function () {
166 await checkBadSortPagination(server.url, path, server.accessToken)
167 })
168 })
169
170 describe('When blocking a server', function () {
171 it('Should fail with an unauthenticated user', async function () {
172 await makePostBodyRequest({
173 url: server.url,
174 path,
175 fields: { host: 'localhost:9002' },
176 statusCodeExpected: 401
177 })
178 })
179
180 it('Should fail with an unknown server', async function () {
181 await makePostBodyRequest({
182 url: server.url,
183 token: server.accessToken,
184 path,
185 fields: { host: 'localhost:9003' },
186 statusCodeExpected: 404
187 })
188 })
189
190 it('Should fail with our own server', async function () {
191 await makePostBodyRequest({
192 url: server.url,
193 token: server.accessToken,
194 path,
195 fields: { host: 'localhost:9001' },
196 statusCodeExpected: 409
197 })
198 })
199
200 it('Should succeed with the correct params', async function () {
201 await makePostBodyRequest({
202 url: server.url,
203 token: server.accessToken,
204 path,
205 fields: { host: 'localhost:9002' },
206 statusCodeExpected: 204
207 })
208 })
209 })
210
211 describe('When unblocking a server', function () {
212 it('Should fail with an unauthenticated user', async function () {
213 await makeDeleteRequest({
214 url: server.url,
215 path: path + '/localhost:9002',
216 statusCodeExpected: 401
217 })
218 })
219
220 it('Should fail with an unknown server block', async function () {
221 await makeDeleteRequest({
222 url: server.url,
223 path: path + '/localhost:9003',
224 token: server.accessToken,
225 statusCodeExpected: 404
226 })
227 })
228
229 it('Should succeed with the correct params', async function () {
230 await makeDeleteRequest({
231 url: server.url,
232 path: path + '/localhost:9002',
233 token: server.accessToken,
234 statusCodeExpected: 204
235 })
236 })
237 })
238 })
239 })
240
241 describe('When managing server blocklist', function () {
242
243 describe('When managing server accounts blocklist', function () {
244 const path = '/api/v1/server/blocklist/accounts'
245
246 describe('When listing blocked accounts', function () {
247 it('Should fail with an unauthenticated user', async function () {
248 await makeGetRequest({
249 url: server.url,
250 path,
251 statusCodeExpected: 401
252 })
253 })
254
255 it('Should fail with a user without the appropriate rights', async function () {
256 await makeGetRequest({
257 url: server.url,
258 token: userAccessToken,
259 path,
260 statusCodeExpected: 403
261 })
262 })
263
264 it('Should fail with a bad start pagination', async function () {
265 await checkBadStartPagination(server.url, path, server.accessToken)
266 })
267
268 it('Should fail with a bad count pagination', async function () {
269 await checkBadCountPagination(server.url, path, server.accessToken)
270 })
271
272 it('Should fail with an incorrect sort', async function () {
273 await checkBadSortPagination(server.url, path, server.accessToken)
274 })
275 })
276
277 describe('When blocking an account', function () {
278 it('Should fail with an unauthenticated user', async function () {
279 await makePostBodyRequest({
280 url: server.url,
281 path,
282 fields: { accountName: 'user1' },
283 statusCodeExpected: 401
284 })
285 })
286
287 it('Should fail with a user without the appropriate rights', async function () {
288 await makePostBodyRequest({
289 url: server.url,
290 token: userAccessToken,
291 path,
292 fields: { accountName: 'user1' },
293 statusCodeExpected: 403
294 })
295 })
296
297 it('Should fail with an unknown account', async function () {
298 await makePostBodyRequest({
299 url: server.url,
300 token: server.accessToken,
301 path,
302 fields: { accountName: 'user2' },
303 statusCodeExpected: 404
304 })
305 })
306
307 it('Should fail to block ourselves', async function () {
308 await makePostBodyRequest({
309 url: server.url,
310 token: server.accessToken,
311 path,
312 fields: { accountName: 'root' },
313 statusCodeExpected: 409
314 })
315 })
316
317 it('Should succeed with the correct params', async function () {
318 await makePostBodyRequest({
319 url: server.url,
320 token: server.accessToken,
321 path,
322 fields: { accountName: 'user1' },
323 statusCodeExpected: 204
324 })
325 })
326 })
327
328 describe('When unblocking an account', function () {
329 it('Should fail with an unauthenticated user', async function () {
330 await makeDeleteRequest({
331 url: server.url,
332 path: path + '/user1',
333 statusCodeExpected: 401
334 })
335 })
336
337 it('Should fail with a user without the appropriate rights', async function () {
338 await makeDeleteRequest({
339 url: server.url,
340 path: path + '/user1',
341 token: userAccessToken,
342 statusCodeExpected: 403
343 })
344 })
345
346 it('Should fail with an unknown account block', async function () {
347 await makeDeleteRequest({
348 url: server.url,
349 path: path + '/user2',
350 token: server.accessToken,
351 statusCodeExpected: 404
352 })
353 })
354
355 it('Should succeed with the correct params', async function () {
356 await makeDeleteRequest({
357 url: server.url,
358 path: path + '/user1',
359 token: server.accessToken,
360 statusCodeExpected: 204
361 })
362 })
363 })
364 })
365
366 describe('When managing server servers blocklist', function () {
367 const path = '/api/v1/server/blocklist/servers'
368
369 describe('When listing blocked servers', function () {
370 it('Should fail with an unauthenticated user', async function () {
371 await makeGetRequest({
372 url: server.url,
373 path,
374 statusCodeExpected: 401
375 })
376 })
377
378 it('Should fail with a user without the appropriate rights', async function () {
379 await makeGetRequest({
380 url: server.url,
381 token: userAccessToken,
382 path,
383 statusCodeExpected: 403
384 })
385 })
386
387 it('Should fail with a bad start pagination', async function () {
388 await checkBadStartPagination(server.url, path, server.accessToken)
389 })
390
391 it('Should fail with a bad count pagination', async function () {
392 await checkBadCountPagination(server.url, path, server.accessToken)
393 })
394
395 it('Should fail with an incorrect sort', async function () {
396 await checkBadSortPagination(server.url, path, server.accessToken)
397 })
398 })
399
400 describe('When blocking a server', function () {
401 it('Should fail with an unauthenticated user', async function () {
402 await makePostBodyRequest({
403 url: server.url,
404 path,
405 fields: { host: 'localhost:9002' },
406 statusCodeExpected: 401
407 })
408 })
409
410 it('Should fail with a user without the appropriate rights', async function () {
411 await makePostBodyRequest({
412 url: server.url,
413 token: userAccessToken,
414 path,
415 fields: { host: 'localhost:9002' },
416 statusCodeExpected: 403
417 })
418 })
419
420 it('Should fail with an unknown server', async function () {
421 await makePostBodyRequest({
422 url: server.url,
423 token: server.accessToken,
424 path,
425 fields: { host: 'localhost:9003' },
426 statusCodeExpected: 404
427 })
428 })
429
430 it('Should fail with our own server', async function () {
431 await makePostBodyRequest({
432 url: server.url,
433 token: server.accessToken,
434 path,
435 fields: { host: 'localhost:9001' },
436 statusCodeExpected: 409
437 })
438 })
439
440 it('Should succeed with the correct params', async function () {
441 await makePostBodyRequest({
442 url: server.url,
443 token: server.accessToken,
444 path,
445 fields: { host: 'localhost:9002' },
446 statusCodeExpected: 204
447 })
448 })
449 })
450
451 describe('When unblocking a server', function () {
452 it('Should fail with an unauthenticated user', async function () {
453 await makeDeleteRequest({
454 url: server.url,
455 path: path + '/localhost:9002',
456 statusCodeExpected: 401
457 })
458 })
459
460 it('Should fail with a user without the appropriate rights', async function () {
461 await makeDeleteRequest({
462 url: server.url,
463 path: path + '/localhost:9002',
464 token: userAccessToken,
465 statusCodeExpected: 403
466 })
467 })
468
469 it('Should fail with an unknown server block', async function () {
470 await makeDeleteRequest({
471 url: server.url,
472 path: path + '/localhost:9003',
473 token: server.accessToken,
474 statusCodeExpected: 404
475 })
476 })
477
478 it('Should succeed with the correct params', async function () {
479 await makeDeleteRequest({
480 url: server.url,
481 path: path + '/localhost:9002',
482 token: server.accessToken,
483 statusCodeExpected: 204
484 })
485 })
486 })
487 })
488 })
489
490 after(async function () {
491 killallServers(servers)
492
493 // Keep the logs if the test failed
494 if (this['ok']) {
495 await flushTests()
496 }
497 })
498})
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index d807f910b..07de2b5a5 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -7,7 +7,7 @@ import { CustomConfig } from '../../../../shared/models/server/custom-config.mod
7import { 7import {
8 createUser, flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePutBodyRequest, runServer, ServerInfo, 8 createUser, flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePutBodyRequest, runServer, ServerInfo,
9 setAccessTokensToServers, userLogin, immutableAssign 9 setAccessTokensToServers, userLogin, immutableAssign
10} from '../../utils' 10} from '../../../../shared/utils'
11 11
12describe('Test config API validators', function () { 12describe('Test config API validators', function () {
13 const path = '/api/v1/config/custom' 13 const path = '/api/v1/config/custom'
@@ -48,12 +48,16 @@ describe('Test config API validators', function () {
48 admin: { 48 admin: {
49 email: 'superadmin1@example.com' 49 email: 'superadmin1@example.com'
50 }, 50 },
51 contactForm: {
52 enabled: false
53 },
51 user: { 54 user: {
52 videoQuota: 5242881, 55 videoQuota: 5242881,
53 videoQuotaDaily: 318742 56 videoQuotaDaily: 318742
54 }, 57 },
55 transcoding: { 58 transcoding: {
56 enabled: true, 59 enabled: true,
60 allowAdditionalExtensions: true,
57 threads: 1, 61 threads: 1,
58 resolutions: { 62 resolutions: {
59 '240p': false, 63 '240p': false,
@@ -61,6 +65,9 @@ describe('Test config API validators', function () {
61 '480p': true, 65 '480p': true,
62 '720p': false, 66 '720p': false,
63 '1080p': false 67 '1080p': false
68 },
69 hls: {
70 enabled: false
64 } 71 }
65 }, 72 },
66 import: { 73 import: {
diff --git a/server/tests/api/check-params/contact-form.ts b/server/tests/api/check-params/contact-form.ts
new file mode 100644
index 000000000..c7e014b1f
--- /dev/null
+++ b/server/tests/api/check-params/contact-form.ts
@@ -0,0 +1,96 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4
5import {
6 flushTests,
7 immutableAssign,
8 killallServers,
9 reRunServer,
10 runServer,
11 ServerInfo,
12 setAccessTokensToServers
13} from '../../../../shared/utils'
14import {
15 checkBadCountPagination,
16 checkBadSortPagination,
17 checkBadStartPagination
18} from '../../../../shared/utils/requests/check-api-params'
19import { getAccount } from '../../../../shared/utils/users/accounts'
20import { sendContactForm } from '../../../../shared/utils/server/contact-form'
21import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
22
23describe('Test contact form API validators', function () {
24 let server: ServerInfo
25 const emails: object[] = []
26 const defaultBody = {
27 fromName: 'super name',
28 fromEmail: 'toto@example.com',
29 body: 'Hello, how are you?'
30 }
31
32 // ---------------------------------------------------------------
33
34 before(async function () {
35 this.timeout(60000)
36
37 await flushTests()
38 await MockSmtpServer.Instance.collectEmails(emails)
39
40 // Email is disabled
41 server = await runServer(1)
42 })
43
44 it('Should not accept a contact form if emails are disabled', async function () {
45 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 }))
46 })
47
48 it('Should not accept a contact form if it is disabled in the configuration', async function () {
49 this.timeout(10000)
50
51 killallServers([ server ])
52
53 // Contact form is disabled
54 await reRunServer(server, { smtp: { hostname: 'localhost' }, contact_form: { enabled: false } })
55 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 }))
56 })
57
58 it('Should not accept a contact form if from email is invalid', async function () {
59 this.timeout(10000)
60
61 killallServers([ server ])
62
63 // Email & contact form enabled
64 await reRunServer(server, { smtp: { hostname: 'localhost' } })
65
66 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail' }))
67 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail@' }))
68 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: undefined }))
69 })
70
71 it('Should not accept a contact form if from name is invalid', async function () {
72 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: 'name'.repeat(100) }))
73 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: '' }))
74 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: undefined }))
75 })
76
77 it('Should not accept a contact form if body is invalid', async function () {
78 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'body'.repeat(5000) }))
79 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'a' }))
80 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: undefined }))
81 })
82
83 it('Should accept a contact form with the correct parameters', async function () {
84 await sendContactForm(immutableAssign(defaultBody, { url: server.url }))
85 })
86
87 after(async function () {
88 MockSmtpServer.Instance.kill()
89 killallServers([ server ])
90
91 // Keep the logs if the test failed
92 if (this['ok']) {
93 await flushTests()
94 }
95 })
96})
diff --git a/server/tests/api/check-params/follows.ts b/server/tests/api/check-params/follows.ts
index cdc95c81a..2ad1575a3 100644
--- a/server/tests/api/check-params/follows.ts
+++ b/server/tests/api/check-params/follows.ts
@@ -5,8 +5,12 @@ import 'mocha'
5import { 5import {
6 createUser, flushTests, killallServers, makeDeleteRequest, makePostBodyRequest, runServer, ServerInfo, setAccessTokensToServers, 6 createUser, flushTests, killallServers, makeDeleteRequest, makePostBodyRequest, runServer, ServerInfo, setAccessTokensToServers,
7 userLogin 7 userLogin
8} from '../../utils' 8} from '../../../../shared/utils'
9import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 9import {
10 checkBadCountPagination,
11 checkBadSortPagination,
12 checkBadStartPagination
13} from '../../../../shared/utils/requests/check-api-params'
10 14
11describe('Test server follows API validators', function () { 15describe('Test server follows API validators', function () {
12 let server: ServerInfo 16 let server: ServerInfo
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 71a217649..77c17036a 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -1,11 +1,13 @@
1// Order of the tests we want to execute
2import './accounts' 1import './accounts'
2import './blocklist'
3import './config' 3import './config'
4import './contact-form'
4import './follows' 5import './follows'
5import './jobs' 6import './jobs'
6import './redundancy' 7import './redundancy'
7import './search' 8import './search'
8import './services' 9import './services'
10import './user-notifications'
9import './user-subscriptions' 11import './user-subscriptions'
10import './users' 12import './users'
11import './video-abuses' 13import './video-abuses'
@@ -15,4 +17,5 @@ import './video-channels'
15import './video-comments' 17import './video-comments'
16import './video-imports' 18import './video-imports'
17import './videos' 19import './videos'
20import './videos-filter'
18import './videos-history' 21import './videos-history'
diff --git a/server/tests/api/check-params/jobs.ts b/server/tests/api/check-params/jobs.ts
index ce3ac8809..89760ff98 100644
--- a/server/tests/api/check-params/jobs.ts
+++ b/server/tests/api/check-params/jobs.ts
@@ -2,9 +2,21 @@
2 2
3import 'mocha' 3import 'mocha'
4 4
5import { createUser, flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, userLogin } from '../../utils' 5import {
6import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 6 createUser,
7import { makeGetRequest } from '../../utils/requests/requests' 7 flushTests,
8 killallServers,
9 runServer,
10 ServerInfo,
11 setAccessTokensToServers,
12 userLogin
13} from '../../../../shared/utils'
14import {
15 checkBadCountPagination,
16 checkBadSortPagination,
17 checkBadStartPagination
18} from '../../../../shared/utils/requests/check-api-params'
19import { makeGetRequest } from '../../../../shared/utils/requests/requests'
8 20
9describe('Test jobs API validators', function () { 21describe('Test jobs API validators', function () {
10 const path = '/api/v1/jobs/failed' 22 const path = '/api/v1/jobs/failed'
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index aa588e3dd..ff4726ceb 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -12,7 +12,7 @@ import {
12 ServerInfo, 12 ServerInfo,
13 setAccessTokensToServers, 13 setAccessTokensToServers,
14 userLogin 14 userLogin
15} from '../../utils' 15} from '../../../../shared/utils'
16 16
17describe('Test server redundancy API validators', function () { 17describe('Test server redundancy API validators', function () {
18 let servers: ServerInfo[] 18 let servers: ServerInfo[]
diff --git a/server/tests/api/check-params/search.ts b/server/tests/api/check-params/search.ts
index eabf602ac..aa81965f3 100644
--- a/server/tests/api/check-params/search.ts
+++ b/server/tests/api/check-params/search.ts
@@ -2,8 +2,12 @@
2 2
3import 'mocha' 3import 'mocha'
4 4
5import { flushTests, immutableAssign, killallServers, makeGetRequest, runServer, ServerInfo } from '../../utils' 5import { flushTests, immutableAssign, killallServers, makeGetRequest, runServer, ServerInfo } from '../../../../shared/utils'
6import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 6import {
7 checkBadCountPagination,
8 checkBadSortPagination,
9 checkBadStartPagination
10} from '../../../../shared/utils/requests/check-api-params'
7 11
8describe('Test videos API validator', function () { 12describe('Test videos API validator', function () {
9 let server: ServerInfo 13 let server: ServerInfo
diff --git a/server/tests/api/check-params/services.ts b/server/tests/api/check-params/services.ts
index fcde7e179..28591af9d 100644
--- a/server/tests/api/check-params/services.ts
+++ b/server/tests/api/check-params/services.ts
@@ -2,7 +2,15 @@
2 2
3import 'mocha' 3import 'mocha'
4 4
5import { flushTests, killallServers, makeGetRequest, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils' 5import {
6 flushTests,
7 killallServers,
8 makeGetRequest,
9 runServer,
10 ServerInfo,
11 setAccessTokensToServers,
12 uploadVideo
13} from '../../../../shared/utils'
6 14
7describe('Test services API validators', function () { 15describe('Test services API validators', function () {
8 let server: ServerInfo 16 let server: ServerInfo
diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts
new file mode 100644
index 000000000..714f481e9
--- /dev/null
+++ b/server/tests/api/check-params/user-notifications.ts
@@ -0,0 +1,297 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4import * as io from 'socket.io-client'
5
6import {
7 flushTests,
8 immutableAssign,
9 killallServers,
10 makeGetRequest,
11 makePostBodyRequest,
12 makePutBodyRequest,
13 runServer,
14 ServerInfo,
15 setAccessTokensToServers,
16 wait
17} from '../../../../shared/utils'
18import {
19 checkBadCountPagination,
20 checkBadSortPagination,
21 checkBadStartPagination
22} from '../../../../shared/utils/requests/check-api-params'
23import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users'
24
25describe('Test user notifications API validators', function () {
26 let server: ServerInfo
27
28 // ---------------------------------------------------------------
29
30 before(async function () {
31 this.timeout(30000)
32
33 await flushTests()
34
35 server = await runServer(1)
36
37 await setAccessTokensToServers([ server ])
38 })
39
40 describe('When listing my notifications', function () {
41 const path = '/api/v1/users/me/notifications'
42
43 it('Should fail with a bad start pagination', async function () {
44 await checkBadStartPagination(server.url, path, server.accessToken)
45 })
46
47 it('Should fail with a bad count pagination', async function () {
48 await checkBadCountPagination(server.url, path, server.accessToken)
49 })
50
51 it('Should fail with an incorrect sort', async function () {
52 await checkBadSortPagination(server.url, path, server.accessToken)
53 })
54
55 it('Should fail with an incorrect unread parameter', async function () {
56 await makeGetRequest({
57 url: server.url,
58 path,
59 query: {
60 unread: 'toto'
61 },
62 token: server.accessToken,
63 statusCodeExpected: 200
64 })
65 })
66
67 it('Should fail with a non authenticated user', async function () {
68 await makeGetRequest({
69 url: server.url,
70 path,
71 statusCodeExpected: 401
72 })
73 })
74
75 it('Should succeed with the correct parameters', async function () {
76 await makeGetRequest({
77 url: server.url,
78 path,
79 token: server.accessToken,
80 statusCodeExpected: 200
81 })
82 })
83 })
84
85 describe('When marking as read my notifications', function () {
86 const path = '/api/v1/users/me/notifications/read'
87
88 it('Should fail with wrong ids parameters', async function () {
89 await makePostBodyRequest({
90 url: server.url,
91 path,
92 fields: {
93 ids: [ 'hello' ]
94 },
95 token: server.accessToken,
96 statusCodeExpected: 400
97 })
98
99 await makePostBodyRequest({
100 url: server.url,
101 path,
102 fields: {
103 ids: [ ]
104 },
105 token: server.accessToken,
106 statusCodeExpected: 400
107 })
108
109 await makePostBodyRequest({
110 url: server.url,
111 path,
112 fields: {
113 ids: 5
114 },
115 token: server.accessToken,
116 statusCodeExpected: 400
117 })
118 })
119
120 it('Should fail with a non authenticated user', async function () {
121 await makePostBodyRequest({
122 url: server.url,
123 path,
124 fields: {
125 ids: [ 5 ]
126 },
127 statusCodeExpected: 401
128 })
129 })
130
131 it('Should succeed with the correct parameters', async function () {
132 await makePostBodyRequest({
133 url: server.url,
134 path,
135 fields: {
136 ids: [ 5 ]
137 },
138 token: server.accessToken,
139 statusCodeExpected: 204
140 })
141 })
142 })
143
144 describe('When marking as read my notifications', function () {
145 const path = '/api/v1/users/me/notifications/read-all'
146
147 it('Should fail with a non authenticated user', async function () {
148 await makePostBodyRequest({
149 url: server.url,
150 path,
151 statusCodeExpected: 401
152 })
153 })
154
155 it('Should succeed with the correct parameters', async function () {
156 await makePostBodyRequest({
157 url: server.url,
158 path,
159 token: server.accessToken,
160 statusCodeExpected: 204
161 })
162 })
163 })
164
165 describe('When updating my notification settings', function () {
166 const path = '/api/v1/users/me/notification-settings'
167 const correctFields: UserNotificationSetting = {
168 newVideoFromSubscription: UserNotificationSettingValue.WEB,
169 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
170 videoAbuseAsModerator: UserNotificationSettingValue.WEB,
171 blacklistOnMyVideo: UserNotificationSettingValue.WEB,
172 myVideoImportFinished: UserNotificationSettingValue.WEB,
173 myVideoPublished: UserNotificationSettingValue.WEB,
174 commentMention: UserNotificationSettingValue.WEB,
175 newFollow: UserNotificationSettingValue.WEB,
176 newUserRegistration: UserNotificationSettingValue.WEB
177 }
178
179 it('Should fail with missing fields', async function () {
180 await makePutBodyRequest({
181 url: server.url,
182 path,
183 token: server.accessToken,
184 fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB },
185 statusCodeExpected: 400
186 })
187 })
188
189 it('Should fail with incorrect field values', async function () {
190 {
191 const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 15 })
192
193 await makePutBodyRequest({
194 url: server.url,
195 path,
196 token: server.accessToken,
197 fields,
198 statusCodeExpected: 400
199 })
200 }
201
202 {
203 const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 'toto' })
204
205 await makePutBodyRequest({
206 url: server.url,
207 path,
208 fields,
209 token: server.accessToken,
210 statusCodeExpected: 400
211 })
212 }
213 })
214
215 it('Should fail with a non authenticated user', async function () {
216 await makePutBodyRequest({
217 url: server.url,
218 path,
219 fields: correctFields,
220 statusCodeExpected: 401
221 })
222 })
223
224 it('Should succeed with the correct parameters', async function () {
225 await makePutBodyRequest({
226 url: server.url,
227 path,
228 token: server.accessToken,
229 fields: correctFields,
230 statusCodeExpected: 204
231 })
232 })
233 })
234
235 describe('When connecting to my notification socket', function () {
236 it('Should fail with no token', function (next) {
237 const socket = io('http://localhost:9001/user-notifications', { reconnection: false })
238
239 socket.on('error', () => {
240 socket.removeListener('error', this)
241 socket.disconnect()
242 next()
243 })
244
245 socket.on('connect', () => {
246 socket.disconnect()
247 next(new Error('Connected with a missing token.'))
248 })
249 })
250
251 it('Should fail with an invalid token', function (next) {
252 const socket = io('http://localhost:9001/user-notifications', {
253 query: { accessToken: 'bad_access_token' },
254 reconnection: false
255 })
256
257 socket.on('error', () => {
258 socket.removeListener('error', this)
259 socket.disconnect()
260 next()
261 })
262
263 socket.on('connect', () => {
264 socket.disconnect()
265 next(new Error('Connected with an invalid token.'))
266 })
267 })
268
269 it('Should success with the correct token', function (next) {
270 const socket = io('http://localhost:9001/user-notifications', {
271 query: { accessToken: server.accessToken },
272 reconnection: false
273 })
274
275 const errorListener = socket.on('error', err => {
276 next(new Error('Error in connection: ' + err))
277 })
278
279 socket.on('connect', async () => {
280 socket.removeListener('error', errorListener)
281 socket.disconnect()
282
283 await wait(500)
284 next()
285 })
286 })
287 })
288
289 after(async function () {
290 killallServers([ server ])
291
292 // Keep the logs if the test failed
293 if (this['ok']) {
294 await flushTests()
295 }
296 })
297})
diff --git a/server/tests/api/check-params/user-subscriptions.ts b/server/tests/api/check-params/user-subscriptions.ts
index 9fba99ac8..8a9ced7c1 100644
--- a/server/tests/api/check-params/user-subscriptions.ts
+++ b/server/tests/api/check-params/user-subscriptions.ts
@@ -13,8 +13,14 @@ import {
13 ServerInfo, 13 ServerInfo,
14 setAccessTokensToServers, 14 setAccessTokensToServers,
15 userLogin 15 userLogin
16} from '../../utils' 16} from '../../../../shared/utils'
17import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 17
18import {
19 checkBadCountPagination,
20 checkBadSortPagination,
21 checkBadStartPagination
22} from '../../../../shared/utils/requests/check-api-params'
23import { waitJobs } from '../../../../shared/utils/server/jobs'
18 24
19describe('Test user subscriptions API validators', function () { 25describe('Test user subscriptions API validators', function () {
20 const path = '/api/v1/users/me/subscriptions' 26 const path = '/api/v1/users/me/subscriptions'
@@ -141,6 +147,8 @@ describe('Test user subscriptions API validators', function () {
141 }) 147 })
142 148
143 it('Should succeed with the correct parameters', async function () { 149 it('Should succeed with the correct parameters', async function () {
150 this.timeout(20000)
151
144 await makePostBodyRequest({ 152 await makePostBodyRequest({
145 url: server.url, 153 url: server.url,
146 path, 154 path,
@@ -148,6 +156,8 @@ describe('Test user subscriptions API validators', function () {
148 fields: { uri: 'user1_channel@localhost:9001' }, 156 fields: { uri: 'user1_channel@localhost:9001' },
149 statusCodeExpected: 204 157 statusCodeExpected: 204
150 }) 158 })
159
160 await waitJobs([ server ])
151 }) 161 })
152 }) 162 })
153 163
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index cbfa0c137..13be8b460 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -9,11 +9,15 @@ import {
9 createUser, flushTests, getMyUserInformation, getMyUserVideoRating, getUsersList, immutableAssign, killallServers, makeGetRequest, 9 createUser, flushTests, getMyUserInformation, getMyUserVideoRating, getUsersList, immutableAssign, killallServers, makeGetRequest,
10 makePostBodyRequest, makeUploadRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers, 10 makePostBodyRequest, makeUploadRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers,
11 updateUser, uploadVideo, userLogin, deleteMe, unblockUser, blockUser 11 updateUser, uploadVideo, userLogin, deleteMe, unblockUser, blockUser
12} from '../../utils' 12} from '../../../../shared/utils'
13import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 13import {
14import { getMagnetURI, getMyVideoImports, getYoutubeVideoUrl, importVideo } from '../../utils/videos/video-imports' 14 checkBadCountPagination,
15 checkBadSortPagination,
16 checkBadStartPagination
17} from '../../../../shared/utils/requests/check-api-params'
18import { getMagnetURI, getMyVideoImports, getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
15import { VideoPrivacy } from '../../../../shared/models/videos' 19import { VideoPrivacy } from '../../../../shared/models/videos'
16import { waitJobs } from '../../utils/server/jobs' 20import { waitJobs } from '../../../../shared/utils/server/jobs'
17import { expect } from 'chai' 21import { expect } from 'chai'
18 22
19describe('Test users API validators', function () { 23describe('Test users API validators', function () {
@@ -99,13 +103,13 @@ describe('Test users API validators', function () {
99 } 103 }
100 104
101 it('Should fail with a too small username', async function () { 105 it('Should fail with a too small username', async function () {
102 const fields = immutableAssign(baseCorrectParams, { username: 'fi' }) 106 const fields = immutableAssign(baseCorrectParams, { username: '' })
103 107
104 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 108 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
105 }) 109 })
106 110
107 it('Should fail with a too long username', async function () { 111 it('Should fail with a too long username', async function () {
108 const fields = immutableAssign(baseCorrectParams, { username: 'my_super_username_which_is_very_long' }) 112 const fields = immutableAssign(baseCorrectParams, { username: 'super'.repeat(50) })
109 113
110 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 114 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
111 }) 115 })
@@ -304,6 +308,14 @@ describe('Test users API validators', function () {
304 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields }) 308 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
305 }) 309 })
306 310
311 it('Should fail with an invalid videosHistoryEnabled attribute', async function () {
312 const fields = {
313 videosHistoryEnabled: -1
314 }
315
316 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
317 })
318
307 it('Should fail with an non authenticated user', async function () { 319 it('Should fail with an non authenticated user', async function () {
308 const fields = { 320 const fields = {
309 currentPassword: 'my super password', 321 currentPassword: 'my super password',
@@ -315,7 +327,7 @@ describe('Test users API validators', function () {
315 327
316 it('Should fail with a too long description', async function () { 328 it('Should fail with a too long description', async function () {
317 const fields = { 329 const fields = {
318 description: 'super'.repeat(60) 330 description: 'super'.repeat(201)
319 } 331 }
320 332
321 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields }) 333 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
@@ -428,6 +440,14 @@ describe('Test users API validators', function () {
428 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) 440 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
429 }) 441 })
430 442
443 it('Should fail with an invalid emailVerified attribute', async function () {
444 const fields = {
445 emailVerified: 'yes'
446 }
447
448 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
449 })
450
431 it('Should fail with an invalid videoQuota attribute', async function () { 451 it('Should fail with an invalid videoQuota attribute', async function () {
432 const fields = { 452 const fields = {
433 videoQuota: -90 453 videoQuota: -90
@@ -444,6 +464,24 @@ describe('Test users API validators', function () {
444 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) 464 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
445 }) 465 })
446 466
467 it('Should fail with a too small password', async function () {
468 const fields = {
469 currentPassword: 'my super password',
470 password: 'bla'
471 }
472
473 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
474 })
475
476 it('Should fail with a too long password', async function () {
477 const fields = {
478 currentPassword: 'my super password',
479 password: 'super'.repeat(61)
480 }
481
482 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
483 })
484
447 it('Should fail with an non authenticated user', async function () { 485 it('Should fail with an non authenticated user', async function () {
448 const fields = { 486 const fields = {
449 videoQuota: 42 487 videoQuota: 42
@@ -463,12 +501,12 @@ describe('Test users API validators', function () {
463 it('Should succeed with the correct params', async function () { 501 it('Should succeed with the correct params', async function () {
464 const fields = { 502 const fields = {
465 email: 'email@example.com', 503 email: 'email@example.com',
504 emailVerified: true,
466 videoQuota: 42, 505 videoQuota: 42,
467 role: UserRole.MODERATOR 506 role: UserRole.USER
468 } 507 }
469 508
470 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 }) 509 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields, statusCodeExpected: 204 })
471 userAccessToken = await userLogin(server, user)
472 }) 510 })
473 }) 511 })
474 512
@@ -541,13 +579,13 @@ describe('Test users API validators', function () {
541 } 579 }
542 580
543 it('Should fail with a too small username', async function () { 581 it('Should fail with a too small username', async function () {
544 const fields = immutableAssign(baseCorrectParams, { username: 'ji' }) 582 const fields = immutableAssign(baseCorrectParams, { username: '' })
545 583
546 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields }) 584 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
547 }) 585 })
548 586
549 it('Should fail with a too long username', async function () { 587 it('Should fail with a too long username', async function () {
550 const fields = immutableAssign(baseCorrectParams, { username: 'my_super_username_which_is_very_long' }) 588 const fields = immutableAssign(baseCorrectParams, { username: 'super'.repeat(50) })
551 589
552 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields }) 590 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
553 }) 591 })
diff --git a/server/tests/api/check-params/video-abuses.ts b/server/tests/api/check-params/video-abuses.ts
index d2bed6a2a..3b8f5f14d 100644
--- a/server/tests/api/check-params/video-abuses.ts
+++ b/server/tests/api/check-params/video-abuses.ts
@@ -15,8 +15,12 @@ import {
15 updateVideoAbuse, 15 updateVideoAbuse,
16 uploadVideo, 16 uploadVideo,
17 userLogin 17 userLogin
18} from '../../utils' 18} from '../../../../shared/utils'
19import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 19import {
20 checkBadCountPagination,
21 checkBadSortPagination,
22 checkBadStartPagination
23} from '../../../../shared/utils/requests/check-api-params'
20import { VideoAbuseState } from '../../../../shared/models/videos' 24import { VideoAbuseState } from '../../../../shared/models/videos'
21 25
22describe('Test video abuses API validators', function () { 26describe('Test video abuses API validators', function () {
@@ -109,8 +113,8 @@ describe('Test video abuses API validators', function () {
109 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 113 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
110 }) 114 })
111 115
112 it('Should fail with a reason too big', async function () { 116 it('Should fail with a too big reason', async function () {
113 const fields = { reason: 'super'.repeat(61) } 117 const fields = { reason: 'super'.repeat(605) }
114 118
115 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 119 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
116 }) 120 })
@@ -150,7 +154,7 @@ describe('Test video abuses API validators', function () {
150 }) 154 })
151 155
152 it('Should fail with a bad moderation comment', async function () { 156 it('Should fail with a bad moderation comment', async function () {
153 const body = { moderationComment: 'b'.repeat(305) } 157 const body = { moderationComment: 'b'.repeat(3001) }
154 await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body, 400) 158 await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body, 400)
155 }) 159 })
156 160
diff --git a/server/tests/api/check-params/video-blacklist.ts b/server/tests/api/check-params/video-blacklist.ts
index 473216236..6b82643f4 100644
--- a/server/tests/api/check-params/video-blacklist.ts
+++ b/server/tests/api/check-params/video-blacklist.ts
@@ -4,25 +4,33 @@ import 'mocha'
4 4
5import { 5import {
6 createUser, 6 createUser,
7 doubleFollow,
8 flushAndRunMultipleServers,
7 flushTests, 9 flushTests,
8 getBlacklistedVideosList, getVideo, getVideoWithToken, 10 getBlacklistedVideosList,
11 getVideo,
12 getVideoWithToken,
9 killallServers, 13 killallServers,
10 makePostBodyRequest, 14 makePostBodyRequest,
11 makePutBodyRequest, 15 makePutBodyRequest,
12 removeVideoFromBlacklist, 16 removeVideoFromBlacklist,
13 runServer,
14 ServerInfo, 17 ServerInfo,
15 setAccessTokensToServers, 18 setAccessTokensToServers,
16 uploadVideo, 19 uploadVideo,
17 userLogin 20 userLogin, waitJobs
18} from '../../utils' 21} from '../../../../shared/utils'
19import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 22import {
23 checkBadCountPagination,
24 checkBadSortPagination,
25 checkBadStartPagination
26} from '../../../../shared/utils/requests/check-api-params'
20import { VideoDetails } from '../../../../shared/models/videos' 27import { VideoDetails } from '../../../../shared/models/videos'
21import { expect } from 'chai' 28import { expect } from 'chai'
22 29
23describe('Test video blacklist API validators', function () { 30describe('Test video blacklist API validators', function () {
24 let server: ServerInfo 31 let servers: ServerInfo[]
25 let notBlacklistedVideoId: number 32 let notBlacklistedVideoId: number
33 let remoteVideoUUID: string
26 let userAccessToken1 = '' 34 let userAccessToken1 = ''
27 let userAccessToken2 = '' 35 let userAccessToken2 = ''
28 36
@@ -32,75 +40,89 @@ describe('Test video blacklist API validators', function () {
32 this.timeout(120000) 40 this.timeout(120000)
33 41
34 await flushTests() 42 await flushTests()
43 servers = await flushAndRunMultipleServers(2)
35 44
36 server = await runServer(1) 45 await setAccessTokensToServers(servers)
37 46 await doubleFollow(servers[0], servers[1])
38 await setAccessTokensToServers([ server ])
39 47
40 { 48 {
41 const username = 'user1' 49 const username = 'user1'
42 const password = 'my super password' 50 const password = 'my super password'
43 await createUser(server.url, server.accessToken, username, password) 51 await createUser(servers[0].url, servers[0].accessToken, username, password)
44 userAccessToken1 = await userLogin(server, { username, password }) 52 userAccessToken1 = await userLogin(servers[0], { username, password })
45 } 53 }
46 54
47 { 55 {
48 const username = 'user2' 56 const username = 'user2'
49 const password = 'my super password' 57 const password = 'my super password'
50 await createUser(server.url, server.accessToken, username, password) 58 await createUser(servers[0].url, servers[0].accessToken, username, password)
51 userAccessToken2 = await userLogin(server, { username, password }) 59 userAccessToken2 = await userLogin(servers[0], { username, password })
52 } 60 }
53 61
54 { 62 {
55 const res = await uploadVideo(server.url, userAccessToken1, {}) 63 const res = await uploadVideo(servers[0].url, userAccessToken1, {})
56 server.video = res.body.video 64 servers[0].video = res.body.video
57 } 65 }
58 66
59 { 67 {
60 const res = await uploadVideo(server.url, server.accessToken, {}) 68 const res = await uploadVideo(servers[0].url, servers[0].accessToken, {})
61 notBlacklistedVideoId = res.body.video.uuid 69 notBlacklistedVideoId = res.body.video.uuid
62 } 70 }
71
72 {
73 const res = await uploadVideo(servers[1].url, servers[1].accessToken, {})
74 remoteVideoUUID = res.body.video.uuid
75 }
76
77 await waitJobs(servers)
63 }) 78 })
64 79
65 describe('When adding a video in blacklist', function () { 80 describe('When adding a video in blacklist', function () {
66 const basePath = '/api/v1/videos/' 81 const basePath = '/api/v1/videos/'
67 82
68 it('Should fail with nothing', async function () { 83 it('Should fail with nothing', async function () {
69 const path = basePath + server.video + '/blacklist' 84 const path = basePath + servers[0].video + '/blacklist'
70 const fields = {} 85 const fields = {}
71 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 86 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields })
72 }) 87 })
73 88
74 it('Should fail with a wrong video', async function () { 89 it('Should fail with a wrong video', async function () {
75 const wrongPath = '/api/v1/videos/blabla/blacklist' 90 const wrongPath = '/api/v1/videos/blabla/blacklist'
76 const fields = {} 91 const fields = {}
77 await makePostBodyRequest({ url: server.url, path: wrongPath, token: server.accessToken, fields }) 92 await makePostBodyRequest({ url: servers[0].url, path: wrongPath, token: servers[0].accessToken, fields })
78 }) 93 })
79 94
80 it('Should fail with a non authenticated user', async function () { 95 it('Should fail with a non authenticated user', async function () {
81 const path = basePath + server.video + '/blacklist' 96 const path = basePath + servers[0].video + '/blacklist'
82 const fields = {} 97 const fields = {}
83 await makePostBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 }) 98 await makePostBodyRequest({ url: servers[0].url, path, token: 'hello', fields, statusCodeExpected: 401 })
84 }) 99 })
85 100
86 it('Should fail with a non admin user', async function () { 101 it('Should fail with a non admin user', async function () {
87 const path = basePath + server.video + '/blacklist' 102 const path = basePath + servers[0].video + '/blacklist'
88 const fields = {} 103 const fields = {}
89 await makePostBodyRequest({ url: server.url, path, token: userAccessToken2, fields, statusCodeExpected: 403 }) 104 await makePostBodyRequest({ url: servers[0].url, path, token: userAccessToken2, fields, statusCodeExpected: 403 })
90 }) 105 })
91 106
92 it('Should fail with an invalid reason', async function () { 107 it('Should fail with an invalid reason', async function () {
93 const path = basePath + server.video.uuid + '/blacklist' 108 const path = basePath + servers[0].video.uuid + '/blacklist'
94 const fields = { reason: 'a'.repeat(305) } 109 const fields = { reason: 'a'.repeat(305) }
95 110
96 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 111 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields })
112 })
113
114 it('Should fail to unfederate a remote video', async function () {
115 const path = basePath + remoteVideoUUID + '/blacklist'
116 const fields = { unfederate: true }
117
118 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 409 })
97 }) 119 })
98 120
99 it('Should succeed with the correct params', async function () { 121 it('Should succeed with the correct params', async function () {
100 const path = basePath + server.video.uuid + '/blacklist' 122 const path = basePath + servers[0].video.uuid + '/blacklist'
101 const fields = { } 123 const fields = { }
102 124
103 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 }) 125 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 204 })
104 }) 126 })
105 }) 127 })
106 128
@@ -110,61 +132,61 @@ describe('Test video blacklist API validators', function () {
110 it('Should fail with a wrong video', async function () { 132 it('Should fail with a wrong video', async function () {
111 const wrongPath = '/api/v1/videos/blabla/blacklist' 133 const wrongPath = '/api/v1/videos/blabla/blacklist'
112 const fields = {} 134 const fields = {}
113 await makePutBodyRequest({ url: server.url, path: wrongPath, token: server.accessToken, fields }) 135 await makePutBodyRequest({ url: servers[0].url, path: wrongPath, token: servers[0].accessToken, fields })
114 }) 136 })
115 137
116 it('Should fail with a video not blacklisted', async function () { 138 it('Should fail with a video not blacklisted', async function () {
117 const path = '/api/v1/videos/' + notBlacklistedVideoId + '/blacklist' 139 const path = '/api/v1/videos/' + notBlacklistedVideoId + '/blacklist'
118 const fields = {} 140 const fields = {}
119 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 404 }) 141 await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 404 })
120 }) 142 })
121 143
122 it('Should fail with a non authenticated user', async function () { 144 it('Should fail with a non authenticated user', async function () {
123 const path = basePath + server.video + '/blacklist' 145 const path = basePath + servers[0].video + '/blacklist'
124 const fields = {} 146 const fields = {}
125 await makePutBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 }) 147 await makePutBodyRequest({ url: servers[0].url, path, token: 'hello', fields, statusCodeExpected: 401 })
126 }) 148 })
127 149
128 it('Should fail with a non admin user', async function () { 150 it('Should fail with a non admin user', async function () {
129 const path = basePath + server.video + '/blacklist' 151 const path = basePath + servers[0].video + '/blacklist'
130 const fields = {} 152 const fields = {}
131 await makePutBodyRequest({ url: server.url, path, token: userAccessToken2, fields, statusCodeExpected: 403 }) 153 await makePutBodyRequest({ url: servers[0].url, path, token: userAccessToken2, fields, statusCodeExpected: 403 })
132 }) 154 })
133 155
134 it('Should fail with an invalid reason', async function () { 156 it('Should fail with an invalid reason', async function () {
135 const path = basePath + server.video.uuid + '/blacklist' 157 const path = basePath + servers[0].video.uuid + '/blacklist'
136 const fields = { reason: 'a'.repeat(305) } 158 const fields = { reason: 'a'.repeat(305) }
137 159
138 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 160 await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields })
139 }) 161 })
140 162
141 it('Should succeed with the correct params', async function () { 163 it('Should succeed with the correct params', async function () {
142 const path = basePath + server.video.uuid + '/blacklist' 164 const path = basePath + servers[0].video.uuid + '/blacklist'
143 const fields = { reason: 'hello' } 165 const fields = { reason: 'hello' }
144 166
145 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 }) 167 await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 204 })
146 }) 168 })
147 }) 169 })
148 170
149 describe('When getting blacklisted video', function () { 171 describe('When getting blacklisted video', function () {
150 172
151 it('Should fail with a non authenticated user', async function () { 173 it('Should fail with a non authenticated user', async function () {
152 await getVideo(server.url, server.video.uuid, 401) 174 await getVideo(servers[0].url, servers[0].video.uuid, 401)
153 }) 175 })
154 176
155 it('Should fail with another user', async function () { 177 it('Should fail with another user', async function () {
156 await getVideoWithToken(server.url, userAccessToken2, server.video.uuid, 403) 178 await getVideoWithToken(servers[0].url, userAccessToken2, servers[0].video.uuid, 403)
157 }) 179 })
158 180
159 it('Should succeed with the owner authenticated user', async function () { 181 it('Should succeed with the owner authenticated user', async function () {
160 const res = await getVideoWithToken(server.url, userAccessToken1, server.video.uuid, 200) 182 const res = await getVideoWithToken(servers[0].url, userAccessToken1, servers[0].video.uuid, 200)
161 const video: VideoDetails = res.body 183 const video: VideoDetails = res.body
162 184
163 expect(video.blacklisted).to.be.true 185 expect(video.blacklisted).to.be.true
164 }) 186 })
165 187
166 it('Should succeed with an admin', async function () { 188 it('Should succeed with an admin', async function () {
167 const res = await getVideoWithToken(server.url, server.accessToken, server.video.uuid, 200) 189 const res = await getVideoWithToken(servers[0].url, servers[0].accessToken, servers[0].video.uuid, 200)
168 const video: VideoDetails = res.body 190 const video: VideoDetails = res.body
169 191
170 expect(video.blacklisted).to.be.true 192 expect(video.blacklisted).to.be.true
@@ -173,24 +195,24 @@ describe('Test video blacklist API validators', function () {
173 195
174 describe('When removing a video in blacklist', function () { 196 describe('When removing a video in blacklist', function () {
175 it('Should fail with a non authenticated user', async function () { 197 it('Should fail with a non authenticated user', async function () {
176 await removeVideoFromBlacklist(server.url, 'fake token', server.video.uuid, 401) 198 await removeVideoFromBlacklist(servers[0].url, 'fake token', servers[0].video.uuid, 401)
177 }) 199 })
178 200
179 it('Should fail with a non admin user', async function () { 201 it('Should fail with a non admin user', async function () {
180 await removeVideoFromBlacklist(server.url, userAccessToken2, server.video.uuid, 403) 202 await removeVideoFromBlacklist(servers[0].url, userAccessToken2, servers[0].video.uuid, 403)
181 }) 203 })
182 204
183 it('Should fail with an incorrect id', async function () { 205 it('Should fail with an incorrect id', async function () {
184 await removeVideoFromBlacklist(server.url, server.accessToken, 'hello', 400) 206 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, 'hello', 400)
185 }) 207 })
186 208
187 it('Should fail with a not blacklisted video', async function () { 209 it('Should fail with a not blacklisted video', async function () {
188 // The video was not added to the blacklist so it should fail 210 // The video was not added to the blacklist so it should fail
189 await removeVideoFromBlacklist(server.url, server.accessToken, notBlacklistedVideoId, 404) 211 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, notBlacklistedVideoId, 404)
190 }) 212 })
191 213
192 it('Should succeed with the correct params', async function () { 214 it('Should succeed with the correct params', async function () {
193 await removeVideoFromBlacklist(server.url, server.accessToken, server.video.uuid, 204) 215 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, servers[0].video.uuid, 204)
194 }) 216 })
195 }) 217 })
196 218
@@ -198,28 +220,28 @@ describe('Test video blacklist API validators', function () {
198 const basePath = '/api/v1/videos/blacklist/' 220 const basePath = '/api/v1/videos/blacklist/'
199 221
200 it('Should fail with a non authenticated user', async function () { 222 it('Should fail with a non authenticated user', async function () {
201 await getBlacklistedVideosList(server.url, 'fake token', 401) 223 await getBlacklistedVideosList(servers[0].url, 'fake token', 401)
202 }) 224 })
203 225
204 it('Should fail with a non admin user', async function () { 226 it('Should fail with a non admin user', async function () {
205 await getBlacklistedVideosList(server.url, userAccessToken2, 403) 227 await getBlacklistedVideosList(servers[0].url, userAccessToken2, 403)
206 }) 228 })
207 229
208 it('Should fail with a bad start pagination', async function () { 230 it('Should fail with a bad start pagination', async function () {
209 await checkBadStartPagination(server.url, basePath, server.accessToken) 231 await checkBadStartPagination(servers[0].url, basePath, servers[0].accessToken)
210 }) 232 })
211 233
212 it('Should fail with a bad count pagination', async function () { 234 it('Should fail with a bad count pagination', async function () {
213 await checkBadCountPagination(server.url, basePath, server.accessToken) 235 await checkBadCountPagination(servers[0].url, basePath, servers[0].accessToken)
214 }) 236 })
215 237
216 it('Should fail with an incorrect sort', async function () { 238 it('Should fail with an incorrect sort', async function () {
217 await checkBadSortPagination(server.url, basePath, server.accessToken) 239 await checkBadSortPagination(servers[0].url, basePath, servers[0].accessToken)
218 }) 240 })
219 }) 241 })
220 242
221 after(async function () { 243 after(async function () {
222 killallServers([ server ]) 244 killallServers(servers)
223 245
224 // Keep the logs if the test failed 246 // Keep the logs if the test failed
225 if (this['ok']) { 247 if (this['ok']) {
diff --git a/server/tests/api/check-params/video-captions.ts b/server/tests/api/check-params/video-captions.ts
index 8d46971a1..e4d36fd4f 100644
--- a/server/tests/api/check-params/video-captions.ts
+++ b/server/tests/api/check-params/video-captions.ts
@@ -13,9 +13,9 @@ import {
13 setAccessTokensToServers, 13 setAccessTokensToServers,
14 uploadVideo, 14 uploadVideo,
15 userLogin 15 userLogin
16} from '../../utils' 16} from '../../../../shared/utils'
17import { join } from 'path' 17import { join } from 'path'
18import { createVideoCaption } from '../../utils/videos/video-captions' 18import { createVideoCaption } from '../../../../shared/utils/videos/video-captions'
19 19
20describe('Test video captions API validator', function () { 20describe('Test video captions API validator', function () {
21 const path = '/api/v1/videos/' 21 const path = '/api/v1/videos/'
diff --git a/server/tests/api/check-params/video-channels.ts b/server/tests/api/check-params/video-channels.ts
index 3a7942945..14e4deaf7 100644
--- a/server/tests/api/check-params/video-channels.ts
+++ b/server/tests/api/check-params/video-channels.ts
@@ -20,8 +20,12 @@ import {
20 ServerInfo, 20 ServerInfo,
21 setAccessTokensToServers, 21 setAccessTokensToServers,
22 userLogin 22 userLogin
23} from '../../utils' 23} from '../../../../shared/utils'
24import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 24import {
25 checkBadCountPagination,
26 checkBadSortPagination,
27 checkBadStartPagination
28} from '../../../../shared/utils/requests/check-api-params'
25import { User } from '../../../../shared/models/users' 29import { User } from '../../../../shared/models/users'
26import { join } from 'path' 30import { join } from 'path'
27 31
@@ -118,12 +122,12 @@ describe('Test video channels API validator', function () {
118 }) 122 })
119 123
120 it('Should fail with a long description', async function () { 124 it('Should fail with a long description', async function () {
121 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(150) }) 125 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(201) })
122 await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) 126 await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields })
123 }) 127 })
124 128
125 it('Should fail with a long support text', async function () { 129 it('Should fail with a long support text', async function () {
126 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(150) }) 130 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
127 await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) 131 await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields })
128 }) 132 })
129 133
@@ -185,12 +189,12 @@ describe('Test video channels API validator', function () {
185 }) 189 })
186 190
187 it('Should fail with a long description', async function () { 191 it('Should fail with a long description', async function () {
188 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(150) }) 192 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(201) })
189 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 193 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields })
190 }) 194 })
191 195
192 it('Should fail with a long support text', async function () { 196 it('Should fail with a long support text', async function () {
193 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(150) }) 197 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
194 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 198 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields })
195 }) 199 })
196 200
diff --git a/server/tests/api/check-params/video-comments.ts b/server/tests/api/check-params/video-comments.ts
index 5241832fe..5981780ed 100644
--- a/server/tests/api/check-params/video-comments.ts
+++ b/server/tests/api/check-params/video-comments.ts
@@ -6,9 +6,13 @@ import {
6 createUser, 6 createUser,
7 flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePostBodyRequest, runServer, ServerInfo, setAccessTokensToServers, 7 flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePostBodyRequest, runServer, ServerInfo, setAccessTokensToServers,
8 uploadVideo, userLogin 8 uploadVideo, userLogin
9} from '../../utils' 9} from '../../../../shared/utils'
10import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 10import {
11import { addVideoCommentThread } from '../../utils/videos/video-comments' 11 checkBadCountPagination,
12 checkBadSortPagination,
13 checkBadStartPagination
14} from '../../../../shared/utils/requests/check-api-params'
15import { addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
12 16
13const expect = chai.expect 17const expect = chai.expect
14 18
diff --git a/server/tests/api/check-params/video-imports.ts b/server/tests/api/check-params/video-imports.ts
index 1ffb81a38..6dd9f15f7 100644
--- a/server/tests/api/check-params/video-imports.ts
+++ b/server/tests/api/check-params/video-imports.ts
@@ -18,9 +18,13 @@ import {
18 setAccessTokensToServers, 18 setAccessTokensToServers,
19 updateCustomSubConfig, 19 updateCustomSubConfig,
20 userLogin 20 userLogin
21} from '../../utils' 21} from '../../../../shared/utils'
22import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 22import {
23import { getMagnetURI, getYoutubeVideoUrl } from '../../utils/videos/video-imports' 23 checkBadCountPagination,
24 checkBadSortPagination,
25 checkBadStartPagination
26} from '../../../../shared/utils/requests/check-api-params'
27import { getMagnetURI, getYoutubeVideoUrl } from '../../../../shared/utils/videos/video-imports'
24 28
25describe('Test video imports API validator', function () { 29describe('Test video imports API validator', function () {
26 const path = '/api/v1/videos/imports' 30 const path = '/api/v1/videos/imports'
@@ -141,7 +145,7 @@ describe('Test video imports API validator', function () {
141 }) 145 })
142 146
143 it('Should fail with a long support text', async function () { 147 it('Should fail with a long support text', async function () {
144 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(150) }) 148 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
145 149
146 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 150 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
147 }) 151 })
diff --git a/server/tests/api/check-params/videos-filter.ts b/server/tests/api/check-params/videos-filter.ts
new file mode 100644
index 000000000..e998c8a3d
--- /dev/null
+++ b/server/tests/api/check-params/videos-filter.ts
@@ -0,0 +1,127 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 createUser,
7 flushTests,
8 killallServers,
9 makeGetRequest,
10 runServer,
11 ServerInfo,
12 setAccessTokensToServers,
13 userLogin
14} from '../../../../shared/utils'
15import { UserRole } from '../../../../shared/models/users'
16
17const expect = chai.expect
18
19async function testEndpoints (server: ServerInfo, token: string, filter: string, statusCodeExpected: number) {
20 const paths = [
21 '/api/v1/video-channels/root_channel/videos',
22 '/api/v1/accounts/root/videos',
23 '/api/v1/videos',
24 '/api/v1/search/videos'
25 ]
26
27 for (const path of paths) {
28 await makeGetRequest({
29 url: server.url,
30 path,
31 token,
32 query: {
33 filter
34 },
35 statusCodeExpected
36 })
37 }
38}
39
40describe('Test videos filters', function () {
41 let server: ServerInfo
42 let userAccessToken: string
43 let moderatorAccessToken: string
44
45 // ---------------------------------------------------------------
46
47 before(async function () {
48 this.timeout(30000)
49
50 await flushTests()
51
52 server = await runServer(1)
53
54 await setAccessTokensToServers([ server ])
55
56 const user = { username: 'user1', password: 'my super password' }
57 await createUser(server.url, server.accessToken, user.username, user.password)
58 userAccessToken = await userLogin(server, user)
59
60 const moderator = { username: 'moderator', password: 'my super password' }
61 await createUser(
62 server.url,
63 server.accessToken,
64 moderator.username,
65 moderator.password,
66 undefined,
67 undefined,
68 UserRole.MODERATOR
69 )
70 moderatorAccessToken = await userLogin(server, moderator)
71 })
72
73 describe('When setting a video filter', function () {
74
75 it('Should fail with a bad filter', async function () {
76 await testEndpoints(server, server.accessToken, 'bad-filter', 400)
77 })
78
79 it('Should succeed with a good filter', async function () {
80 await testEndpoints(server, server.accessToken,'local', 200)
81 })
82
83 it('Should fail to list all-local with a simple user', async function () {
84 await testEndpoints(server, userAccessToken, 'all-local', 401)
85 })
86
87 it('Should succeed to list all-local with a moderator', async function () {
88 await testEndpoints(server, moderatorAccessToken, 'all-local', 200)
89 })
90
91 it('Should succeed to list all-local with an admin', async function () {
92 await testEndpoints(server, server.accessToken, 'all-local', 200)
93 })
94
95 // Because we cannot authenticate the user on the RSS endpoint
96 it('Should fail on the feeds endpoint with the all-local filter', async function () {
97 await makeGetRequest({
98 url: server.url,
99 path: '/feeds/videos.json',
100 statusCodeExpected: 401,
101 query: {
102 filter: 'all-local'
103 }
104 })
105 })
106
107 it('Should succed on the feeds endpoint with the local filter', async function () {
108 await makeGetRequest({
109 url: server.url,
110 path: '/feeds/videos.json',
111 statusCodeExpected: 200,
112 query: {
113 filter: 'local'
114 }
115 })
116 })
117 })
118
119 after(async function () {
120 killallServers([ server ])
121
122 // Keep the logs if the test failed
123 if (this['ok']) {
124 await flushTests()
125 }
126 })
127})
diff --git a/server/tests/api/check-params/videos-history.ts b/server/tests/api/check-params/videos-history.ts
index 808c3b616..8c079a956 100644
--- a/server/tests/api/check-params/videos-history.ts
+++ b/server/tests/api/check-params/videos-history.ts
@@ -3,20 +3,25 @@
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { 5import {
6 checkBadCountPagination,
7 checkBadStartPagination,
6 flushTests, 8 flushTests,
7 killallServers, 9 killallServers,
10 makeGetRequest,
8 makePostBodyRequest, 11 makePostBodyRequest,
9 makePutBodyRequest, 12 makePutBodyRequest,
10 runServer, 13 runServer,
11 ServerInfo, 14 ServerInfo,
12 setAccessTokensToServers, 15 setAccessTokensToServers,
13 uploadVideo 16 uploadVideo
14} from '../../utils' 17} from '../../../../shared/utils'
15 18
16const expect = chai.expect 19const expect = chai.expect
17 20
18describe('Test videos history API validator', function () { 21describe('Test videos history API validator', function () {
19 let path: string 22 let watchingPath: string
23 let myHistoryPath = '/api/v1/users/me/history/videos'
24 let myHistoryRemove = myHistoryPath + '/remove'
20 let server: ServerInfo 25 let server: ServerInfo
21 26
22 // --------------------------------------------------------------- 27 // ---------------------------------------------------------------
@@ -33,14 +38,14 @@ describe('Test videos history API validator', function () {
33 const res = await uploadVideo(server.url, server.accessToken, {}) 38 const res = await uploadVideo(server.url, server.accessToken, {})
34 const videoUUID = res.body.video.uuid 39 const videoUUID = res.body.video.uuid
35 40
36 path = '/api/v1/videos/' + videoUUID + '/watching' 41 watchingPath = '/api/v1/videos/' + videoUUID + '/watching'
37 }) 42 })
38 43
39 describe('When notifying a user is watching a video', function () { 44 describe('When notifying a user is watching a video', function () {
40 45
41 it('Should fail with an unauthenticated user', async function () { 46 it('Should fail with an unauthenticated user', async function () {
42 const fields = { currentTime: 5 } 47 const fields = { currentTime: 5 }
43 await makePutBodyRequest({ url: server.url, path, fields, statusCodeExpected: 401 }) 48 await makePutBodyRequest({ url: server.url, path: watchingPath, fields, statusCodeExpected: 401 })
44 }) 49 })
45 50
46 it('Should fail with an incorrect video id', async function () { 51 it('Should fail with an incorrect video id', async function () {
@@ -58,13 +63,68 @@ describe('Test videos history API validator', function () {
58 63
59 it('Should fail with a bad current time', async function () { 64 it('Should fail with a bad current time', async function () {
60 const fields = { currentTime: 'hello' } 65 const fields = { currentTime: 'hello' }
61 await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 }) 66 await makePutBodyRequest({ url: server.url, path: watchingPath, fields, token: server.accessToken, statusCodeExpected: 400 })
62 }) 67 })
63 68
64 it('Should succeed with the correct parameters', async function () { 69 it('Should succeed with the correct parameters', async function () {
65 const fields = { currentTime: 5 } 70 const fields = { currentTime: 5 }
66 71
67 await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 204 }) 72 await makePutBodyRequest({ url: server.url, path: watchingPath, fields, token: server.accessToken, statusCodeExpected: 204 })
73 })
74 })
75
76 describe('When listing user videos history', function () {
77 it('Should fail with a bad start pagination', async function () {
78 await checkBadStartPagination(server.url, myHistoryPath, server.accessToken)
79 })
80
81 it('Should fail with a bad count pagination', async function () {
82 await checkBadCountPagination(server.url, myHistoryPath, server.accessToken)
83 })
84
85 it('Should fail with an unauthenticated user', async function () {
86 await makeGetRequest({ url: server.url, path: myHistoryPath, statusCodeExpected: 401 })
87 })
88
89 it('Should succeed with the correct params', async function () {
90 await makeGetRequest({ url: server.url, token: server.accessToken, path: myHistoryPath, statusCodeExpected: 200 })
91 })
92 })
93
94 describe('When removing user videos history', function () {
95 it('Should fail with an unauthenticated user', async function () {
96 await makePostBodyRequest({ url: server.url, path: myHistoryPath + '/remove', statusCodeExpected: 401 })
97 })
98
99 it('Should fail with a bad beforeDate parameter', async function () {
100 const body = { beforeDate: '15' }
101 await makePostBodyRequest({
102 url: server.url,
103 token: server.accessToken,
104 path: myHistoryRemove,
105 fields: body,
106 statusCodeExpected: 400
107 })
108 })
109
110 it('Should succeed with a valid beforeDate param', async function () {
111 const body = { beforeDate: new Date().toISOString() }
112 await makePostBodyRequest({
113 url: server.url,
114 token: server.accessToken,
115 path: myHistoryRemove,
116 fields: body,
117 statusCodeExpected: 204
118 })
119 })
120
121 it('Should succeed without body', async function () {
122 await makePostBodyRequest({
123 url: server.url,
124 token: server.accessToken,
125 path: myHistoryRemove,
126 statusCodeExpected: 204
127 })
68 }) 128 })
69 }) 129 })
70 130
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index bc28e2422..878ffe025 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -8,9 +8,13 @@ import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enu
8import { 8import {
9 createUser, flushTests, getMyUserInformation, getVideo, getVideosList, immutableAssign, killallServers, makeDeleteRequest, 9 createUser, flushTests, getMyUserInformation, getVideo, getVideosList, immutableAssign, killallServers, makeDeleteRequest,
10 makeGetRequest, makeUploadRequest, makePutBodyRequest, removeVideo, runServer, ServerInfo, setAccessTokensToServers, userLogin 10 makeGetRequest, makeUploadRequest, makePutBodyRequest, removeVideo, runServer, ServerInfo, setAccessTokensToServers, userLogin
11} from '../../utils' 11} from '../../../../shared/utils'
12import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 12import {
13import { getAccountsList } from '../../utils/users/accounts' 13 checkBadCountPagination,
14 checkBadSortPagination,
15 checkBadStartPagination
16} from '../../../../shared/utils/requests/check-api-params'
17import { getAccountsList } from '../../../../shared/utils/users/accounts'
14 18
15const expect = chai.expect 19const expect = chai.expect
16 20
@@ -234,7 +238,7 @@ describe('Test videos API validator', function () {
234 }) 238 })
235 239
236 it('Should fail with a long support text', async function () { 240 it('Should fail with a long support text', async function () {
237 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(150) }) 241 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
238 const attaches = baseCorrectAttaches 242 const attaches = baseCorrectAttaches
239 243
240 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 244 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
@@ -317,10 +321,15 @@ describe('Test videos API validator', function () {
317 321
318 it('Should fail without an incorrect input file', async function () { 322 it('Should fail without an incorrect input file', async function () {
319 const fields = baseCorrectParams 323 const fields = baseCorrectParams
320 const attaches = { 324 let attaches = {
321 'videofile': join(__dirname, '..', '..', 'fixtures', 'video_short_fake.webm') 325 'videofile': join(__dirname, '..', '..', 'fixtures', 'video_short_fake.webm')
322 } 326 }
323 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) 327 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
328
329 attaches = {
330 'videofile': join(__dirname, '..', '..', 'fixtures', 'video_short.mkv')
331 }
332 await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
324 }) 333 })
325 334
326 it('Should fail with an incorrect thumbnail file', async function () { 335 it('Should fail with an incorrect thumbnail file', async function () {
@@ -484,7 +493,7 @@ describe('Test videos API validator', function () {
484 }) 493 })
485 494
486 it('Should fail with a long support text', async function () { 495 it('Should fail with a long support text', async function () {
487 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(150) }) 496 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
488 497
489 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 498 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields })
490 }) 499 })
diff --git a/server/tests/api/index-4.ts b/server/tests/api/index-4.ts
new file mode 100644
index 000000000..7d8be2b3d
--- /dev/null
+++ b/server/tests/api/index-4.ts
@@ -0,0 +1,2 @@
1import './redundancy'
2import './activitypub'
diff --git a/server/tests/api/index.ts b/server/tests/api/index.ts
index 2d996dbf9..bc140f860 100644
--- a/server/tests/api/index.ts
+++ b/server/tests/api/index.ts
@@ -2,3 +2,4 @@
2import './index-1' 2import './index-1'
3import './index-2' 3import './index-2'
4import './index-3' 4import './index-3'
5import './index-4'
diff --git a/server/tests/api/redundancy/index.ts b/server/tests/api/redundancy/index.ts
new file mode 100644
index 000000000..8e69b95a6
--- /dev/null
+++ b/server/tests/api/redundancy/index.ts
@@ -0,0 +1 @@
import './redundancy'
diff --git a/server/tests/api/server/redundancy.ts b/server/tests/api/redundancy/redundancy.ts
index 1960854b6..778611fff 100644
--- a/server/tests/api/server/redundancy.ts
+++ b/server/tests/api/redundancy/redundancy.ts
@@ -17,16 +17,17 @@ import {
17 viewVideo, 17 viewVideo,
18 wait, 18 wait,
19 waitUntilLog, 19 waitUntilLog,
20 checkVideoFilesWereRemoved, removeVideo 20 checkVideoFilesWereRemoved, removeVideo, getVideoWithToken, reRunServer, checkSegmentHash
21} from '../../utils' 21} from '../../../../shared/utils'
22import { waitJobs } from '../../utils/server/jobs' 22import { waitJobs } from '../../../../shared/utils/server/jobs'
23
23import * as magnetUtil from 'magnet-uri' 24import * as magnetUtil from 'magnet-uri'
24import { updateRedundancy } from '../../utils/server/redundancy' 25import { updateRedundancy } from '../../../../shared/utils/server/redundancy'
25import { ActorFollow } from '../../../../shared/models/actors' 26import { ActorFollow } from '../../../../shared/models/actors'
26import { readdir } from 'fs-extra' 27import { readdir } from 'fs-extra'
27import { join } from 'path' 28import { join } from 'path'
28import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy' 29import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy'
29import { getStats } from '../../utils/server/stats' 30import { getStats } from '../../../../shared/utils/server/stats'
30import { ServerStats } from '../../../../shared/models/server/server-stats.model' 31import { ServerStats } from '../../../../shared/models/server/server-stats.model'
31 32
32const expect = chai.expect 33const expect = chai.expect
@@ -47,6 +48,11 @@ function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: numbe
47 48
48async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) { 49async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
49 const config = { 50 const config = {
51 transcoding: {
52 hls: {
53 enabled: true
54 }
55 },
50 redundancy: { 56 redundancy: {
51 videos: { 57 videos: {
52 check_interval: '5 seconds', 58 check_interval: '5 seconds',
@@ -54,7 +60,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams:
54 immutableAssign({ 60 immutableAssign({
55 min_lifetime: '1 hour', 61 min_lifetime: '1 hour',
56 strategy: strategy, 62 strategy: strategy,
57 size: '100KB' 63 size: '200KB'
58 }, additionalParams) 64 }, additionalParams)
59 ] 65 ]
60 } 66 }
@@ -84,7 +90,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams:
84 await waitJobs(servers) 90 await waitJobs(servers)
85} 91}
86 92
87async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: string) { 93async function check1WebSeed (videoUUID?: string) {
88 if (!videoUUID) videoUUID = video1Server2UUID 94 if (!videoUUID) videoUUID = video1Server2UUID
89 95
90 const webseeds = [ 96 const webseeds = [
@@ -92,50 +98,21 @@ async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: str
92 ] 98 ]
93 99
94 for (const server of servers) { 100 for (const server of servers) {
95 { 101 // With token to avoid issues with video follow constraints
96 const res = await getVideo(server.url, videoUUID) 102 const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
97 103
98 const video: VideoDetails = res.body 104 const video: VideoDetails = res.body
99 for (const f of video.files) { 105 for (const f of video.files) {
100 checkMagnetWebseeds(f, webseeds, server) 106 checkMagnetWebseeds(f, webseeds, server)
101 }
102 } 107 }
103 } 108 }
104} 109}
105 110
106async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) { 111async function check2Webseeds (videoUUID?: string) {
107 const res = await getStats(servers[0].url)
108 const data: ServerStats = res.body
109
110 expect(data.videosRedundancy).to.have.lengthOf(1)
111 const stat = data.videosRedundancy[0]
112
113 expect(stat.strategy).to.equal(strategy)
114 expect(stat.totalSize).to.equal(102400)
115 expect(stat.totalUsed).to.be.at.least(1).and.below(102401)
116 expect(stat.totalVideoFiles).to.equal(4)
117 expect(stat.totalVideos).to.equal(1)
118}
119
120async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
121 const res = await getStats(servers[0].url)
122 const data: ServerStats = res.body
123
124 expect(data.videosRedundancy).to.have.lengthOf(1)
125
126 const stat = data.videosRedundancy[0]
127 expect(stat.strategy).to.equal(strategy)
128 expect(stat.totalSize).to.equal(102400)
129 expect(stat.totalUsed).to.equal(0)
130 expect(stat.totalVideoFiles).to.equal(0)
131 expect(stat.totalVideos).to.equal(0)
132}
133
134async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: string) {
135 if (!videoUUID) videoUUID = video1Server2UUID 112 if (!videoUUID) videoUUID = video1Server2UUID
136 113
137 const webseeds = [ 114 const webseeds = [
138 'http://localhost:9001/static/webseed/' + videoUUID, 115 'http://localhost:9001/static/redundancy/' + videoUUID,
139 'http://localhost:9002/static/webseed/' + videoUUID 116 'http://localhost:9002/static/webseed/' + videoUUID
140 ] 117 ]
141 118
@@ -147,20 +124,23 @@ async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: st
147 for (const file of video.files) { 124 for (const file of video.files) {
148 checkMagnetWebseeds(file, webseeds, server) 125 checkMagnetWebseeds(file, webseeds, server)
149 126
150 // Only servers 1 and 2 have the video 127 await makeGetRequest({
151 if (server.serverNumber !== 3) { 128 url: servers[0].url,
152 await makeGetRequest({ 129 statusCodeExpected: 200,
153 url: server.url, 130 path: '/static/redundancy/' + `${videoUUID}-${file.resolution.id}.mp4`,
154 statusCodeExpected: 200, 131 contentType: null
155 path: '/static/webseed/' + `${videoUUID}-${file.resolution.id}.mp4`, 132 })
156 contentType: null 133 await makeGetRequest({
157 }) 134 url: servers[1].url,
158 } 135 statusCodeExpected: 200,
136 path: `/static/webseed/${videoUUID}-${file.resolution.id}.mp4`,
137 contentType: null
138 })
159 } 139 }
160 } 140 }
161 141
162 for (const directory of [ 'test1', 'test2' ]) { 142 for (const directory of [ 'test1/redundancy', 'test2/videos' ]) {
163 const files = await readdir(join(root(), directory, 'videos')) 143 const files = await readdir(join(root(), directory))
164 expect(files).to.have.length.at.least(4) 144 expect(files).to.have.length.at.least(4)
165 145
166 for (const resolution of [ 240, 360, 480, 720 ]) { 146 for (const resolution of [ 240, 360, 480, 720 ]) {
@@ -169,6 +149,85 @@ async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: st
169 } 149 }
170} 150}
171 151
152async function check0PlaylistRedundancies (videoUUID?: string) {
153 if (!videoUUID) videoUUID = video1Server2UUID
154
155 for (const server of servers) {
156 // With token to avoid issues with video follow constraints
157 const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
158 const video: VideoDetails = res.body
159
160 expect(video.streamingPlaylists).to.be.an('array')
161 expect(video.streamingPlaylists).to.have.lengthOf(1)
162 expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(0)
163 }
164}
165
166async function check1PlaylistRedundancies (videoUUID?: string) {
167 if (!videoUUID) videoUUID = video1Server2UUID
168
169 for (const server of servers) {
170 const res = await getVideo(server.url, videoUUID)
171 const video: VideoDetails = res.body
172
173 expect(video.streamingPlaylists).to.have.lengthOf(1)
174 expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(1)
175
176 const redundancy = video.streamingPlaylists[0].redundancies[0]
177
178 expect(redundancy.baseUrl).to.equal(servers[0].url + '/static/redundancy/hls/' + videoUUID)
179 }
180
181 const baseUrlPlaylist = servers[1].url + '/static/playlists/hls'
182 const baseUrlSegment = servers[0].url + '/static/redundancy/hls'
183
184 const res = await getVideo(servers[0].url, videoUUID)
185 const hlsPlaylist = (res.body as VideoDetails).streamingPlaylists[0]
186
187 for (const resolution of [ 240, 360, 480, 720 ]) {
188 await checkSegmentHash(baseUrlPlaylist, baseUrlSegment, videoUUID, resolution, hlsPlaylist)
189 }
190
191 for (const directory of [ 'test1/redundancy/hls', 'test2/playlists/hls' ]) {
192 const files = await readdir(join(root(), directory, videoUUID))
193 expect(files).to.have.length.at.least(4)
194
195 for (const resolution of [ 240, 360, 480, 720 ]) {
196 const filename = `${videoUUID}-${resolution}-fragmented.mp4`
197
198 expect(files.find(f => f === filename)).to.not.be.undefined
199 }
200 }
201}
202
203async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
204 const res = await getStats(servers[0].url)
205 const data: ServerStats = res.body
206
207 expect(data.videosRedundancy).to.have.lengthOf(1)
208 const stat = data.videosRedundancy[0]
209
210 expect(stat.strategy).to.equal(strategy)
211 expect(stat.totalSize).to.equal(204800)
212 expect(stat.totalUsed).to.be.at.least(1).and.below(204801)
213 expect(stat.totalVideoFiles).to.equal(4)
214 expect(stat.totalVideos).to.equal(1)
215}
216
217async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
218 const res = await getStats(servers[0].url)
219 const data: ServerStats = res.body
220
221 expect(data.videosRedundancy).to.have.lengthOf(1)
222
223 const stat = data.videosRedundancy[0]
224 expect(stat.strategy).to.equal(strategy)
225 expect(stat.totalSize).to.equal(204800)
226 expect(stat.totalUsed).to.equal(0)
227 expect(stat.totalVideoFiles).to.equal(0)
228 expect(stat.totalVideos).to.equal(0)
229}
230
172async function enableRedundancyOnServer1 () { 231async function enableRedundancyOnServer1 () {
173 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true) 232 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
174 233
@@ -215,7 +274,8 @@ describe('Test videos redundancy', function () {
215 }) 274 })
216 275
217 it('Should have 1 webseed on the first video', async function () { 276 it('Should have 1 webseed on the first video', async function () {
218 await check1WebSeed(strategy) 277 await check1WebSeed()
278 await check0PlaylistRedundancies()
219 await checkStatsWith1Webseed(strategy) 279 await checkStatsWith1Webseed(strategy)
220 }) 280 })
221 281
@@ -223,28 +283,30 @@ describe('Test videos redundancy', function () {
223 return enableRedundancyOnServer1() 283 return enableRedundancyOnServer1()
224 }) 284 })
225 285
226 it('Should have 2 webseed on the first video', async function () { 286 it('Should have 2 webseeds on the first video', async function () {
227 this.timeout(40000) 287 this.timeout(80000)
228 288
229 await waitJobs(servers) 289 await waitJobs(servers)
230 await waitUntilLog(servers[0], 'Duplicated ', 4) 290 await waitUntilLog(servers[0], 'Duplicated ', 5)
231 await waitJobs(servers) 291 await waitJobs(servers)
232 292
233 await check2Webseeds(strategy) 293 await check2Webseeds()
294 await check1PlaylistRedundancies()
234 await checkStatsWith2Webseed(strategy) 295 await checkStatsWith2Webseed(strategy)
235 }) 296 })
236 297
237 it('Should undo redundancy on server 1 and remove duplicated videos', async function () { 298 it('Should undo redundancy on server 1 and remove duplicated videos', async function () {
238 this.timeout(40000) 299 this.timeout(80000)
239 300
240 await disableRedundancyOnServer1() 301 await disableRedundancyOnServer1()
241 302
242 await waitJobs(servers) 303 await waitJobs(servers)
243 await wait(5000) 304 await wait(5000)
244 305
245 await check1WebSeed(strategy) 306 await check1WebSeed()
307 await check0PlaylistRedundancies()
246 308
247 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ]) 309 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos', join('playlists', 'hls') ])
248 }) 310 })
249 311
250 after(function () { 312 after(function () {
@@ -262,7 +324,8 @@ describe('Test videos redundancy', function () {
262 }) 324 })
263 325
264 it('Should have 1 webseed on the first video', async function () { 326 it('Should have 1 webseed on the first video', async function () {
265 await check1WebSeed(strategy) 327 await check1WebSeed()
328 await check0PlaylistRedundancies()
266 await checkStatsWith1Webseed(strategy) 329 await checkStatsWith1Webseed(strategy)
267 }) 330 })
268 331
@@ -270,26 +333,28 @@ describe('Test videos redundancy', function () {
270 return enableRedundancyOnServer1() 333 return enableRedundancyOnServer1()
271 }) 334 })
272 335
273 it('Should have 2 webseed on the first video', async function () { 336 it('Should have 2 webseeds on the first video', async function () {
274 this.timeout(40000) 337 this.timeout(80000)
275 338
276 await waitJobs(servers) 339 await waitJobs(servers)
277 await waitUntilLog(servers[0], 'Duplicated ', 4) 340 await waitUntilLog(servers[0], 'Duplicated ', 5)
278 await waitJobs(servers) 341 await waitJobs(servers)
279 342
280 await check2Webseeds(strategy) 343 await check2Webseeds()
344 await check1PlaylistRedundancies()
281 await checkStatsWith2Webseed(strategy) 345 await checkStatsWith2Webseed(strategy)
282 }) 346 })
283 347
284 it('Should unfollow on server 1 and remove duplicated videos', async function () { 348 it('Should unfollow on server 1 and remove duplicated videos', async function () {
285 this.timeout(40000) 349 this.timeout(80000)
286 350
287 await unfollow(servers[0].url, servers[0].accessToken, servers[1]) 351 await unfollow(servers[0].url, servers[0].accessToken, servers[1])
288 352
289 await waitJobs(servers) 353 await waitJobs(servers)
290 await wait(5000) 354 await wait(5000)
291 355
292 await check1WebSeed(strategy) 356 await check1WebSeed()
357 await check0PlaylistRedundancies()
293 358
294 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ]) 359 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
295 }) 360 })
@@ -309,7 +374,8 @@ describe('Test videos redundancy', function () {
309 }) 374 })
310 375
311 it('Should have 1 webseed on the first video', async function () { 376 it('Should have 1 webseed on the first video', async function () {
312 await check1WebSeed(strategy) 377 await check1WebSeed()
378 await check0PlaylistRedundancies()
313 await checkStatsWith1Webseed(strategy) 379 await checkStatsWith1Webseed(strategy)
314 }) 380 })
315 381
@@ -318,18 +384,19 @@ describe('Test videos redundancy', function () {
318 }) 384 })
319 385
320 it('Should still have 1 webseed on the first video', async function () { 386 it('Should still have 1 webseed on the first video', async function () {
321 this.timeout(40000) 387 this.timeout(80000)
322 388
323 await waitJobs(servers) 389 await waitJobs(servers)
324 await wait(15000) 390 await wait(15000)
325 await waitJobs(servers) 391 await waitJobs(servers)
326 392
327 await check1WebSeed(strategy) 393 await check1WebSeed()
394 await check0PlaylistRedundancies()
328 await checkStatsWith1Webseed(strategy) 395 await checkStatsWith1Webseed(strategy)
329 }) 396 })
330 397
331 it('Should view 2 times the first video to have > min_views config', async function () { 398 it('Should view 2 times the first video to have > min_views config', async function () {
332 this.timeout(40000) 399 this.timeout(80000)
333 400
334 await viewVideo(servers[ 0 ].url, video1Server2UUID) 401 await viewVideo(servers[ 0 ].url, video1Server2UUID)
335 await viewVideo(servers[ 2 ].url, video1Server2UUID) 402 await viewVideo(servers[ 2 ].url, video1Server2UUID)
@@ -338,14 +405,15 @@ describe('Test videos redundancy', function () {
338 await waitJobs(servers) 405 await waitJobs(servers)
339 }) 406 })
340 407
341 it('Should have 2 webseed on the first video', async function () { 408 it('Should have 2 webseeds on the first video', async function () {
342 this.timeout(40000) 409 this.timeout(80000)
343 410
344 await waitJobs(servers) 411 await waitJobs(servers)
345 await waitUntilLog(servers[0], 'Duplicated ', 4) 412 await waitUntilLog(servers[0], 'Duplicated ', 5)
346 await waitJobs(servers) 413 await waitJobs(servers)
347 414
348 await check2Webseeds(strategy) 415 await check2Webseeds()
416 await check1PlaylistRedundancies()
349 await checkStatsWith2Webseed(strategy) 417 await checkStatsWith2Webseed(strategy)
350 }) 418 })
351 419
@@ -400,7 +468,7 @@ describe('Test videos redundancy', function () {
400 }) 468 })
401 469
402 it('Should still have 2 webseeds after 10 seconds', async function () { 470 it('Should still have 2 webseeds after 10 seconds', async function () {
403 this.timeout(40000) 471 this.timeout(80000)
404 472
405 await wait(10000) 473 await wait(10000)
406 474
@@ -415,11 +483,11 @@ describe('Test videos redundancy', function () {
415 }) 483 })
416 484
417 it('Should stop server 1 and expire video redundancy', async function () { 485 it('Should stop server 1 and expire video redundancy', async function () {
418 this.timeout(40000) 486 this.timeout(80000)
419 487
420 killallServers([ servers[0] ]) 488 killallServers([ servers[0] ])
421 489
422 await wait(10000) 490 await wait(15000)
423 491
424 await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2Flocalhost%3A9001') 492 await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2Flocalhost%3A9001')
425 }) 493 })
@@ -441,41 +509,60 @@ describe('Test videos redundancy', function () {
441 await enableRedundancyOnServer1() 509 await enableRedundancyOnServer1()
442 510
443 await waitJobs(servers) 511 await waitJobs(servers)
444 await waitUntilLog(servers[0], 'Duplicated ', 4) 512 await waitUntilLog(servers[0], 'Duplicated ', 5)
445 await waitJobs(servers) 513 await waitJobs(servers)
446 514
447 await check2Webseeds(strategy) 515 await check2Webseeds()
516 await check1PlaylistRedundancies()
448 await checkStatsWith2Webseed(strategy) 517 await checkStatsWith2Webseed(strategy)
449 518
450 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' }) 519 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
451 video2Server2UUID = res.body.video.uuid 520 video2Server2UUID = res.body.video.uuid
452 }) 521 })
453 522
454 it('Should cache video 2 webseed on the first video', async function () { 523 it('Should cache video 2 webseeds on the first video', async function () {
455 this.timeout(50000) 524 this.timeout(120000)
456 525
457 await waitJobs(servers) 526 await waitJobs(servers)
458 527
459 await wait(7000) 528 let checked = false
460 529
461 try { 530 while (checked === false) {
462 await check1WebSeed(strategy, video1Server2UUID) 531 await wait(1000)
463 await check2Webseeds(strategy, video2Server2UUID)
464 } catch {
465 await wait(3000)
466 532
467 try { 533 try {
468 await check1WebSeed(strategy, video1Server2UUID) 534 await check1WebSeed(video1Server2UUID)
469 await check2Webseeds(strategy, video2Server2UUID) 535 await check0PlaylistRedundancies(video1Server2UUID)
470 } catch { 536 await check2Webseeds(video2Server2UUID)
471 await wait(5000) 537 await check1PlaylistRedundancies(video2Server2UUID)
472 538
473 await check1WebSeed(strategy, video1Server2UUID) 539 checked = true
474 await check2Webseeds(strategy, video2Server2UUID) 540 } catch {
541 checked = false
475 } 542 }
476 } 543 }
477 }) 544 })
478 545
546 it('Should disable strategy and remove redundancies', async function () {
547 this.timeout(80000)
548
549 await waitJobs(servers)
550
551 killallServers([ servers[ 0 ] ])
552 await reRunServer(servers[ 0 ], {
553 redundancy: {
554 videos: {
555 check_interval: '1 second',
556 strategies: []
557 }
558 }
559 })
560
561 await waitJobs(servers)
562
563 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ join('redundancy', 'hls') ])
564 })
565
479 after(function () { 566 after(function () {
480 return cleanServers() 567 return cleanServers()
481 }) 568 })
diff --git a/server/tests/api/search/search-activitypub-video-channels.ts b/server/tests/api/search/search-activitypub-video-channels.ts
index a287c5bdf..a411e973b 100644
--- a/server/tests/api/search/search-activitypub-video-channels.ts
+++ b/server/tests/api/search/search-activitypub-video-channels.ts
@@ -17,10 +17,10 @@ import {
17 uploadVideo, 17 uploadVideo,
18 userLogin, 18 userLogin,
19 wait 19 wait
20} from '../../utils' 20} from '../../../../shared/utils'
21import { waitJobs } from '../../utils/server/jobs' 21import { waitJobs } from '../../../../shared/utils/server/jobs'
22import { VideoChannel } from '../../../../shared/models/videos' 22import { VideoChannel } from '../../../../shared/models/videos'
23import { searchVideoChannel } from '../../utils/search/video-channels' 23import { searchVideoChannel } from '../../../../shared/utils/search/video-channels'
24 24
25const expect = chai.expect 25const expect = chai.expect
26 26
diff --git a/server/tests/api/search/search-activitypub-videos.ts b/server/tests/api/search/search-activitypub-videos.ts
index 28f4fac50..f881917e7 100644
--- a/server/tests/api/search/search-activitypub-videos.ts
+++ b/server/tests/api/search/search-activitypub-videos.ts
@@ -16,8 +16,8 @@ import {
16 uploadVideo, 16 uploadVideo,
17 wait, 17 wait,
18 searchVideo 18 searchVideo
19} from '../../utils' 19} from '../../../../shared/utils'
20import { waitJobs } from '../../utils/server/jobs' 20import { waitJobs } from '../../../../shared/utils/server/jobs'
21import { Video, VideoPrivacy } from '../../../../shared/models/videos' 21import { Video, VideoPrivacy } from '../../../../shared/models/videos'
22 22
23const expect = chai.expect 23const expect = chai.expect
diff --git a/server/tests/api/search/search-videos.ts b/server/tests/api/search/search-videos.ts
index f1392ffea..50da837da 100644
--- a/server/tests/api/search/search-videos.ts
+++ b/server/tests/api/search/search-videos.ts
@@ -13,7 +13,7 @@ import {
13 uploadVideo, 13 uploadVideo,
14 wait, 14 wait,
15 immutableAssign 15 immutableAssign
16} from '../../utils' 16} from '../../../../shared/utils'
17 17
18const expect = chai.expect 18const expect = chai.expect
19 19
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index facd1688d..0dfe6e4fe 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -4,8 +4,11 @@ import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { About } from '../../../../shared/models/server/about.model' 5import { About } from '../../../../shared/models/server/about.model'
6import { CustomConfig } from '../../../../shared/models/server/custom-config.model' 6import { CustomConfig } from '../../../../shared/models/server/custom-config.model'
7import { deleteCustomConfig, getAbout, killallServers, reRunServer } from '../../utils'
8import { 7import {
8 deleteCustomConfig,
9 getAbout,
10 killallServers,
11 reRunServer,
9 flushTests, 12 flushTests,
10 getConfig, 13 getConfig,
11 getCustomConfig, 14 getCustomConfig,
@@ -13,7 +16,8 @@ import {
13 runServer, 16 runServer,
14 setAccessTokensToServers, 17 setAccessTokensToServers,
15 updateCustomConfig 18 updateCustomConfig
16} from '../../utils/index' 19} from '../../../../shared/utils'
20import { ServerConfig } from '../../../../shared/models'
17 21
18const expect = chai.expect 22const expect = chai.expect
19 23
@@ -29,23 +33,32 @@ function checkInitialConfig (data: CustomConfig) {
29 expect(data.instance.defaultNSFWPolicy).to.equal('display') 33 expect(data.instance.defaultNSFWPolicy).to.equal('display')
30 expect(data.instance.customizations.css).to.be.empty 34 expect(data.instance.customizations.css).to.be.empty
31 expect(data.instance.customizations.javascript).to.be.empty 35 expect(data.instance.customizations.javascript).to.be.empty
36
32 expect(data.services.twitter.username).to.equal('@Chocobozzz') 37 expect(data.services.twitter.username).to.equal('@Chocobozzz')
33 expect(data.services.twitter.whitelisted).to.be.false 38 expect(data.services.twitter.whitelisted).to.be.false
39
34 expect(data.cache.previews.size).to.equal(1) 40 expect(data.cache.previews.size).to.equal(1)
35 expect(data.cache.captions.size).to.equal(1) 41 expect(data.cache.captions.size).to.equal(1)
42
36 expect(data.signup.enabled).to.be.true 43 expect(data.signup.enabled).to.be.true
37 expect(data.signup.limit).to.equal(4) 44 expect(data.signup.limit).to.equal(4)
38 expect(data.signup.requiresEmailVerification).to.be.false 45 expect(data.signup.requiresEmailVerification).to.be.false
46
39 expect(data.admin.email).to.equal('admin1@example.com') 47 expect(data.admin.email).to.equal('admin1@example.com')
48 expect(data.contactForm.enabled).to.be.true
49
40 expect(data.user.videoQuota).to.equal(5242880) 50 expect(data.user.videoQuota).to.equal(5242880)
41 expect(data.user.videoQuotaDaily).to.equal(-1) 51 expect(data.user.videoQuotaDaily).to.equal(-1)
42 expect(data.transcoding.enabled).to.be.false 52 expect(data.transcoding.enabled).to.be.false
53 expect(data.transcoding.allowAdditionalExtensions).to.be.false
43 expect(data.transcoding.threads).to.equal(2) 54 expect(data.transcoding.threads).to.equal(2)
44 expect(data.transcoding.resolutions['240p']).to.be.true 55 expect(data.transcoding.resolutions['240p']).to.be.true
45 expect(data.transcoding.resolutions['360p']).to.be.true 56 expect(data.transcoding.resolutions['360p']).to.be.true
46 expect(data.transcoding.resolutions['480p']).to.be.true 57 expect(data.transcoding.resolutions['480p']).to.be.true
47 expect(data.transcoding.resolutions['720p']).to.be.true 58 expect(data.transcoding.resolutions['720p']).to.be.true
48 expect(data.transcoding.resolutions['1080p']).to.be.true 59 expect(data.transcoding.resolutions['1080p']).to.be.true
60 expect(data.transcoding.hls.enabled).to.be.true
61
49 expect(data.import.videos.http.enabled).to.be.true 62 expect(data.import.videos.http.enabled).to.be.true
50 expect(data.import.videos.torrent.enabled).to.be.true 63 expect(data.import.videos.torrent.enabled).to.be.true
51} 64}
@@ -59,23 +72,33 @@ function checkUpdatedConfig (data: CustomConfig) {
59 expect(data.instance.defaultNSFWPolicy).to.equal('blur') 72 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
60 expect(data.instance.customizations.javascript).to.equal('alert("coucou")') 73 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
61 expect(data.instance.customizations.css).to.equal('body { background-color: red; }') 74 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
75
62 expect(data.services.twitter.username).to.equal('@Kuja') 76 expect(data.services.twitter.username).to.equal('@Kuja')
63 expect(data.services.twitter.whitelisted).to.be.true 77 expect(data.services.twitter.whitelisted).to.be.true
78
64 expect(data.cache.previews.size).to.equal(2) 79 expect(data.cache.previews.size).to.equal(2)
65 expect(data.cache.captions.size).to.equal(3) 80 expect(data.cache.captions.size).to.equal(3)
81
66 expect(data.signup.enabled).to.be.false 82 expect(data.signup.enabled).to.be.false
67 expect(data.signup.limit).to.equal(5) 83 expect(data.signup.limit).to.equal(5)
68 expect(data.signup.requiresEmailVerification).to.be.true 84 expect(data.signup.requiresEmailVerification).to.be.true
85
69 expect(data.admin.email).to.equal('superadmin1@example.com') 86 expect(data.admin.email).to.equal('superadmin1@example.com')
87 expect(data.contactForm.enabled).to.be.false
88
70 expect(data.user.videoQuota).to.equal(5242881) 89 expect(data.user.videoQuota).to.equal(5242881)
71 expect(data.user.videoQuotaDaily).to.equal(318742) 90 expect(data.user.videoQuotaDaily).to.equal(318742)
91
72 expect(data.transcoding.enabled).to.be.true 92 expect(data.transcoding.enabled).to.be.true
73 expect(data.transcoding.threads).to.equal(1) 93 expect(data.transcoding.threads).to.equal(1)
94 expect(data.transcoding.allowAdditionalExtensions).to.be.true
74 expect(data.transcoding.resolutions['240p']).to.be.false 95 expect(data.transcoding.resolutions['240p']).to.be.false
75 expect(data.transcoding.resolutions['360p']).to.be.true 96 expect(data.transcoding.resolutions['360p']).to.be.true
76 expect(data.transcoding.resolutions['480p']).to.be.true 97 expect(data.transcoding.resolutions['480p']).to.be.true
77 expect(data.transcoding.resolutions['720p']).to.be.false 98 expect(data.transcoding.resolutions['720p']).to.be.false
78 expect(data.transcoding.resolutions['1080p']).to.be.false 99 expect(data.transcoding.resolutions['1080p']).to.be.false
100 expect(data.transcoding.hls.enabled).to.be.false
101
79 expect(data.import.videos.http.enabled).to.be.false 102 expect(data.import.videos.http.enabled).to.be.false
80 expect(data.import.videos.torrent.enabled).to.be.false 103 expect(data.import.videos.torrent.enabled).to.be.false
81} 104}
@@ -93,7 +116,7 @@ describe('Test config', function () {
93 116
94 it('Should have a correct config on a server with registration enabled', async function () { 117 it('Should have a correct config on a server with registration enabled', async function () {
95 const res = await getConfig(server.url) 118 const res = await getConfig(server.url)
96 const data = res.body 119 const data: ServerConfig = res.body
97 120
98 expect(data.signup.allowed).to.be.true 121 expect(data.signup.allowed).to.be.true
99 }) 122 })
@@ -108,11 +131,23 @@ describe('Test config', function () {
108 ]) 131 ])
109 132
110 const res = await getConfig(server.url) 133 const res = await getConfig(server.url)
111 const data = res.body 134 const data: ServerConfig = res.body
112 135
113 expect(data.signup.allowed).to.be.false 136 expect(data.signup.allowed).to.be.false
114 }) 137 })
115 138
139 it('Should have the correct video allowed extensions', async function () {
140 const res = await getConfig(server.url)
141 const data: ServerConfig = res.body
142
143 expect(data.video.file.extensions).to.have.lengthOf(3)
144 expect(data.video.file.extensions).to.contain('.mp4')
145 expect(data.video.file.extensions).to.contain('.webm')
146 expect(data.video.file.extensions).to.contain('.ogv')
147
148 expect(data.contactForm.enabled).to.be.true
149 })
150
116 it('Should get the customized configuration', async function () { 151 it('Should get the customized configuration', async function () {
117 const res = await getCustomConfig(server.url, server.accessToken) 152 const res = await getCustomConfig(server.url, server.accessToken)
118 const data = res.body as CustomConfig 153 const data = res.body as CustomConfig
@@ -156,12 +191,16 @@ describe('Test config', function () {
156 admin: { 191 admin: {
157 email: 'superadmin1@example.com' 192 email: 'superadmin1@example.com'
158 }, 193 },
194 contactForm: {
195 enabled: false
196 },
159 user: { 197 user: {
160 videoQuota: 5242881, 198 videoQuota: 5242881,
161 videoQuotaDaily: 318742 199 videoQuotaDaily: 318742
162 }, 200 },
163 transcoding: { 201 transcoding: {
164 enabled: true, 202 enabled: true,
203 allowAdditionalExtensions: true,
165 threads: 1, 204 threads: 1,
166 resolutions: { 205 resolutions: {
167 '240p': false, 206 '240p': false,
@@ -169,6 +208,9 @@ describe('Test config', function () {
169 '480p': true, 208 '480p': true,
170 '720p': false, 209 '720p': false,
171 '1080p': false 210 '1080p': false
211 },
212 hls: {
213 enabled: false
172 } 214 }
173 }, 215 },
174 import: { 216 import: {
@@ -190,6 +232,18 @@ describe('Test config', function () {
190 checkUpdatedConfig(data) 232 checkUpdatedConfig(data)
191 }) 233 })
192 234
235 it('Should have the correct updated video allowed extensions', async function () {
236 const res = await getConfig(server.url)
237 const data: ServerConfig = res.body
238
239 expect(data.video.file.extensions).to.have.length.above(3)
240 expect(data.video.file.extensions).to.contain('.mp4')
241 expect(data.video.file.extensions).to.contain('.webm')
242 expect(data.video.file.extensions).to.contain('.ogv')
243 expect(data.video.file.extensions).to.contain('.flv')
244 expect(data.video.file.extensions).to.contain('.mkv')
245 })
246
193 it('Should have the configuration updated after a restart', async function () { 247 it('Should have the configuration updated after a restart', async function () {
194 this.timeout(10000) 248 this.timeout(10000)
195 249
diff --git a/server/tests/api/server/contact-form.ts b/server/tests/api/server/contact-form.ts
new file mode 100644
index 000000000..93221d0a3
--- /dev/null
+++ b/server/tests/api/server/contact-form.ts
@@ -0,0 +1,86 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, wait } from '../../../../shared/utils'
6import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
7import { waitJobs } from '../../../../shared/utils/server/jobs'
8import { sendContactForm } from '../../../../shared/utils/server/contact-form'
9
10const expect = chai.expect
11
12describe('Test contact form', function () {
13 let server: ServerInfo
14 const emails: object[] = []
15
16 before(async function () {
17 this.timeout(30000)
18
19 await MockSmtpServer.Instance.collectEmails(emails)
20
21 await flushTests()
22
23 const overrideConfig = {
24 smtp: {
25 hostname: 'localhost'
26 }
27 }
28 server = await runServer(1, overrideConfig)
29 await setAccessTokensToServers([ server ])
30 })
31
32 it('Should send a contact form', async function () {
33 this.timeout(10000)
34
35 await sendContactForm({
36 url: server.url,
37 fromEmail: 'toto@example.com',
38 body: 'my super message',
39 fromName: 'Super toto'
40 })
41
42 await waitJobs(server)
43
44 expect(emails).to.have.lengthOf(1)
45
46 const email = emails[0]
47
48 expect(email['from'][0]['address']).equal('toto@example.com')
49 expect(email['to'][0]['address']).equal('admin1@example.com')
50 expect(email['subject']).contains('Contact form')
51 expect(email['text']).contains('my super message')
52 })
53
54 it('Should not be able to send another contact form because of the anti spam checker', async function () {
55 await sendContactForm({
56 url: server.url,
57 fromEmail: 'toto@example.com',
58 body: 'my super message',
59 fromName: 'Super toto'
60 })
61
62 await sendContactForm({
63 url: server.url,
64 fromEmail: 'toto@example.com',
65 body: 'my super message',
66 fromName: 'Super toto',
67 expectedStatus: 403
68 })
69 })
70
71 it('Should be able to send another contact form after a while', async function () {
72 await wait(1000)
73
74 await sendContactForm({
75 url: server.url,
76 fromEmail: 'toto@example.com',
77 body: 'my super message',
78 fromName: 'Super toto'
79 })
80 })
81
82 after(async function () {
83 MockSmtpServer.Instance.kill()
84 killallServers([ server ])
85 })
86})
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index 713a27143..f96c57b66 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -14,11 +14,14 @@ import {
14 unblockUser, 14 unblockUser,
15 uploadVideo, 15 uploadVideo,
16 userLogin, 16 userLogin,
17 verifyEmail 17 verifyEmail,
18} from '../../utils' 18 flushTests,
19import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' 19 killallServers,
20import { mockSmtpServer } from '../../utils/miscs/email' 20 ServerInfo,
21import { waitJobs } from '../../utils/server/jobs' 21 setAccessTokensToServers
22} from '../../../../shared/utils'
23import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
24import { waitJobs } from '../../../../shared/utils/server/jobs'
22 25
23const expect = chai.expect 26const expect = chai.expect
24 27
@@ -38,7 +41,7 @@ describe('Test emails', function () {
38 before(async function () { 41 before(async function () {
39 this.timeout(30000) 42 this.timeout(30000)
40 43
41 await mockSmtpServer(emails) 44 await MockSmtpServer.Instance.collectEmails(emails)
42 45
43 await flushTests() 46 await flushTests()
44 47
@@ -248,6 +251,7 @@ describe('Test emails', function () {
248 }) 251 })
249 252
250 after(async function () { 253 after(async function () {
254 MockSmtpServer.Instance.kill()
251 killallServers([ server ]) 255 killallServers([ server ])
252 }) 256 })
253}) 257})
diff --git a/server/tests/api/server/follow-constraints.ts b/server/tests/api/server/follow-constraints.ts
new file mode 100644
index 000000000..8bb073c41
--- /dev/null
+++ b/server/tests/api/server/follow-constraints.ts
@@ -0,0 +1,225 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 doubleFollow,
7 getAccountVideos,
8 getVideo,
9 getVideoChannelVideos,
10 getVideoWithToken,
11 flushAndRunMultipleServers,
12 killallServers,
13 ServerInfo,
14 setAccessTokensToServers,
15 uploadVideo
16} from '../../../../shared/utils'
17import { unfollow } from '../../../../shared/utils/server/follows'
18import { userLogin } from '../../../../shared/utils/users/login'
19import { createUser } from '../../../../shared/utils/users/users'
20
21const expect = chai.expect
22
23describe('Test follow constraints', function () {
24 let servers: ServerInfo[] = []
25 let video1UUID: string
26 let video2UUID: string
27 let userAccessToken: string
28
29 before(async function () {
30 this.timeout(30000)
31
32 servers = await flushAndRunMultipleServers(2)
33
34 // Get the access tokens
35 await setAccessTokensToServers(servers)
36
37 {
38 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video server 1' })
39 video1UUID = res.body.video.uuid
40 }
41 {
42 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video server 2' })
43 video2UUID = res.body.video.uuid
44 }
45
46 const user = {
47 username: 'user1',
48 password: 'super_password'
49 }
50 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
51 userAccessToken = await userLogin(servers[0], user)
52
53 await doubleFollow(servers[0], servers[1])
54 })
55
56 describe('With a followed instance', function () {
57
58 describe('With an unlogged user', function () {
59
60 it('Should get the local video', async function () {
61 await getVideo(servers[0].url, video1UUID, 200)
62 })
63
64 it('Should get the remote video', async function () {
65 await getVideo(servers[0].url, video2UUID, 200)
66 })
67
68 it('Should list local account videos', async function () {
69 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9001', 0, 5)
70
71 expect(res.body.total).to.equal(1)
72 expect(res.body.data).to.have.lengthOf(1)
73 })
74
75 it('Should list remote account videos', async function () {
76 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9002', 0, 5)
77
78 expect(res.body.total).to.equal(1)
79 expect(res.body.data).to.have.lengthOf(1)
80 })
81
82 it('Should list local channel videos', async function () {
83 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9001', 0, 5)
84
85 expect(res.body.total).to.equal(1)
86 expect(res.body.data).to.have.lengthOf(1)
87 })
88
89 it('Should list remote channel videos', async function () {
90 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9002', 0, 5)
91
92 expect(res.body.total).to.equal(1)
93 expect(res.body.data).to.have.lengthOf(1)
94 })
95 })
96
97 describe('With a logged user', function () {
98 it('Should get the local video', async function () {
99 await getVideoWithToken(servers[0].url, userAccessToken, video1UUID, 200)
100 })
101
102 it('Should get the remote video', async function () {
103 await getVideoWithToken(servers[0].url, userAccessToken, video2UUID, 200)
104 })
105
106 it('Should list local account videos', async function () {
107 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9001', 0, 5)
108
109 expect(res.body.total).to.equal(1)
110 expect(res.body.data).to.have.lengthOf(1)
111 })
112
113 it('Should list remote account videos', async function () {
114 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9002', 0, 5)
115
116 expect(res.body.total).to.equal(1)
117 expect(res.body.data).to.have.lengthOf(1)
118 })
119
120 it('Should list local channel videos', async function () {
121 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9001', 0, 5)
122
123 expect(res.body.total).to.equal(1)
124 expect(res.body.data).to.have.lengthOf(1)
125 })
126
127 it('Should list remote channel videos', async function () {
128 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9002', 0, 5)
129
130 expect(res.body.total).to.equal(1)
131 expect(res.body.data).to.have.lengthOf(1)
132 })
133 })
134 })
135
136 describe('With a non followed instance', function () {
137
138 before(async function () {
139 this.timeout(30000)
140
141 await unfollow(servers[0].url, servers[0].accessToken, servers[1])
142 })
143
144 describe('With an unlogged user', function () {
145
146 it('Should get the local video', async function () {
147 await getVideo(servers[0].url, video1UUID, 200)
148 })
149
150 it('Should not get the remote video', async function () {
151 await getVideo(servers[0].url, video2UUID, 403)
152 })
153
154 it('Should list local account videos', async function () {
155 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9001', 0, 5)
156
157 expect(res.body.total).to.equal(1)
158 expect(res.body.data).to.have.lengthOf(1)
159 })
160
161 it('Should not list remote account videos', async function () {
162 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9002', 0, 5)
163
164 expect(res.body.total).to.equal(0)
165 expect(res.body.data).to.have.lengthOf(0)
166 })
167
168 it('Should list local channel videos', async function () {
169 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9001', 0, 5)
170
171 expect(res.body.total).to.equal(1)
172 expect(res.body.data).to.have.lengthOf(1)
173 })
174
175 it('Should not list remote channel videos', async function () {
176 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9002', 0, 5)
177
178 expect(res.body.total).to.equal(0)
179 expect(res.body.data).to.have.lengthOf(0)
180 })
181 })
182
183 describe('With a logged user', function () {
184 it('Should get the local video', async function () {
185 await getVideoWithToken(servers[0].url, userAccessToken, video1UUID, 200)
186 })
187
188 it('Should get the remote video', async function () {
189 await getVideoWithToken(servers[0].url, userAccessToken, video2UUID, 200)
190 })
191
192 it('Should list local account videos', async function () {
193 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9001', 0, 5)
194
195 expect(res.body.total).to.equal(1)
196 expect(res.body.data).to.have.lengthOf(1)
197 })
198
199 it('Should list remote account videos', async function () {
200 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9002', 0, 5)
201
202 expect(res.body.total).to.equal(1)
203 expect(res.body.data).to.have.lengthOf(1)
204 })
205
206 it('Should list local channel videos', async function () {
207 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9001', 0, 5)
208
209 expect(res.body.total).to.equal(1)
210 expect(res.body.data).to.have.lengthOf(1)
211 })
212
213 it('Should list remote channel videos', async function () {
214 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9002', 0, 5)
215
216 expect(res.body.total).to.equal(1)
217 expect(res.body.data).to.have.lengthOf(1)
218 })
219 })
220 })
221
222 after(async function () {
223 killallServers(servers)
224 })
225})
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index e8914a9d4..ad4c87c73 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -4,7 +4,7 @@ import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { Video, VideoPrivacy } from '../../../../shared/models/videos' 5import { Video, VideoPrivacy } from '../../../../shared/models/videos'
6import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' 6import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
7import { completeVideoCheck } from '../../utils' 7import { completeVideoCheck } from '../../../../shared/utils'
8import { 8import {
9 flushAndRunMultipleServers, 9 flushAndRunMultipleServers,
10 getVideosList, 10 getVideosList,
@@ -12,21 +12,26 @@ import {
12 ServerInfo, 12 ServerInfo,
13 setAccessTokensToServers, 13 setAccessTokensToServers,
14 uploadVideo 14 uploadVideo
15} from '../../utils/index' 15} from '../../../../shared/utils/index'
16import { dateIsValid } from '../../utils/miscs/miscs' 16import { dateIsValid } from '../../../../shared/utils/miscs/miscs'
17import { follow, getFollowersListPaginationAndSort, getFollowingListPaginationAndSort, unfollow } from '../../utils/server/follows' 17import {
18import { expectAccountFollows } from '../../utils/users/accounts' 18 follow,
19import { userLogin } from '../../utils/users/login' 19 getFollowersListPaginationAndSort,
20import { createUser } from '../../utils/users/users' 20 getFollowingListPaginationAndSort,
21 unfollow
22} from '../../../../shared/utils/server/follows'
23import { expectAccountFollows } from '../../../../shared/utils/users/accounts'
24import { userLogin } from '../../../../shared/utils/users/login'
25import { createUser } from '../../../../shared/utils/users/users'
21import { 26import {
22 addVideoCommentReply, 27 addVideoCommentReply,
23 addVideoCommentThread, 28 addVideoCommentThread,
24 getVideoCommentThreads, 29 getVideoCommentThreads,
25 getVideoThreadComments 30 getVideoThreadComments
26} from '../../utils/videos/video-comments' 31} from '../../../../shared/utils/videos/video-comments'
27import { rateVideo } from '../../utils/videos/videos' 32import { rateVideo } from '../../../../shared/utils/videos/videos'
28import { waitJobs } from '../../utils/server/jobs' 33import { waitJobs } from '../../../../shared/utils/server/jobs'
29import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions' 34import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../../../shared/utils/videos/video-captions'
30import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model' 35import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
31 36
32const expect = chai.expect 37const expect = chai.expect
@@ -93,7 +98,26 @@ describe('Test follows', function () {
93 expect(server3Follow.state).to.equal('accepted') 98 expect(server3Follow.state).to.equal('accepted')
94 }) 99 })
95 100
96 it('Should have 0 followings on server 1 and 2', async function () { 101 it('Should search followings on server 1', async function () {
102 {
103 const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 1, 'createdAt', ':9002')
104 const follows = res.body.data
105
106 expect(res.body.total).to.equal(1)
107 expect(follows.length).to.equal(1)
108 expect(follows[ 0 ].following.host).to.equal('localhost:9002')
109 }
110
111 {
112 const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 1, 'createdAt', 'bla')
113 const follows = res.body.data
114
115 expect(res.body.total).to.equal(0)
116 expect(follows.length).to.equal(0)
117 }
118 })
119
120 it('Should have 0 followings on server 2 and 3', async function () {
97 for (const server of [ servers[1], servers[2] ]) { 121 for (const server of [ servers[1], servers[2] ]) {
98 const res = await getFollowingListPaginationAndSort(server.url, 0, 5, 'createdAt') 122 const res = await getFollowingListPaginationAndSort(server.url, 0, 5, 'createdAt')
99 const follows = res.body.data 123 const follows = res.body.data
@@ -116,6 +140,25 @@ describe('Test follows', function () {
116 } 140 }
117 }) 141 })
118 142
143 it('Should search followers on server 2', async function () {
144 {
145 const res = await getFollowersListPaginationAndSort(servers[ 2 ].url, 0, 5, 'createdAt', '9001')
146 const follows = res.body.data
147
148 expect(res.body.total).to.equal(1)
149 expect(follows.length).to.equal(1)
150 expect(follows[ 0 ].following.host).to.equal('localhost:9003')
151 }
152
153 {
154 const res = await getFollowersListPaginationAndSort(servers[ 2 ].url, 0, 5, 'createdAt', 'bla')
155 const follows = res.body.data
156
157 expect(res.body.total).to.equal(0)
158 expect(follows.length).to.equal(0)
159 }
160 })
161
119 it('Should have 0 followers on server 1', async function () { 162 it('Should have 0 followers on server 1', async function () {
120 const res = await getFollowersListPaginationAndSort(servers[0].url, 0, 5, 'createdAt') 163 const res = await getFollowersListPaginationAndSort(servers[0].url, 0, 5, 'createdAt')
121 const follows = res.body.data 164 const follows = res.body.data
diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts
index b0a3d029a..cd5acbe16 100644
--- a/server/tests/api/server/handle-down.ts
+++ b/server/tests/api/server/handle-down.ts
@@ -5,24 +5,30 @@ import '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/video-comment.model'
8import { completeVideoCheck, getVideo, immutableAssign, reRunServer, unfollow, viewVideo } from '../../utils' 8
9import { 9import {
10 completeVideoCheck,
10 flushAndRunMultipleServers, 11 flushAndRunMultipleServers,
12 getVideo,
11 getVideosList, 13 getVideosList,
14 immutableAssign,
12 killallServers, 15 killallServers,
16 reRunServer,
13 ServerInfo, 17 ServerInfo,
14 setAccessTokensToServers, 18 setAccessTokensToServers,
19 unfollow,
20 updateVideo,
15 uploadVideo, 21 uploadVideo,
16 wait 22 wait
17} from '../../utils/index' 23} from '../../../../shared/utils'
18import { follow, getFollowersListPaginationAndSort } from '../../utils/server/follows' 24import { follow, getFollowersListPaginationAndSort } from '../../../../shared/utils/server/follows'
19import { getJobsListPaginationAndSort, waitJobs } from '../../utils/server/jobs' 25import { getJobsListPaginationAndSort, waitJobs } from '../../../../shared/utils/server/jobs'
20import { 26import {
21 addVideoCommentReply, 27 addVideoCommentReply,
22 addVideoCommentThread, 28 addVideoCommentThread,
23 getVideoCommentThreads, 29 getVideoCommentThreads,
24 getVideoThreadComments 30 getVideoThreadComments
25} from '../../utils/videos/video-comments' 31} from '../../../../shared/utils/videos/video-comments'
26 32
27const expect = chai.expect 33const expect = chai.expect
28 34
@@ -195,15 +201,15 @@ describe('Test handle downs', function () {
195 expect(res.body.data).to.have.lengthOf(2) 201 expect(res.body.data).to.have.lengthOf(2)
196 }) 202 })
197 203
198 it('Should send a view to server 3, and automatically fetch the video', async function () { 204 it('Should send an update to server 3, and automatically fetch the video', async function () {
199 this.timeout(15000) 205 this.timeout(15000)
200 206
201 const res1 = await getVideosList(servers[2].url) 207 const res1 = await getVideosList(servers[2].url)
202 expect(res1.body.data).to.be.an('array') 208 expect(res1.body.data).to.be.an('array')
203 expect(res1.body.data).to.have.lengthOf(11) 209 expect(res1.body.data).to.have.lengthOf(11)
204 210
205 await viewVideo(servers[0].url, missedVideo1.uuid) 211 await updateVideo(servers[0].url, servers[0].accessToken, missedVideo1.uuid, { })
206 await viewVideo(servers[0].url, unlistedVideo.uuid) 212 await updateVideo(servers[0].url, servers[0].accessToken, unlistedVideo.uuid, { })
207 213
208 await waitJobs(servers) 214 await waitJobs(servers)
209 215
diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts
index c74c68a33..1f80cc6cf 100644
--- a/server/tests/api/server/index.ts
+++ b/server/tests/api/server/index.ts
@@ -1,9 +1,11 @@
1import './config' 1import './config'
2import './contact-form'
2import './email' 3import './email'
4import './follow-constraints'
3import './follows' 5import './follows'
4import './handle-down' 6import './handle-down'
5import './jobs' 7import './jobs'
6import './redundancy'
7import './reverse-proxy' 8import './reverse-proxy'
8import './stats' 9import './stats'
9import './tracker' 10import './tracker'
11import './no-client'
diff --git a/server/tests/api/server/jobs.ts b/server/tests/api/server/jobs.ts
index cd59d9a1b..52948b1d6 100644
--- a/server/tests/api/server/jobs.ts
+++ b/server/tests/api/server/jobs.ts
@@ -2,12 +2,12 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' 5import { killallServers, ServerInfo, setAccessTokensToServers } from '../../../../shared/utils/index'
6import { doubleFollow } from '../../utils/server/follows' 6import { doubleFollow } from '../../../../shared/utils/server/follows'
7import { getJobsList, getJobsListPaginationAndSort, waitJobs } from '../../utils/server/jobs' 7import { getJobsList, getJobsListPaginationAndSort, waitJobs } from '../../../../shared/utils/server/jobs'
8import { flushAndRunMultipleServers } from '../../utils/server/servers' 8import { flushAndRunMultipleServers } from '../../../../shared/utils/server/servers'
9import { uploadVideo } from '../../utils/videos/videos' 9import { uploadVideo } from '../../../../shared/utils/videos/videos'
10import { dateIsValid } from '../../utils/miscs/miscs' 10import { dateIsValid } from '../../../../shared/utils/miscs/miscs'
11 11
12const expect = chai.expect 12const expect = chai.expect
13 13
diff --git a/server/tests/api/server/no-client.ts b/server/tests/api/server/no-client.ts
new file mode 100644
index 000000000..3b95ce945
--- /dev/null
+++ b/server/tests/api/server/no-client.ts
@@ -0,0 +1,36 @@
1import 'mocha'
2import * as request from 'supertest'
3import {
4 flushTests,
5 killallServers,
6 ServerInfo
7} from '../../../../shared/utils'
8import { runServer } from '../../../../shared/utils/server/servers'
9
10describe('Start and stop server without web client routes', function () {
11 let server: ServerInfo
12
13 before(async function () {
14 this.timeout(30000)
15
16 await flushTests()
17
18 server = await runServer(1, {}, ['--no-client'])
19 })
20
21 it('Should fail getting the client', function () {
22 const req = request(server.url)
23 .get('/')
24
25 return req.expect(404)
26 })
27
28 after(async function () {
29 killallServers([ server ])
30
31 // Keep the logs if the test failed
32 if (this['ok']) {
33 await flushTests()
34 }
35 })
36})
diff --git a/server/tests/api/server/reverse-proxy.ts b/server/tests/api/server/reverse-proxy.ts
index e2c2a293e..ee0fffd5a 100644
--- a/server/tests/api/server/reverse-proxy.ts
+++ b/server/tests/api/server/reverse-proxy.ts
@@ -15,7 +15,7 @@ import {
15 userLogin, 15 userLogin,
16 viewVideo, 16 viewVideo,
17 wait 17 wait
18} from '../../utils' 18} from '../../../../shared/utils'
19const expect = chai.expect 19const expect = chai.expect
20 20
21import { 21import {
@@ -23,7 +23,7 @@ import {
23 flushTests, 23 flushTests,
24 runServer, 24 runServer,
25 registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig 25 registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
26} from '../../utils/index' 26} from '../../../../shared/utils/index'
27 27
28describe('Test application behind a reverse proxy', function () { 28describe('Test application behind a reverse proxy', function () {
29 let server = null 29 let server = null
@@ -95,7 +95,7 @@ describe('Test application behind a reverse proxy', function () {
95 it('Should rate limit logins', async function () { 95 it('Should rate limit logins', async function () {
96 const user = { username: 'root', password: 'fail' } 96 const user = { username: 'root', password: 'fail' }
97 97
98 for (let i = 0; i < 14; i++) { 98 for (let i = 0; i < 19; i++) {
99 await userLogin(server, user, 400) 99 await userLogin(server, user, 400)
100 } 100 }
101 101
diff --git a/server/tests/api/server/stats.ts b/server/tests/api/server/stats.ts
index cb229e876..aaa6c62f7 100644
--- a/server/tests/api/server/stats.ts
+++ b/server/tests/api/server/stats.ts
@@ -13,11 +13,11 @@ import {
13 uploadVideo, 13 uploadVideo,
14 viewVideo, 14 viewVideo,
15 wait 15 wait
16} from '../../utils' 16} from '../../../../shared/utils'
17import { flushTests, setAccessTokensToServers } from '../../utils/index' 17import { flushTests, setAccessTokensToServers } from '../../../../shared/utils/index'
18import { getStats } from '../../utils/server/stats' 18import { getStats } from '../../../../shared/utils/server/stats'
19import { addVideoCommentThread } from '../../utils/videos/video-comments' 19import { addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
20import { waitJobs } from '../../utils/server/jobs' 20import { waitJobs } from '../../../../shared/utils/server/jobs'
21 21
22const expect = chai.expect 22const expect = chai.expect
23 23
@@ -39,7 +39,7 @@ describe('Test stats (excluding redundancy)', function () {
39 } 39 }
40 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password) 40 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
41 41
42 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, {}) 42 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { fixture: 'video_short.webm' })
43 const videoUUID = resVideo.body.video.uuid 43 const videoUUID = resVideo.body.video.uuid
44 44
45 await addVideoCommentThread(servers[0].url, servers[0].accessToken, videoUUID, 'comment') 45 await addVideoCommentThread(servers[0].url, servers[0].accessToken, videoUUID, 'comment')
@@ -60,6 +60,7 @@ describe('Test stats (excluding redundancy)', function () {
60 expect(data.totalLocalVideoComments).to.equal(1) 60 expect(data.totalLocalVideoComments).to.equal(1)
61 expect(data.totalLocalVideos).to.equal(1) 61 expect(data.totalLocalVideos).to.equal(1)
62 expect(data.totalLocalVideoViews).to.equal(1) 62 expect(data.totalLocalVideoViews).to.equal(1)
63 expect(data.totalLocalVideoFilesSize).to.equal(218910)
63 expect(data.totalUsers).to.equal(2) 64 expect(data.totalUsers).to.equal(2)
64 expect(data.totalVideoComments).to.equal(1) 65 expect(data.totalVideoComments).to.equal(1)
65 expect(data.totalVideos).to.equal(1) 66 expect(data.totalVideos).to.equal(1)
@@ -74,6 +75,7 @@ describe('Test stats (excluding redundancy)', function () {
74 expect(data.totalLocalVideoComments).to.equal(0) 75 expect(data.totalLocalVideoComments).to.equal(0)
75 expect(data.totalLocalVideos).to.equal(0) 76 expect(data.totalLocalVideos).to.equal(0)
76 expect(data.totalLocalVideoViews).to.equal(0) 77 expect(data.totalLocalVideoViews).to.equal(0)
78 expect(data.totalLocalVideoFilesSize).to.equal(0)
77 expect(data.totalUsers).to.equal(1) 79 expect(data.totalUsers).to.equal(1)
78 expect(data.totalVideoComments).to.equal(1) 80 expect(data.totalVideoComments).to.equal(1)
79 expect(data.totalVideos).to.equal(1) 81 expect(data.totalVideos).to.equal(1)
diff --git a/server/tests/api/server/tracker.ts b/server/tests/api/server/tracker.ts
index 856f2f4d1..25ca00029 100644
--- a/server/tests/api/server/tracker.ts
+++ b/server/tests/api/server/tracker.ts
@@ -2,8 +2,8 @@
2 2
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import 'mocha' 4import 'mocha'
5import { getVideo, killallServers, runServer, ServerInfo, uploadVideo } from '../../utils' 5import { getVideo, killallServers, runServer, ServerInfo, uploadVideo } from '../../../../shared/utils'
6import { flushTests, setAccessTokensToServers } from '../../utils/index' 6import { flushTests, setAccessTokensToServers } from '../../../../shared/utils/index'
7import { VideoDetails } from '../../../../shared/models/videos' 7import { VideoDetails } from '../../../../shared/models/videos'
8import * as WebTorrent from 'webtorrent' 8import * as WebTorrent from 'webtorrent'
9 9
diff --git a/server/tests/api/users/blocklist.ts b/server/tests/api/users/blocklist.ts
new file mode 100644
index 000000000..4bca27a94
--- /dev/null
+++ b/server/tests/api/users/blocklist.ts
@@ -0,0 +1,511 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { AccountBlock, ServerBlock, Video } from '../../../../shared/index'
6import {
7 createUser,
8 doubleFollow,
9 flushAndRunMultipleServers,
10 flushTests,
11 killallServers,
12 ServerInfo,
13 uploadVideo,
14 userLogin
15} from '../../../../shared/utils/index'
16import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
17import { getVideosListWithToken, getVideosList } from '../../../../shared/utils/videos/videos'
18import {
19 addVideoCommentReply,
20 addVideoCommentThread,
21 getVideoCommentThreads,
22 getVideoThreadComments
23} from '../../../../shared/utils/videos/video-comments'
24import { waitJobs } from '../../../../shared/utils/server/jobs'
25import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
26import {
27 addAccountToAccountBlocklist,
28 addAccountToServerBlocklist,
29 addServerToAccountBlocklist,
30 addServerToServerBlocklist,
31 getAccountBlocklistByAccount,
32 getAccountBlocklistByServer,
33 getServerBlocklistByAccount,
34 getServerBlocklistByServer,
35 removeAccountFromAccountBlocklist,
36 removeAccountFromServerBlocklist,
37 removeServerFromAccountBlocklist,
38 removeServerFromServerBlocklist
39} from '../../../../shared/utils/users/blocklist'
40
41const expect = chai.expect
42
43async function checkAllVideos (url: string, token: string) {
44 {
45 const res = await getVideosListWithToken(url, token)
46
47 expect(res.body.data).to.have.lengthOf(4)
48 }
49
50 {
51 const res = await getVideosList(url)
52
53 expect(res.body.data).to.have.lengthOf(4)
54 }
55}
56
57async function checkAllComments (url: string, token: string, videoUUID: string) {
58 const resThreads = await getVideoCommentThreads(url, videoUUID, 0, 5, '-createdAt', token)
59
60 const threads: VideoComment[] = resThreads.body.data
61 expect(threads).to.have.lengthOf(2)
62
63 for (const thread of threads) {
64 const res = await getVideoThreadComments(url, videoUUID, thread.id, token)
65
66 const tree: VideoCommentThreadTree = res.body
67 expect(tree.children).to.have.lengthOf(1)
68 }
69}
70
71describe('Test blocklist', function () {
72 let servers: ServerInfo[]
73 let videoUUID1: string
74 let videoUUID2: string
75 let userToken1: string
76 let userModeratorToken: string
77 let userToken2: string
78
79 before(async function () {
80 this.timeout(60000)
81
82 await flushTests()
83
84 servers = await flushAndRunMultipleServers(2)
85 await setAccessTokensToServers(servers)
86
87 {
88 const user = { username: 'user1', password: 'password' }
89 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
90
91 userToken1 = await userLogin(servers[0], user)
92 await uploadVideo(servers[0].url, userToken1, { name: 'video user 1' })
93 }
94
95 {
96 const user = { username: 'moderator', password: 'password' }
97 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
98
99 userModeratorToken = await userLogin(servers[0], user)
100 }
101
102 {
103 const user = { username: 'user2', password: 'password' }
104 await createUser(servers[1].url, servers[1].accessToken, user.username, user.password)
105
106 userToken2 = await userLogin(servers[1], user)
107 await uploadVideo(servers[1].url, userToken2, { name: 'video user 2' })
108 }
109
110 {
111 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video server 1' })
112 videoUUID1 = res.body.video.uuid
113 }
114
115 {
116 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video server 2' })
117 videoUUID2 = res.body.video.uuid
118 }
119
120 await doubleFollow(servers[0], servers[1])
121
122 {
123 const resComment = await addVideoCommentThread(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1, 'comment root 1')
124 const resReply = await addVideoCommentReply(servers[ 0 ].url, userToken1, videoUUID1, resComment.body.comment.id, 'comment user 1')
125 await addVideoCommentReply(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1, resReply.body.comment.id, 'comment root 1')
126 }
127
128 {
129 const resComment = await addVideoCommentThread(servers[ 0 ].url, userToken1, videoUUID1, 'comment user 1')
130 await addVideoCommentReply(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1, resComment.body.comment.id, 'comment root 1')
131 }
132
133 await waitJobs(servers)
134 })
135
136 describe('User blocklist', function () {
137
138 describe('When managing account blocklist', function () {
139 it('Should list all videos', function () {
140 return checkAllVideos(servers[ 0 ].url, servers[ 0 ].accessToken)
141 })
142
143 it('Should list the comments', function () {
144 return checkAllComments(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1)
145 })
146
147 it('Should block a remote account', async function () {
148 await addAccountToAccountBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user2@localhost:9002')
149 })
150
151 it('Should hide its videos', async function () {
152 const res = await getVideosListWithToken(servers[ 0 ].url, servers[ 0 ].accessToken)
153
154 const videos: Video[] = res.body.data
155 expect(videos).to.have.lengthOf(3)
156
157 const v = videos.find(v => v.name === 'video user 2')
158 expect(v).to.be.undefined
159 })
160
161 it('Should block a local account', async function () {
162 await addAccountToAccountBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user1')
163 })
164
165 it('Should hide its videos', async function () {
166 const res = await getVideosListWithToken(servers[ 0 ].url, servers[ 0 ].accessToken)
167
168 const videos: Video[] = res.body.data
169 expect(videos).to.have.lengthOf(2)
170
171 const v = videos.find(v => v.name === 'video user 1')
172 expect(v).to.be.undefined
173 })
174
175 it('Should hide its comments', async function () {
176 const resThreads = await getVideoCommentThreads(servers[ 0 ].url, videoUUID1, 0, 5, '-createdAt', servers[ 0 ].accessToken)
177
178 const threads: VideoComment[] = resThreads.body.data
179 expect(threads).to.have.lengthOf(1)
180 expect(threads[ 0 ].totalReplies).to.equal(0)
181
182 const t = threads.find(t => t.text === 'comment user 1')
183 expect(t).to.be.undefined
184
185 for (const thread of threads) {
186 const res = await getVideoThreadComments(servers[ 0 ].url, videoUUID1, thread.id, servers[ 0 ].accessToken)
187
188 const tree: VideoCommentThreadTree = res.body
189 expect(tree.children).to.have.lengthOf(0)
190 }
191 })
192
193 it('Should list all the videos with another user', async function () {
194 return checkAllVideos(servers[ 0 ].url, userToken1)
195 })
196
197 it('Should list all the comments with another user', async function () {
198 return checkAllComments(servers[ 0 ].url, userToken1, videoUUID1)
199 })
200
201 it('Should list blocked accounts', async function () {
202 {
203 const res = await getAccountBlocklistByAccount(servers[ 0 ].url, servers[ 0 ].accessToken, 0, 1, 'createdAt')
204 const blocks: AccountBlock[] = res.body.data
205
206 expect(res.body.total).to.equal(2)
207
208 const block = blocks[ 0 ]
209 expect(block.byAccount.displayName).to.equal('root')
210 expect(block.byAccount.name).to.equal('root')
211 expect(block.blockedAccount.displayName).to.equal('user2')
212 expect(block.blockedAccount.name).to.equal('user2')
213 expect(block.blockedAccount.host).to.equal('localhost:9002')
214 }
215
216 {
217 const res = await getAccountBlocklistByAccount(servers[ 0 ].url, servers[ 0 ].accessToken, 1, 2, 'createdAt')
218 const blocks: AccountBlock[] = res.body.data
219
220 expect(res.body.total).to.equal(2)
221
222 const block = blocks[ 0 ]
223 expect(block.byAccount.displayName).to.equal('root')
224 expect(block.byAccount.name).to.equal('root')
225 expect(block.blockedAccount.displayName).to.equal('user1')
226 expect(block.blockedAccount.name).to.equal('user1')
227 expect(block.blockedAccount.host).to.equal('localhost:9001')
228 }
229 })
230
231 it('Should unblock the remote account', async function () {
232 await removeAccountFromAccountBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user2@localhost:9002')
233 })
234
235 it('Should display its videos', async function () {
236 const res = await getVideosListWithToken(servers[ 0 ].url, servers[ 0 ].accessToken)
237
238 const videos: Video[] = res.body.data
239 expect(videos).to.have.lengthOf(3)
240
241 const v = videos.find(v => v.name === 'video user 2')
242 expect(v).not.to.be.undefined
243 })
244
245 it('Should unblock the local account', async function () {
246 await removeAccountFromAccountBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user1')
247 })
248
249 it('Should display its comments', function () {
250 return checkAllComments(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1)
251 })
252 })
253
254 describe('When managing server blocklist', function () {
255 it('Should list all videos', function () {
256 return checkAllVideos(servers[ 0 ].url, servers[ 0 ].accessToken)
257 })
258
259 it('Should list the comments', function () {
260 return checkAllComments(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1)
261 })
262
263 it('Should block a remote server', async function () {
264 await addServerToAccountBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:9002')
265 })
266
267 it('Should hide its videos', async function () {
268 const res = await getVideosListWithToken(servers[ 0 ].url, servers[ 0 ].accessToken)
269
270 const videos: Video[] = res.body.data
271 expect(videos).to.have.lengthOf(2)
272
273 const v1 = videos.find(v => v.name === 'video user 2')
274 const v2 = videos.find(v => v.name === 'video server 2')
275
276 expect(v1).to.be.undefined
277 expect(v2).to.be.undefined
278 })
279
280 it('Should list all the videos with another user', async function () {
281 return checkAllVideos(servers[ 0 ].url, userToken1)
282 })
283
284 it('Should hide its comments')
285
286 it('Should list blocked servers', async function () {
287 const res = await getServerBlocklistByAccount(servers[ 0 ].url, servers[ 0 ].accessToken, 0, 1, 'createdAt')
288 const blocks: ServerBlock[] = res.body.data
289
290 expect(res.body.total).to.equal(1)
291
292 const block = blocks[ 0 ]
293 expect(block.byAccount.displayName).to.equal('root')
294 expect(block.byAccount.name).to.equal('root')
295 expect(block.blockedServer.host).to.equal('localhost:9002')
296 })
297
298 it('Should unblock the remote server', async function () {
299 await removeServerFromAccountBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:9002')
300 })
301
302 it('Should display its videos', function () {
303 return checkAllVideos(servers[ 0 ].url, servers[ 0 ].accessToken)
304 })
305
306 it('Should display its comments', function () {
307 return checkAllComments(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1)
308 })
309 })
310 })
311
312 describe('Server blocklist', function () {
313
314 describe('When managing account blocklist', function () {
315 it('Should list all videos', async function () {
316 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
317 await checkAllVideos(servers[ 0 ].url, token)
318 }
319 })
320
321 it('Should list the comments', async function () {
322 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
323 await checkAllComments(servers[ 0 ].url, token, videoUUID1)
324 }
325 })
326
327 it('Should block a remote account', async function () {
328 await addAccountToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user2@localhost:9002')
329 })
330
331 it('Should hide its videos', async function () {
332 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
333 const res = await getVideosListWithToken(servers[ 0 ].url, token)
334
335 const videos: Video[] = res.body.data
336 expect(videos).to.have.lengthOf(3)
337
338 const v = videos.find(v => v.name === 'video user 2')
339 expect(v).to.be.undefined
340 }
341 })
342
343 it('Should block a local account', async function () {
344 await addAccountToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user1')
345 })
346
347 it('Should hide its videos', async function () {
348 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
349 const res = await getVideosListWithToken(servers[ 0 ].url, token)
350
351 const videos: Video[] = res.body.data
352 expect(videos).to.have.lengthOf(2)
353
354 const v = videos.find(v => v.name === 'video user 1')
355 expect(v).to.be.undefined
356 }
357 })
358
359 it('Should hide its comments', async function () {
360 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
361 const resThreads = await getVideoCommentThreads(servers[ 0 ].url, videoUUID1, 0, 5, '-createdAt', token)
362
363 const threads: VideoComment[] = resThreads.body.data
364 expect(threads).to.have.lengthOf(1)
365 expect(threads[ 0 ].totalReplies).to.equal(0)
366
367 const t = threads.find(t => t.text === 'comment user 1')
368 expect(t).to.be.undefined
369
370 for (const thread of threads) {
371 const res = await getVideoThreadComments(servers[ 0 ].url, videoUUID1, thread.id, token)
372
373 const tree: VideoCommentThreadTree = res.body
374 expect(tree.children).to.have.lengthOf(0)
375 }
376 }
377 })
378
379 it('Should list blocked accounts', async function () {
380 {
381 const res = await getAccountBlocklistByServer(servers[ 0 ].url, servers[ 0 ].accessToken, 0, 1, 'createdAt')
382 const blocks: AccountBlock[] = res.body.data
383
384 expect(res.body.total).to.equal(2)
385
386 const block = blocks[ 0 ]
387 expect(block.byAccount.displayName).to.equal('peertube')
388 expect(block.byAccount.name).to.equal('peertube')
389 expect(block.blockedAccount.displayName).to.equal('user2')
390 expect(block.blockedAccount.name).to.equal('user2')
391 expect(block.blockedAccount.host).to.equal('localhost:9002')
392 }
393
394 {
395 const res = await getAccountBlocklistByServer(servers[ 0 ].url, servers[ 0 ].accessToken, 1, 2, 'createdAt')
396 const blocks: AccountBlock[] = res.body.data
397
398 expect(res.body.total).to.equal(2)
399
400 const block = blocks[ 0 ]
401 expect(block.byAccount.displayName).to.equal('peertube')
402 expect(block.byAccount.name).to.equal('peertube')
403 expect(block.blockedAccount.displayName).to.equal('user1')
404 expect(block.blockedAccount.name).to.equal('user1')
405 expect(block.blockedAccount.host).to.equal('localhost:9001')
406 }
407 })
408
409 it('Should unblock the remote account', async function () {
410 await removeAccountFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user2@localhost:9002')
411 })
412
413 it('Should display its videos', async function () {
414 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
415 const res = await getVideosListWithToken(servers[ 0 ].url, token)
416
417 const videos: Video[] = res.body.data
418 expect(videos).to.have.lengthOf(3)
419
420 const v = videos.find(v => v.name === 'video user 2')
421 expect(v).not.to.be.undefined
422 }
423 })
424
425 it('Should unblock the local account', async function () {
426 await removeAccountFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'user1')
427 })
428
429 it('Should display its comments', async function () {
430 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
431 await checkAllComments(servers[ 0 ].url, token, videoUUID1)
432 }
433 })
434 })
435
436 describe('When managing server blocklist', function () {
437 it('Should list all videos', async function () {
438 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
439 await checkAllVideos(servers[ 0 ].url, token)
440 }
441 })
442
443 it('Should list the comments', async function () {
444 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
445 await checkAllComments(servers[ 0 ].url, token, videoUUID1)
446 }
447 })
448
449 it('Should block a remote server', async function () {
450 await addServerToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:9002')
451 })
452
453 it('Should hide its videos', async function () {
454 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
455 const res1 = await getVideosList(servers[ 0 ].url)
456 const res2 = await getVideosListWithToken(servers[ 0 ].url, token)
457
458 for (const res of [ res1, res2 ]) {
459 const videos: Video[] = res.body.data
460 expect(videos).to.have.lengthOf(2)
461
462 const v1 = videos.find(v => v.name === 'video user 2')
463 const v2 = videos.find(v => v.name === 'video server 2')
464
465 expect(v1).to.be.undefined
466 expect(v2).to.be.undefined
467 }
468 }
469 })
470
471 it('Should hide its comments')
472
473 it('Should list blocked servers', async function () {
474 const res = await getServerBlocklistByServer(servers[ 0 ].url, servers[ 0 ].accessToken, 0, 1, 'createdAt')
475 const blocks: ServerBlock[] = res.body.data
476
477 expect(res.body.total).to.equal(1)
478
479 const block = blocks[ 0 ]
480 expect(block.byAccount.displayName).to.equal('peertube')
481 expect(block.byAccount.name).to.equal('peertube')
482 expect(block.blockedServer.host).to.equal('localhost:9002')
483 })
484
485 it('Should unblock the remote server', async function () {
486 await removeServerFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:9002')
487 })
488
489 it('Should list all videos', async function () {
490 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
491 await checkAllVideos(servers[ 0 ].url, token)
492 }
493 })
494
495 it('Should list the comments', async function () {
496 for (const token of [ userModeratorToken, servers[ 0 ].accessToken ]) {
497 await checkAllComments(servers[ 0 ].url, token, videoUUID1)
498 }
499 })
500 })
501 })
502
503 after(async function () {
504 killallServers(servers)
505
506 // Keep the logs if the test failed
507 if (this[ 'ok' ]) {
508 await flushTests()
509 }
510 })
511})
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index 21d75da3e..52ba6984e 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,4 +1,6 @@
1import './users-verification'
2import './user-notifications'
3import './blocklist'
1import './user-subscriptions' 4import './user-subscriptions'
2import './users' 5import './users'
3import './users-verification'
4import './users-multiple-servers' 6import './users-multiple-servers'
diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts
new file mode 100644
index 000000000..69e51677e
--- /dev/null
+++ b/server/tests/api/users/user-notifications.ts
@@ -0,0 +1,1053 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 addVideoToBlacklist,
7 createUser,
8 doubleFollow,
9 flushAndRunMultipleServers,
10 flushTests,
11 getMyUserInformation,
12 immutableAssign,
13 registerUser,
14 removeVideoFromBlacklist,
15 reportVideoAbuse,
16 updateMyUser,
17 updateVideo,
18 updateVideoChannel,
19 userLogin,
20 wait
21} from '../../../../shared/utils'
22import { killallServers, ServerInfo, uploadVideo } from '../../../../shared/utils/index'
23import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
24import { waitJobs } from '../../../../shared/utils/server/jobs'
25import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
26import {
27 checkCommentMention,
28 CheckerBaseParams,
29 checkMyVideoImportIsFinished,
30 checkNewActorFollow,
31 checkNewBlacklistOnMyVideo,
32 checkNewCommentOnMyVideo,
33 checkNewVideoAbuseForModerators,
34 checkNewVideoFromSubscription,
35 checkUserRegistered,
36 checkVideoIsPublished,
37 getLastNotification,
38 getUserNotifications,
39 markAsReadNotifications,
40 updateMyNotificationSettings,
41 markAsReadAllNotifications
42} from '../../../../shared/utils/users/user-notifications'
43import {
44 User,
45 UserNotification,
46 UserNotificationSetting,
47 UserNotificationSettingValue,
48 UserNotificationType
49} from '../../../../shared/models/users'
50import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
51import { addUserSubscription, removeUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
52import { VideoPrivacy } from '../../../../shared/models/videos'
53import { getBadVideoUrl, getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
54import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
55import * as uuidv4 from 'uuid/v4'
56import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist'
57
58const expect = chai.expect
59
60async function uploadVideoByRemoteAccount (servers: ServerInfo[], additionalParams: any = {}) {
61 const name = 'remote video ' + uuidv4()
62
63 const data = Object.assign({ name }, additionalParams)
64 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, data)
65
66 await waitJobs(servers)
67
68 return { uuid: res.body.video.uuid, name }
69}
70
71async function uploadVideoByLocalAccount (servers: ServerInfo[], additionalParams: any = {}) {
72 const name = 'local video ' + uuidv4()
73
74 const data = Object.assign({ name }, additionalParams)
75 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, data)
76
77 await waitJobs(servers)
78
79 return { uuid: res.body.video.uuid, name }
80}
81
82describe('Test users notifications', function () {
83 let servers: ServerInfo[] = []
84 let userAccessToken: string
85 let userNotifications: UserNotification[] = []
86 let adminNotifications: UserNotification[] = []
87 let adminNotificationsServer2: UserNotification[] = []
88 const emails: object[] = []
89 let channelId: number
90
91 const allNotificationSettings: UserNotificationSetting = {
92 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
93 newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
94 videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
95 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
96 myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
97 myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
98 commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
99 newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
100 newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
101 }
102
103 before(async function () {
104 this.timeout(120000)
105
106 await MockSmtpServer.Instance.collectEmails(emails)
107
108 await flushTests()
109
110 const overrideConfig = {
111 smtp: {
112 hostname: 'localhost'
113 }
114 }
115 servers = await flushAndRunMultipleServers(2, overrideConfig)
116
117 // Get the access tokens
118 await setAccessTokensToServers(servers)
119
120 // Server 1 and server 2 follow each other
121 await doubleFollow(servers[0], servers[1])
122
123 await waitJobs(servers)
124
125 const user = {
126 username: 'user_1',
127 password: 'super password'
128 }
129 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password, 10 * 1000 * 1000)
130 userAccessToken = await userLogin(servers[0], user)
131
132 await updateMyNotificationSettings(servers[0].url, userAccessToken, allNotificationSettings)
133 await updateMyNotificationSettings(servers[0].url, servers[0].accessToken, allNotificationSettings)
134 await updateMyNotificationSettings(servers[1].url, servers[1].accessToken, allNotificationSettings)
135
136 {
137 const socket = getUserNotificationSocket(servers[ 0 ].url, userAccessToken)
138 socket.on('new-notification', n => userNotifications.push(n))
139 }
140 {
141 const socket = getUserNotificationSocket(servers[ 0 ].url, servers[0].accessToken)
142 socket.on('new-notification', n => adminNotifications.push(n))
143 }
144 {
145 const socket = getUserNotificationSocket(servers[ 1 ].url, servers[1].accessToken)
146 socket.on('new-notification', n => adminNotificationsServer2.push(n))
147 }
148
149 {
150 const resChannel = await getMyUserInformation(servers[0].url, servers[0].accessToken)
151 channelId = resChannel.body.videoChannels[0].id
152 }
153 })
154
155 describe('New video from my subscription notification', function () {
156 let baseParams: CheckerBaseParams
157
158 before(() => {
159 baseParams = {
160 server: servers[0],
161 emails,
162 socketNotifications: userNotifications,
163 token: userAccessToken
164 }
165 })
166
167 it('Should not send notifications if the user does not follow the video publisher', async function () {
168 this.timeout(10000)
169
170 await uploadVideoByLocalAccount(servers)
171
172 const notification = await getLastNotification(servers[ 0 ].url, userAccessToken)
173 expect(notification).to.be.undefined
174
175 expect(emails).to.have.lengthOf(0)
176 expect(userNotifications).to.have.lengthOf(0)
177 })
178
179 it('Should send a new video notification if the user follows the local video publisher', async function () {
180 this.timeout(15000)
181
182 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
183 await waitJobs(servers)
184
185 const { name, uuid } = await uploadVideoByLocalAccount(servers)
186 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
187 })
188
189 it('Should send a new video notification from a remote account', async function () {
190 this.timeout(50000) // Server 2 has transcoding enabled
191
192 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
193 await waitJobs(servers)
194
195 const { name, uuid } = await uploadVideoByRemoteAccount(servers)
196 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
197 })
198
199 it('Should send a new video notification on a scheduled publication', async function () {
200 this.timeout(20000)
201
202 // In 2 seconds
203 let updateAt = new Date(new Date().getTime() + 2000)
204
205 const data = {
206 privacy: VideoPrivacy.PRIVATE,
207 scheduleUpdate: {
208 updateAt: updateAt.toISOString(),
209 privacy: VideoPrivacy.PUBLIC
210 }
211 }
212 const { name, uuid } = await uploadVideoByLocalAccount(servers, data)
213
214 await wait(6000)
215 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
216 })
217
218 it('Should send a new video notification on a remote scheduled publication', async function () {
219 this.timeout(20000)
220
221 // In 2 seconds
222 let updateAt = new Date(new Date().getTime() + 2000)
223
224 const data = {
225 privacy: VideoPrivacy.PRIVATE,
226 scheduleUpdate: {
227 updateAt: updateAt.toISOString(),
228 privacy: VideoPrivacy.PUBLIC
229 }
230 }
231 const { name, uuid } = await uploadVideoByRemoteAccount(servers, data)
232 await waitJobs(servers)
233
234 await wait(6000)
235 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
236 })
237
238 it('Should not send a notification before the video is published', async function () {
239 this.timeout(20000)
240
241 let updateAt = new Date(new Date().getTime() + 100000)
242
243 const data = {
244 privacy: VideoPrivacy.PRIVATE,
245 scheduleUpdate: {
246 updateAt: updateAt.toISOString(),
247 privacy: VideoPrivacy.PUBLIC
248 }
249 }
250 const { name, uuid } = await uploadVideoByLocalAccount(servers, data)
251
252 await wait(6000)
253 await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence')
254 })
255
256 it('Should send a new video notification when a video becomes public', async function () {
257 this.timeout(10000)
258
259 const data = { privacy: VideoPrivacy.PRIVATE }
260 const { name, uuid } = await uploadVideoByLocalAccount(servers, data)
261
262 await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence')
263
264 await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
265
266 await wait(500)
267 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
268 })
269
270 it('Should send a new video notification when a remote video becomes public', async function () {
271 this.timeout(20000)
272
273 const data = { privacy: VideoPrivacy.PRIVATE }
274 const { name, uuid } = await uploadVideoByRemoteAccount(servers, data)
275
276 await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence')
277
278 await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
279
280 await waitJobs(servers)
281 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
282 })
283
284 it('Should not send a new video notification when a video becomes unlisted', async function () {
285 this.timeout(20000)
286
287 const data = { privacy: VideoPrivacy.PRIVATE }
288 const { name, uuid } = await uploadVideoByLocalAccount(servers, data)
289
290 await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
291
292 await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence')
293 })
294
295 it('Should not send a new video notification when a remote video becomes unlisted', async function () {
296 this.timeout(20000)
297
298 const data = { privacy: VideoPrivacy.PRIVATE }
299 const { name, uuid } = await uploadVideoByRemoteAccount(servers, data)
300
301 await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
302
303 await waitJobs(servers)
304 await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence')
305 })
306
307 it('Should send a new video notification after a video import', async function () {
308 this.timeout(30000)
309
310 const name = 'video import ' + uuidv4()
311
312 const attributes = {
313 name,
314 channelId,
315 privacy: VideoPrivacy.PUBLIC,
316 targetUrl: getYoutubeVideoUrl()
317 }
318 const res = await importVideo(servers[0].url, servers[0].accessToken, attributes)
319 const uuid = res.body.video.uuid
320
321 await waitJobs(servers)
322
323 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
324 })
325 })
326
327 describe('Comment on my video notifications', function () {
328 let baseParams: CheckerBaseParams
329
330 before(() => {
331 baseParams = {
332 server: servers[0],
333 emails,
334 socketNotifications: userNotifications,
335 token: userAccessToken
336 }
337 })
338
339 it('Should not send a new comment notification after a comment on another video', async function () {
340 this.timeout(10000)
341
342 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
343 const uuid = resVideo.body.video.uuid
344
345 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
346 const commentId = resComment.body.comment.id
347
348 await wait(500)
349 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
350 })
351
352 it('Should not send a new comment notification if I comment my own video', async function () {
353 this.timeout(10000)
354
355 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
356 const uuid = resVideo.body.video.uuid
357
358 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, 'comment')
359 const commentId = resComment.body.comment.id
360
361 await wait(500)
362 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
363 })
364
365 it('Should not send a new comment notification if the account is muted', async function () {
366 this.timeout(10000)
367
368 await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
369
370 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
371 const uuid = resVideo.body.video.uuid
372
373 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
374 const commentId = resComment.body.comment.id
375
376 await wait(500)
377 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
378
379 await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
380 })
381
382 it('Should send a new comment notification after a local comment on my video', async function () {
383 this.timeout(10000)
384
385 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
386 const uuid = resVideo.body.video.uuid
387
388 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
389 const commentId = resComment.body.comment.id
390
391 await wait(500)
392 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
393 })
394
395 it('Should send a new comment notification after a remote comment on my video', async function () {
396 this.timeout(10000)
397
398 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
399 const uuid = resVideo.body.video.uuid
400
401 await waitJobs(servers)
402
403 const resComment = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
404 const commentId = resComment.body.comment.id
405
406 await waitJobs(servers)
407 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
408 })
409
410 it('Should send a new comment notification after a local reply on my video', async function () {
411 this.timeout(10000)
412
413 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
414 const uuid = resVideo.body.video.uuid
415
416 const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
417 const threadId = resThread.body.comment.id
418
419 const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'reply')
420 const commentId = resComment.body.comment.id
421
422 await wait(500)
423 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
424 })
425
426 it('Should send a new comment notification after a remote reply on my video', async function () {
427 this.timeout(10000)
428
429 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
430 const uuid = resVideo.body.video.uuid
431 await waitJobs(servers)
432
433 const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
434 const threadId = resThread.body.comment.id
435
436 const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, 'reply')
437 const commentId = resComment.body.comment.id
438
439 await waitJobs(servers)
440 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
441 })
442 })
443
444 describe('Mention notifications', function () {
445 let baseParams: CheckerBaseParams
446
447 before(async () => {
448 baseParams = {
449 server: servers[0],
450 emails,
451 socketNotifications: userNotifications,
452 token: userAccessToken
453 }
454
455 await updateMyUser({
456 url: servers[0].url,
457 accessToken: servers[0].accessToken,
458 displayName: 'super root name'
459 })
460
461 await updateMyUser({
462 url: servers[1].url,
463 accessToken: servers[1].accessToken,
464 displayName: 'super root 2 name'
465 })
466 })
467
468 it('Should not send a new mention comment notification if I mention the video owner', async function () {
469 this.timeout(10000)
470
471 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
472 const uuid = resVideo.body.video.uuid
473
474 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello')
475 const commentId = resComment.body.comment.id
476
477 await wait(500)
478 await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
479 })
480
481 it('Should not send a new mention comment notification if I mention myself', async function () {
482 this.timeout(10000)
483
484 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
485 const uuid = resVideo.body.video.uuid
486
487 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, '@user_1 hello')
488 const commentId = resComment.body.comment.id
489
490 await wait(500)
491 await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
492 })
493
494 it('Should not send a new mention notification if the account is muted', async function () {
495 this.timeout(10000)
496
497 await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
498
499 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
500 const uuid = resVideo.body.video.uuid
501
502 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello')
503 const commentId = resComment.body.comment.id
504
505 await wait(500)
506 await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
507
508 await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
509 })
510
511 it('Should send a new mention notification after local comments', async function () {
512 this.timeout(10000)
513
514 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
515 const uuid = resVideo.body.video.uuid
516
517 const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello 1')
518 const threadId = resThread.body.comment.id
519
520 await wait(500)
521 await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root name', 'presence')
522
523 const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'hello 2 @user_1')
524 const commentId = resComment.body.comment.id
525
526 await wait(500)
527 await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root name', 'presence')
528 })
529
530 it('Should send a new mention notification after remote comments', async function () {
531 this.timeout(20000)
532
533 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
534 const uuid = resVideo.body.video.uuid
535
536 await waitJobs(servers)
537 const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'hello @user_1@localhost:9001 1')
538 const threadId = resThread.body.comment.id
539
540 await waitJobs(servers)
541 await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root 2 name', 'presence')
542
543 const text = '@user_1@localhost:9001 hello 2 @root@localhost:9001'
544 const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, text)
545 const commentId = resComment.body.comment.id
546
547 await waitJobs(servers)
548 await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root 2 name', 'presence')
549 })
550 })
551
552 describe('Video abuse for moderators notification' , function () {
553 let baseParams: CheckerBaseParams
554
555 before(() => {
556 baseParams = {
557 server: servers[0],
558 emails,
559 socketNotifications: adminNotifications,
560 token: servers[0].accessToken
561 }
562 })
563
564 it('Should send a notification to moderators on local video abuse', async function () {
565 this.timeout(10000)
566
567 const name = 'video for abuse ' + uuidv4()
568 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
569 const uuid = resVideo.body.video.uuid
570
571 await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason')
572
573 await waitJobs(servers)
574 await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence')
575 })
576
577 it('Should send a notification to moderators on remote video abuse', async function () {
578 this.timeout(10000)
579
580 const name = 'video for abuse ' + uuidv4()
581 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
582 const uuid = resVideo.body.video.uuid
583
584 await waitJobs(servers)
585
586 await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason')
587
588 await waitJobs(servers)
589 await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence')
590 })
591 })
592
593 describe('Video blacklist on my video', function () {
594 let baseParams: CheckerBaseParams
595
596 before(() => {
597 baseParams = {
598 server: servers[0],
599 emails,
600 socketNotifications: userNotifications,
601 token: userAccessToken
602 }
603 })
604
605 it('Should send a notification to video owner on blacklist', async function () {
606 this.timeout(10000)
607
608 const name = 'video for abuse ' + uuidv4()
609 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
610 const uuid = resVideo.body.video.uuid
611
612 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
613
614 await waitJobs(servers)
615 await checkNewBlacklistOnMyVideo(baseParams, uuid, name, 'blacklist')
616 })
617
618 it('Should send a notification to video owner on unblacklist', async function () {
619 this.timeout(10000)
620
621 const name = 'video for abuse ' + uuidv4()
622 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
623 const uuid = resVideo.body.video.uuid
624
625 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
626
627 await waitJobs(servers)
628 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, uuid)
629 await waitJobs(servers)
630
631 await wait(500)
632 await checkNewBlacklistOnMyVideo(baseParams, uuid, name, 'unblacklist')
633 })
634 })
635
636 describe('My video is published', function () {
637 let baseParams: CheckerBaseParams
638
639 before(() => {
640 baseParams = {
641 server: servers[1],
642 emails,
643 socketNotifications: adminNotificationsServer2,
644 token: servers[1].accessToken
645 }
646 })
647
648 it('Should not send a notification if transcoding is not enabled', async function () {
649 this.timeout(10000)
650
651 const { name, uuid } = await uploadVideoByLocalAccount(servers)
652 await waitJobs(servers)
653
654 await checkVideoIsPublished(baseParams, name, uuid, 'absence')
655 })
656
657 it('Should not send a notification if the wait transcoding is false', async function () {
658 this.timeout(50000)
659
660 await uploadVideoByRemoteAccount(servers, { waitTranscoding: false })
661 await waitJobs(servers)
662
663 const notification = await getLastNotification(servers[ 0 ].url, userAccessToken)
664 if (notification) {
665 expect(notification.type).to.not.equal(UserNotificationType.MY_VIDEO_PUBLISHED)
666 }
667 })
668
669 it('Should send a notification even if the video is not transcoded in other resolutions', async function () {
670 this.timeout(50000)
671
672 const { name, uuid } = await uploadVideoByRemoteAccount(servers, { waitTranscoding: true, fixture: 'video_short_240p.mp4' })
673 await waitJobs(servers)
674
675 await checkVideoIsPublished(baseParams, name, uuid, 'presence')
676 })
677
678 it('Should send a notification with a transcoded video', async function () {
679 this.timeout(50000)
680
681 const { name, uuid } = await uploadVideoByRemoteAccount(servers, { waitTranscoding: true })
682 await waitJobs(servers)
683
684 await checkVideoIsPublished(baseParams, name, uuid, 'presence')
685 })
686
687 it('Should send a notification when an imported video is transcoded', async function () {
688 this.timeout(50000)
689
690 const name = 'video import ' + uuidv4()
691
692 const attributes = {
693 name,
694 channelId,
695 privacy: VideoPrivacy.PUBLIC,
696 targetUrl: getYoutubeVideoUrl(),
697 waitTranscoding: true
698 }
699 const res = await importVideo(servers[1].url, servers[1].accessToken, attributes)
700 const uuid = res.body.video.uuid
701
702 await waitJobs(servers)
703 await checkVideoIsPublished(baseParams, name, uuid, 'presence')
704 })
705
706 it('Should send a notification when the scheduled update has been proceeded', async function () {
707 this.timeout(70000)
708
709 // In 2 seconds
710 let updateAt = new Date(new Date().getTime() + 2000)
711
712 const data = {
713 privacy: VideoPrivacy.PRIVATE,
714 scheduleUpdate: {
715 updateAt: updateAt.toISOString(),
716 privacy: VideoPrivacy.PUBLIC
717 }
718 }
719 const { name, uuid } = await uploadVideoByRemoteAccount(servers, data)
720
721 await wait(6000)
722 await checkVideoIsPublished(baseParams, name, uuid, 'presence')
723 })
724
725 it('Should not send a notification before the video is published', async function () {
726 this.timeout(20000)
727
728 let updateAt = new Date(new Date().getTime() + 100000)
729
730 const data = {
731 privacy: VideoPrivacy.PRIVATE,
732 scheduleUpdate: {
733 updateAt: updateAt.toISOString(),
734 privacy: VideoPrivacy.PUBLIC
735 }
736 }
737 const { name, uuid } = await uploadVideoByRemoteAccount(servers, data)
738
739 await wait(6000)
740 await checkVideoIsPublished(baseParams, name, uuid, 'absence')
741 })
742 })
743
744 describe('My video is imported', function () {
745 let baseParams: CheckerBaseParams
746
747 before(() => {
748 baseParams = {
749 server: servers[0],
750 emails,
751 socketNotifications: adminNotifications,
752 token: servers[0].accessToken
753 }
754 })
755
756 it('Should send a notification when the video import failed', async function () {
757 this.timeout(70000)
758
759 const name = 'video import ' + uuidv4()
760
761 const attributes = {
762 name,
763 channelId,
764 privacy: VideoPrivacy.PRIVATE,
765 targetUrl: getBadVideoUrl()
766 }
767 const res = await importVideo(servers[0].url, servers[0].accessToken, attributes)
768 const uuid = res.body.video.uuid
769
770 await waitJobs(servers)
771 await checkMyVideoImportIsFinished(baseParams, name, uuid, getBadVideoUrl(), false, 'presence')
772 })
773
774 it('Should send a notification when the video import succeeded', async function () {
775 this.timeout(70000)
776
777 const name = 'video import ' + uuidv4()
778
779 const attributes = {
780 name,
781 channelId,
782 privacy: VideoPrivacy.PRIVATE,
783 targetUrl: getYoutubeVideoUrl()
784 }
785 const res = await importVideo(servers[0].url, servers[0].accessToken, attributes)
786 const uuid = res.body.video.uuid
787
788 await waitJobs(servers)
789 await checkMyVideoImportIsFinished(baseParams, name, uuid, getYoutubeVideoUrl(), true, 'presence')
790 })
791 })
792
793 describe('New registration', function () {
794 let baseParams: CheckerBaseParams
795
796 before(() => {
797 baseParams = {
798 server: servers[0],
799 emails,
800 socketNotifications: adminNotifications,
801 token: servers[0].accessToken
802 }
803 })
804
805 it('Should send a notification only to moderators when a user registers on the instance', async function () {
806 this.timeout(10000)
807
808 await registerUser(servers[0].url, 'user_45', 'password')
809
810 await waitJobs(servers)
811
812 await checkUserRegistered(baseParams, 'user_45', 'presence')
813
814 const userOverride = { socketNotifications: userNotifications, token: userAccessToken, check: { web: true, mail: false } }
815 await checkUserRegistered(immutableAssign(baseParams, userOverride), 'user_45', 'absence')
816 })
817 })
818
819 describe('New actor follow', function () {
820 let baseParams: CheckerBaseParams
821 let myChannelName = 'super channel name'
822 let myUserName = 'super user name'
823
824 before(async () => {
825 baseParams = {
826 server: servers[0],
827 emails,
828 socketNotifications: userNotifications,
829 token: userAccessToken
830 }
831
832 await updateMyUser({
833 url: servers[0].url,
834 accessToken: servers[0].accessToken,
835 displayName: 'super root name'
836 })
837
838 await updateMyUser({
839 url: servers[0].url,
840 accessToken: userAccessToken,
841 displayName: myUserName
842 })
843
844 await updateMyUser({
845 url: servers[1].url,
846 accessToken: servers[1].accessToken,
847 displayName: 'super root 2 name'
848 })
849
850 await updateVideoChannel(servers[0].url, userAccessToken, 'user_1_channel', { displayName: myChannelName })
851 })
852
853 it('Should notify when a local channel is following one of our channel', async function () {
854 this.timeout(10000)
855
856 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
857 await waitJobs(servers)
858
859 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
860
861 await removeUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
862 })
863
864 it('Should notify when a remote channel is following one of our channel', async function () {
865 this.timeout(10000)
866
867 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
868 await waitJobs(servers)
869
870 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
871
872 await removeUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
873 })
874
875 it('Should notify when a local account is following one of our channel', async function () {
876 this.timeout(10000)
877
878 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@localhost:9001')
879
880 await waitJobs(servers)
881
882 await checkNewActorFollow(baseParams, 'account', 'root', 'super root name', myUserName, 'presence')
883 })
884
885 it('Should notify when a remote account is following one of our channel', async function () {
886 this.timeout(10000)
887
888 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@localhost:9001')
889
890 await waitJobs(servers)
891
892 await checkNewActorFollow(baseParams, 'account', 'root', 'super root 2 name', myUserName, 'presence')
893 })
894 })
895
896 describe('Mark as read', function () {
897 it('Should mark as read some notifications', async function () {
898 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3)
899 const ids = res.body.data.map(n => n.id)
900
901 await markAsReadNotifications(servers[ 0 ].url, userAccessToken, ids)
902 })
903
904 it('Should have the notifications marked as read', async function () {
905 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10)
906
907 const notifications = res.body.data as UserNotification[]
908 expect(notifications[ 0 ].read).to.be.false
909 expect(notifications[ 1 ].read).to.be.false
910 expect(notifications[ 2 ].read).to.be.true
911 expect(notifications[ 3 ].read).to.be.true
912 expect(notifications[ 4 ].read).to.be.true
913 expect(notifications[ 5 ].read).to.be.false
914 })
915
916 it('Should only list read notifications', async function () {
917 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, false)
918
919 const notifications = res.body.data as UserNotification[]
920 for (const notification of notifications) {
921 expect(notification.read).to.be.true
922 }
923 })
924
925 it('Should only list unread notifications', async function () {
926 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, true)
927
928 const notifications = res.body.data as UserNotification[]
929 for (const notification of notifications) {
930 expect(notification.read).to.be.false
931 }
932 })
933
934 it('Should mark as read all notifications', async function () {
935 await markAsReadAllNotifications(servers[ 0 ].url, userAccessToken)
936
937 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, true)
938
939 expect(res.body.total).to.equal(0)
940 expect(res.body.data).to.have.lengthOf(0)
941 })
942 })
943
944 describe('Notification settings', function () {
945 let baseParams: CheckerBaseParams
946
947 before(() => {
948 baseParams = {
949 server: servers[0],
950 emails,
951 socketNotifications: userNotifications,
952 token: userAccessToken
953 }
954 })
955
956 it('Should not have notifications', async function () {
957 this.timeout(10000)
958
959 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
960 newVideoFromSubscription: UserNotificationSettingValue.NONE
961 }))
962
963 {
964 const res = await getMyUserInformation(servers[0].url, userAccessToken)
965 const info = res.body as User
966 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.NONE)
967 }
968
969 const { name, uuid } = await uploadVideoByLocalAccount(servers)
970
971 const check = { web: true, mail: true }
972 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'absence')
973 })
974
975 it('Should only have web notifications', async function () {
976 this.timeout(10000)
977
978 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
979 newVideoFromSubscription: UserNotificationSettingValue.WEB
980 }))
981
982 {
983 const res = await getMyUserInformation(servers[0].url, userAccessToken)
984 const info = res.body as User
985 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB)
986 }
987
988 const { name, uuid } = await uploadVideoByLocalAccount(servers)
989
990 {
991 const check = { mail: true, web: false }
992 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'absence')
993 }
994
995 {
996 const check = { mail: false, web: true }
997 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'presence')
998 }
999 })
1000
1001 it('Should only have mail notifications', async function () {
1002 this.timeout(10000)
1003
1004 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
1005 newVideoFromSubscription: UserNotificationSettingValue.EMAIL
1006 }))
1007
1008 {
1009 const res = await getMyUserInformation(servers[0].url, userAccessToken)
1010 const info = res.body as User
1011 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.EMAIL)
1012 }
1013
1014 const { name, uuid } = await uploadVideoByLocalAccount(servers)
1015
1016 {
1017 const check = { mail: false, web: true }
1018 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'absence')
1019 }
1020
1021 {
1022 const check = { mail: true, web: false }
1023 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'presence')
1024 }
1025 })
1026
1027 it('Should have email and web notifications', async function () {
1028 this.timeout(10000)
1029
1030 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
1031 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
1032 }))
1033
1034 {
1035 const res = await getMyUserInformation(servers[0].url, userAccessToken)
1036 const info = res.body as User
1037 expect(info.notificationSettings.newVideoFromSubscription).to.equal(
1038 UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
1039 )
1040 }
1041
1042 const { name, uuid } = await uploadVideoByLocalAccount(servers)
1043
1044 await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence')
1045 })
1046 })
1047
1048 after(async function () {
1049 MockSmtpServer.Instance.kill()
1050
1051 killallServers(servers)
1052 })
1053})
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index 65b80540c..88a7187d6 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -2,18 +2,27 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { createUser, doubleFollow, flushAndRunMultipleServers, follow, getVideosList, unfollow, updateVideo, userLogin } from '../../utils' 5import {
6import { killallServers, ServerInfo, uploadVideo } from '../../utils/index' 6 createUser,
7import { setAccessTokensToServers } from '../../utils/users/login' 7 doubleFollow,
8 flushAndRunMultipleServers,
9 follow,
10 getVideosList,
11 unfollow,
12 updateVideo,
13 userLogin
14} from '../../../../shared/utils'
15import { killallServers, ServerInfo, uploadVideo } from '../../../../shared/utils/index'
16import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
8import { Video, VideoChannel } from '../../../../shared/models/videos' 17import { Video, VideoChannel } from '../../../../shared/models/videos'
9import { waitJobs } from '../../utils/server/jobs' 18import { waitJobs } from '../../../../shared/utils/server/jobs'
10import { 19import {
11 addUserSubscription, 20 addUserSubscription,
12 listUserSubscriptions, 21 listUserSubscriptions,
13 listUserSubscriptionVideos, 22 listUserSubscriptionVideos,
14 removeUserSubscription, 23 removeUserSubscription,
15 getUserSubscription, areSubscriptionsExist 24 getUserSubscription, areSubscriptionsExist
16} from '../../utils/users/user-subscriptions' 25} from '../../../../shared/utils/users/user-subscriptions'
17 26
18const expect = chai.expect 27const expect = chai.expect
19 28
diff --git a/server/tests/api/users/users-multiple-servers.ts b/server/tests/api/users/users-multiple-servers.ts
index d8699db17..006d6cdf0 100644
--- a/server/tests/api/users/users-multiple-servers.ts
+++ b/server/tests/api/users/users-multiple-servers.ts
@@ -13,13 +13,13 @@ import {
13 removeUser, 13 removeUser,
14 updateMyUser, 14 updateMyUser,
15 userLogin 15 userLogin
16} from '../../utils' 16} from '../../../../shared/utils'
17import { getMyUserInformation, killallServers, ServerInfo, testImage, updateMyAvatar, uploadVideo } from '../../utils/index' 17import { getMyUserInformation, killallServers, ServerInfo, testImage, updateMyAvatar, uploadVideo } from '../../../../shared/utils/index'
18import { checkActorFilesWereRemoved, getAccount, getAccountsList } from '../../utils/users/accounts' 18import { checkActorFilesWereRemoved, getAccount, getAccountsList } from '../../../../shared/utils/users/accounts'
19import { setAccessTokensToServers } from '../../utils/users/login' 19import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
20import { User } from '../../../../shared/models/users' 20import { User } from '../../../../shared/models/users'
21import { VideoChannel } from '../../../../shared/models/videos' 21import { VideoChannel } from '../../../../shared/models/videos'
22import { waitJobs } from '../../utils/server/jobs' 22import { waitJobs } from '../../../../shared/utils/server/jobs'
23 23
24const expect = chai.expect 24const expect = chai.expect
25 25
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-verification.ts
index fa5f5e371..babeda2b8 100644
--- a/server/tests/api/users/users-verification.ts
+++ b/server/tests/api/users/users-verification.ts
@@ -4,11 +4,11 @@ import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { 5import {
6 registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers, 6 registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers,
7 userLogin, login, runServer, ServerInfo, verifyEmail, updateCustomSubConfig 7 userLogin, login, runServer, ServerInfo, verifyEmail, updateCustomSubConfig, wait
8} from '../../utils' 8} from '../../../../shared/utils'
9import { setAccessTokensToServers } from '../../utils/users/login' 9import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
10import { mockSmtpServer } from '../../utils/miscs/email' 10import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
11import { waitJobs } from '../../utils/server/jobs' 11import { waitJobs } from '../../../../shared/utils/server/jobs'
12 12
13const expect = chai.expect 13const expect = chai.expect
14 14
@@ -30,7 +30,7 @@ describe('Test users account verification', function () {
30 before(async function () { 30 before(async function () {
31 this.timeout(30000) 31 this.timeout(30000)
32 32
33 await mockSmtpServer(emails) 33 await MockSmtpServer.Instance.collectEmails(emails)
34 34
35 await flushTests() 35 await flushTests()
36 36
@@ -123,6 +123,7 @@ describe('Test users account verification', function () {
123 }) 123 })
124 124
125 after(async function () { 125 after(async function () {
126 MockSmtpServer.Instance.kill()
126 killallServers([ server ]) 127 killallServers([ server ])
127 128
128 // Keep the logs if the test failed 129 // Keep the logs if the test failed
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 8b9c6b455..c4465d541 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -32,10 +32,10 @@ import {
32 updateUser, 32 updateUser,
33 uploadVideo, 33 uploadVideo,
34 userLogin 34 userLogin
35} from '../../utils/index' 35} from '../../../../shared/utils/index'
36import { follow } from '../../utils/server/follows' 36import { follow } from '../../../../shared/utils/server/follows'
37import { setAccessTokensToServers } from '../../utils/users/login' 37import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
38import { getMyVideos } from '../../utils/videos/videos' 38import { getMyVideos } from '../../../../shared/utils/videos/videos'
39 39
40const expect = chai.expect 40const expect = chai.expect
41 41
@@ -180,7 +180,7 @@ describe('Test users', function () {
180 it('Should be able to upload a video again') 180 it('Should be able to upload a video again')
181 181
182 it('Should be able to create a new user', async function () { 182 it('Should be able to create a new user', async function () {
183 await createUser(server.url, accessToken, user.username,user.password, 2 * 1024 * 1024) 183 await createUser(server.url, accessToken, user.username, user.password, 2 * 1024 * 1024)
184 }) 184 })
185 185
186 it('Should be able to login with this user', async function () { 186 it('Should be able to login with this user', async function () {
@@ -322,6 +322,40 @@ describe('Test users', function () {
322 expect(users[ 1 ].nsfwPolicy).to.equal('display') 322 expect(users[ 1 ].nsfwPolicy).to.equal('display')
323 }) 323 })
324 324
325 it('Should search user by username', async function () {
326 const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', 'oot')
327 const users = res.body.data as User[]
328
329 expect(res.body.total).to.equal(1)
330 expect(users.length).to.equal(1)
331
332 expect(users[ 0 ].username).to.equal('root')
333 })
334
335 it('Should search user by email', async function () {
336 {
337 const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', 'r_1@exam')
338 const users = res.body.data as User[]
339
340 expect(res.body.total).to.equal(1)
341 expect(users.length).to.equal(1)
342
343 expect(users[ 0 ].username).to.equal('user_1')
344 expect(users[ 0 ].email).to.equal('user_1@example.com')
345 }
346
347 {
348 const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', 'example')
349 const users = res.body.data as User[]
350
351 expect(res.body.total).to.equal(2)
352 expect(users.length).to.equal(2)
353
354 expect(users[ 0 ].username).to.equal('root')
355 expect(users[ 1 ].username).to.equal('user_1')
356 }
357 })
358
325 it('Should update my password', async function () { 359 it('Should update my password', async function () {
326 await updateMyUser({ 360 await updateMyUser({
327 url: server.url, 361 url: server.url,
@@ -444,6 +478,7 @@ describe('Test users', function () {
444 userId, 478 userId,
445 accessToken, 479 accessToken,
446 email: 'updated2@example.com', 480 email: 'updated2@example.com',
481 emailVerified: true,
447 videoQuota: 42, 482 videoQuota: 42,
448 role: UserRole.MODERATOR 483 role: UserRole.MODERATOR
449 }) 484 })
@@ -453,6 +488,7 @@ describe('Test users', function () {
453 488
454 expect(user.username).to.equal('user_1') 489 expect(user.username).to.equal('user_1')
455 expect(user.email).to.equal('updated2@example.com') 490 expect(user.email).to.equal('updated2@example.com')
491 expect(user.emailVerified).to.be.true
456 expect(user.nsfwPolicy).to.equal('do_not_list') 492 expect(user.nsfwPolicy).to.equal('do_not_list')
457 expect(user.videoQuota).to.equal(42) 493 expect(user.videoQuota).to.equal(42)
458 expect(user.roleLabel).to.equal('Moderator') 494 expect(user.roleLabel).to.equal('Moderator')
@@ -465,8 +501,20 @@ describe('Test users', function () {
465 accessTokenUser = await userLogin(server, user) 501 accessTokenUser = await userLogin(server, user)
466 }) 502 })
467 503
468 it('Should not be able to delete a user by a moderator', async function () { 504 it('Should be able to update another user password', async function () {
469 await removeUser(server.url, 2, accessTokenUser, 403) 505 await updateUser({
506 url: server.url,
507 userId,
508 accessToken,
509 password: 'password updated'
510 })
511
512 await getMyUserVideoQuotaUsed(server.url, accessTokenUser, 401)
513
514 await userLogin(server, user, 400)
515
516 user.password = 'password updated'
517 accessTokenUser = await userLogin(server, user)
470 }) 518 })
471 519
472 it('Should be able to list video blacklist by a moderator', async function () { 520 it('Should be able to list video blacklist by a moderator', async function () {
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index 09bb62a8d..a501a80b2 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -3,16 +3,17 @@ import './services'
3import './single-server' 3import './single-server'
4import './video-abuse' 4import './video-abuse'
5import './video-blacklist' 5import './video-blacklist'
6import './video-blacklist-management'
7import './video-captions' 6import './video-captions'
8import './video-change-ownership' 7import './video-change-ownership'
9import './video-channels' 8import './video-channels'
10import './video-comments' 9import './video-comments'
11import './video-description' 10import './video-description'
11import './video-hls'
12import './video-imports' 12import './video-imports'
13import './video-nsfw' 13import './video-nsfw'
14import './video-privacy' 14import './video-privacy'
15import './video-schedule-update' 15import './video-schedule-update'
16import './video-transcoder' 16import './video-transcoder'
17import './videos-filter'
17import './videos-history' 18import './videos-history'
18import './videos-overview' 19import './videos-overview'
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 99b74ccff..1b471ba79 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -8,6 +8,7 @@ import { VideoPrivacy } from '../../../../shared/models/videos'
8import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' 8import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
9import { 9import {
10 addVideoChannel, 10 addVideoChannel,
11 checkTmpIsEmpty,
11 checkVideoFilesWereRemoved, 12 checkVideoFilesWereRemoved,
12 completeVideoCheck, 13 completeVideoCheck,
13 createUser, 14 createUser,
@@ -31,15 +32,15 @@ import {
31 viewVideo, 32 viewVideo,
32 wait, 33 wait,
33 webtorrentAdd 34 webtorrentAdd
34} from '../../utils' 35} from '../../../../shared/utils'
35import { 36import {
36 addVideoCommentReply, 37 addVideoCommentReply,
37 addVideoCommentThread, 38 addVideoCommentThread,
38 deleteVideoComment, 39 deleteVideoComment,
39 getVideoCommentThreads, 40 getVideoCommentThreads,
40 getVideoThreadComments 41 getVideoThreadComments
41} from '../../utils/videos/video-comments' 42} from '../../../../shared/utils/videos/video-comments'
42import { waitJobs } from '../../utils/server/jobs' 43import { waitJobs } from '../../../../shared/utils/server/jobs'
43 44
44const expect = chai.expect 45const expect = chai.expect
45 46
@@ -995,19 +996,19 @@ describe('Test multiple servers', function () {
995 files: [ 996 files: [
996 { 997 {
997 resolution: 720, 998 resolution: 720,
998 size: 36000 999 size: 72000
999 }, 1000 },
1000 { 1001 {
1001 resolution: 480, 1002 resolution: 480,
1002 size: 21000 1003 size: 45000
1003 }, 1004 },
1004 { 1005 {
1005 resolution: 360, 1006 resolution: 360,
1006 size: 17000 1007 size: 34600
1007 }, 1008 },
1008 { 1009 {
1009 resolution: 240, 1010 resolution: 240,
1010 size: 13000 1011 size: 24770
1011 } 1012 }
1012 ] 1013 ]
1013 } 1014 }
@@ -1016,6 +1017,14 @@ describe('Test multiple servers', function () {
1016 }) 1017 })
1017 }) 1018 })
1018 1019
1020 describe('TMP directory', function () {
1021 it('Should have an empty tmp directory', async function () {
1022 for (const server of servers) {
1023 await checkTmpIsEmpty(server)
1024 }
1025 })
1026 })
1027
1019 after(async function () { 1028 after(async function () {
1020 killallServers(servers) 1029 killallServers(servers)
1021 1030
diff --git a/server/tests/api/videos/services.ts b/server/tests/api/videos/services.ts
index 2f1424292..2da86964f 100644
--- a/server/tests/api/videos/services.ts
+++ b/server/tests/api/videos/services.ts
@@ -2,8 +2,16 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { flushTests, getOEmbed, getVideosList, killallServers, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils/index' 5import {
6import { runServer } from '../../utils/server/servers' 6 flushTests,
7 getOEmbed,
8 getVideosList,
9 killallServers,
10 ServerInfo,
11 setAccessTokensToServers,
12 uploadVideo
13} from '../../../../shared/utils/index'
14import { runServer } from '../../../../shared/utils/server/servers'
7 15
8const expect = chai.expect 16const expect = chai.expect
9 17
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts
index 92d42eb80..cfdcbaf3f 100644
--- a/server/tests/api/videos/single-server.ts
+++ b/server/tests/api/videos/single-server.ts
@@ -28,7 +28,7 @@ import {
28 uploadVideo, 28 uploadVideo,
29 viewVideo, 29 viewVideo,
30 wait 30 wait
31} from '../../utils' 31} from '../../../../shared/utils'
32 32
33const expect = chai.expect 33const expect = chai.expect
34 34
@@ -120,7 +120,7 @@ describe('Test a single server', function () {
120 const categories = res.body 120 const categories = res.body
121 expect(Object.keys(categories)).to.have.length.above(10) 121 expect(Object.keys(categories)).to.have.length.above(10)
122 122
123 expect(categories[11]).to.equal('News') 123 expect(categories[11]).to.equal('News & Politics')
124 }) 124 })
125 125
126 it('Should list video licences', async function () { 126 it('Should list video licences', async function () {
diff --git a/server/tests/api/videos/video-abuse.ts b/server/tests/api/videos/video-abuse.ts
index a17f3c8de..3a7b623da 100644
--- a/server/tests/api/videos/video-abuse.ts
+++ b/server/tests/api/videos/video-abuse.ts
@@ -14,9 +14,9 @@ import {
14 setAccessTokensToServers, 14 setAccessTokensToServers,
15 updateVideoAbuse, 15 updateVideoAbuse,
16 uploadVideo 16 uploadVideo
17} from '../../utils/index' 17} from '../../../../shared/utils/index'
18import { doubleFollow } from '../../utils/server/follows' 18import { doubleFollow } from '../../../../shared/utils/server/follows'
19import { waitJobs } from '../../utils/server/jobs' 19import { waitJobs } from '../../../../shared/utils/server/jobs'
20 20
21const expect = chai.expect 21const expect = chai.expect
22 22
diff --git a/server/tests/api/videos/video-blacklist-management.ts b/server/tests/api/videos/video-blacklist-management.ts
deleted file mode 100644
index 7bf39dc99..000000000
--- a/server/tests/api/videos/video-blacklist-management.ts
+++ /dev/null
@@ -1,193 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import * as lodash from 'lodash'
5import 'mocha'
6import {
7 addVideoToBlacklist,
8 flushAndRunMultipleServers,
9 getBlacklistedVideosList,
10 getMyVideos,
11 getSortedBlacklistedVideosList,
12 getVideosList,
13 killallServers,
14 removeVideoFromBlacklist,
15 ServerInfo,
16 setAccessTokensToServers,
17 updateVideoBlacklist,
18 uploadVideo
19} from '../../utils/index'
20import { doubleFollow } from '../../utils/server/follows'
21import { waitJobs } from '../../utils/server/jobs'
22import { VideoAbuse } from '../../../../shared/models/videos'
23
24const expect = chai.expect
25const orderBy = lodash.orderBy
26
27describe('Test video blacklist management', function () {
28 let servers: ServerInfo[] = []
29 let videoId: number
30
31 async function blacklistVideosOnServer (server: ServerInfo) {
32 const res = await getVideosList(server.url)
33
34 const videos = res.body.data
35 for (let video of videos) {
36 await addVideoToBlacklist(server.url, server.accessToken, video.id, 'super reason')
37 }
38 }
39
40 before(async function () {
41 this.timeout(50000)
42
43 // Run servers
44 servers = await flushAndRunMultipleServers(2)
45
46 // Get the access tokens
47 await setAccessTokensToServers(servers)
48
49 // Server 1 and server 2 follow each other
50 await doubleFollow(servers[0], servers[1])
51
52 // Upload 2 videos on server 2
53 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 1st video', description: 'A video on server 2' })
54 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 2nd video', description: 'A video on server 2' })
55
56 // Wait videos propagation, server 2 has transcoding enabled
57 await waitJobs(servers)
58
59 // Blacklist the two videos on server 1
60 await blacklistVideosOnServer(servers[0])
61 })
62
63 describe('When listing blacklisted videos', function () {
64 it('Should display all the blacklisted videos', async function () {
65 const res = await getBlacklistedVideosList(servers[0].url, servers[0].accessToken)
66
67 expect(res.body.total).to.equal(2)
68
69 const blacklistedVideos = res.body.data
70 expect(blacklistedVideos).to.be.an('array')
71 expect(blacklistedVideos.length).to.equal(2)
72
73 for (const blacklistedVideo of blacklistedVideos) {
74 expect(blacklistedVideo.reason).to.equal('super reason')
75 videoId = blacklistedVideo.video.id
76 }
77 })
78
79 it('Should get the correct sort when sorting by descending id', async function () {
80 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-id')
81 expect(res.body.total).to.equal(2)
82
83 const blacklistedVideos = res.body.data
84 expect(blacklistedVideos).to.be.an('array')
85 expect(blacklistedVideos.length).to.equal(2)
86
87 const result = orderBy(res.body.data, [ 'id' ], [ 'desc' ])
88
89 expect(blacklistedVideos).to.deep.equal(result)
90 })
91
92 it('Should get the correct sort when sorting by descending video name', async function () {
93 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
94 expect(res.body.total).to.equal(2)
95
96 const blacklistedVideos = res.body.data
97 expect(blacklistedVideos).to.be.an('array')
98 expect(blacklistedVideos.length).to.equal(2)
99
100 const result = orderBy(res.body.data, [ 'name' ], [ 'desc' ])
101
102 expect(blacklistedVideos).to.deep.equal(result)
103 })
104
105 it('Should get the correct sort when sorting by ascending creation date', async function () {
106 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, 'createdAt')
107 expect(res.body.total).to.equal(2)
108
109 const blacklistedVideos = res.body.data
110 expect(blacklistedVideos).to.be.an('array')
111 expect(blacklistedVideos.length).to.equal(2)
112
113 const result = orderBy(res.body.data, [ 'createdAt' ])
114
115 expect(blacklistedVideos).to.deep.equal(result)
116 })
117 })
118
119 describe('When updating blacklisted videos', function () {
120 it('Should change the reason', async function () {
121 await updateVideoBlacklist(servers[0].url, servers[0].accessToken, videoId, 'my super reason updated')
122
123 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
124 const video = res.body.data.find(b => b.video.id === videoId)
125
126 expect(video.reason).to.equal('my super reason updated')
127 })
128 })
129
130 describe('When listing my videos', function () {
131 it('Should display blacklisted videos', async function () {
132 await blacklistVideosOnServer(servers[1])
133
134 const res = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 5)
135
136 expect(res.body.total).to.equal(2)
137 expect(res.body.data).to.have.lengthOf(2)
138
139 for (const video of res.body.data) {
140 expect(video.blacklisted).to.be.true
141 expect(video.blacklistedReason).to.equal('super reason')
142 }
143 })
144 })
145
146 describe('When removing a blacklisted video', function () {
147 let videoToRemove: VideoAbuse
148 let blacklist = []
149
150 it('Should not have any video in videos list on server 1', async function () {
151 const res = await getVideosList(servers[0].url)
152 expect(res.body.total).to.equal(0)
153 expect(res.body.data).to.be.an('array')
154 expect(res.body.data.length).to.equal(0)
155 })
156
157 it('Should remove a video from the blacklist on server 1', async function () {
158 // Get one video in the blacklist
159 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
160 videoToRemove = res.body.data[0]
161 blacklist = res.body.data.slice(1)
162
163 // Remove it
164 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, videoToRemove.video.id)
165 })
166
167 it('Should have the ex-blacklisted video in videos list on server 1', async function () {
168 const res = await getVideosList(servers[0].url)
169 expect(res.body.total).to.equal(1)
170
171 const videos = res.body.data
172 expect(videos).to.be.an('array')
173 expect(videos.length).to.equal(1)
174
175 expect(videos[0].name).to.equal(videoToRemove.video.name)
176 expect(videos[0].id).to.equal(videoToRemove.video.id)
177 })
178
179 it('Should not have the ex-blacklisted video in videos blacklist list on server 1', async function () {
180 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
181 expect(res.body.total).to.equal(1)
182
183 const videos = res.body.data
184 expect(videos).to.be.an('array')
185 expect(videos.length).to.equal(1)
186 expect(videos).to.deep.equal(blacklist)
187 })
188 })
189
190 after(async function () {
191 killallServers(servers)
192 })
193})
diff --git a/server/tests/api/videos/video-blacklist.ts b/server/tests/api/videos/video-blacklist.ts
index de4c68f1d..d39ad63b4 100644
--- a/server/tests/api/videos/video-blacklist.ts
+++ b/server/tests/api/videos/video-blacklist.ts
@@ -1,24 +1,43 @@
1/* tslint:disable:no-unused-expression */ 1/* tslint:disable:no-unused-expression */
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import { orderBy } from 'lodash'
4import 'mocha' 5import 'mocha'
5import { 6import {
6 addVideoToBlacklist, 7 addVideoToBlacklist,
7 flushAndRunMultipleServers, 8 flushAndRunMultipleServers,
9 getBlacklistedVideosList,
10 getMyVideos,
11 getSortedBlacklistedVideosList,
8 getVideosList, 12 getVideosList,
9 killallServers, 13 killallServers,
14 removeVideoFromBlacklist,
10 searchVideo, 15 searchVideo,
11 ServerInfo, 16 ServerInfo,
12 setAccessTokensToServers, 17 setAccessTokensToServers,
13 uploadVideo 18 updateVideo,
14} from '../../utils/index' 19 updateVideoBlacklist,
15import { doubleFollow } from '../../utils/server/follows' 20 uploadVideo,
16import { waitJobs } from '../../utils/server/jobs' 21 viewVideo
22} from '../../../../shared/utils/index'
23import { doubleFollow } from '../../../../shared/utils/server/follows'
24import { waitJobs } from '../../../../shared/utils/server/jobs'
25import { VideoBlacklist } from '../../../../shared/models/videos'
17 26
18const expect = chai.expect 27const expect = chai.expect
19 28
20describe('Test video blacklists', function () { 29describe('Test video blacklist management', function () {
21 let servers: ServerInfo[] = [] 30 let servers: ServerInfo[] = []
31 let videoId: number
32
33 async function blacklistVideosOnServer (server: ServerInfo) {
34 const res = await getVideosList(server.url)
35
36 const videos = res.body.data
37 for (let video of videos) {
38 await addVideoToBlacklist(server.url, server.accessToken, video.id, 'super reason')
39 }
40 }
22 41
23 before(async function () { 42 before(async function () {
24 this.timeout(50000) 43 this.timeout(50000)
@@ -32,58 +51,270 @@ describe('Test video blacklists', function () {
32 // Server 1 and server 2 follow each other 51 // Server 1 and server 2 follow each other
33 await doubleFollow(servers[0], servers[1]) 52 await doubleFollow(servers[0], servers[1])
34 53
35 // Upload a video on server 2 54 // Upload 2 videos on server 2
36 const videoAttributes = { 55 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 1st video', description: 'A video on server 2' })
37 name: 'my super name for server 2', 56 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 2nd video', description: 'A video on server 2' })
38 description: 'my super description for server 2'
39 }
40 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes)
41 57
42 // Wait videos propagation, server 2 has transcoding enabled 58 // Wait videos propagation, server 2 has transcoding enabled
43 await waitJobs(servers) 59 await waitJobs(servers)
44 60
45 const res = await getVideosList(servers[0].url) 61 // Blacklist the two videos on server 1
46 const videos = res.body.data 62 await blacklistVideosOnServer(servers[0])
63 })
64
65 describe('When listing/searching videos', function () {
47 66
48 expect(videos.length).to.equal(1) 67 it('Should not have the video blacklisted in videos list/search on server 1', async function () {
68 {
69 const res = await getVideosList(servers[ 0 ].url)
49 70
50 servers[0].remoteVideo = videos.find(video => video.name === 'my super name for server 2') 71 expect(res.body.total).to.equal(0)
72 expect(res.body.data).to.be.an('array')
73 expect(res.body.data.length).to.equal(0)
74 }
75
76 {
77 const res = await searchVideo(servers[ 0 ].url, 'name')
78
79 expect(res.body.total).to.equal(0)
80 expect(res.body.data).to.be.an('array')
81 expect(res.body.data.length).to.equal(0)
82 }
83 })
84
85 it('Should have the blacklisted video in videos list/search on server 2', async function () {
86 {
87 const res = await getVideosList(servers[ 1 ].url)
88
89 expect(res.body.total).to.equal(2)
90 expect(res.body.data).to.be.an('array')
91 expect(res.body.data.length).to.equal(2)
92 }
93
94 {
95 const res = await searchVideo(servers[ 1 ].url, 'video')
96
97 expect(res.body.total).to.equal(2)
98 expect(res.body.data).to.be.an('array')
99 expect(res.body.data.length).to.equal(2)
100 }
101 })
51 }) 102 })
52 103
53 it('Should blacklist a remote video on server 1', async function () { 104 describe('When listing blacklisted videos', function () {
54 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, servers[0].remoteVideo.id) 105 it('Should display all the blacklisted videos', async function () {
106 const res = await getBlacklistedVideosList(servers[0].url, servers[0].accessToken)
107
108 expect(res.body.total).to.equal(2)
109
110 const blacklistedVideos = res.body.data
111 expect(blacklistedVideos).to.be.an('array')
112 expect(blacklistedVideos.length).to.equal(2)
113
114 for (const blacklistedVideo of blacklistedVideos) {
115 expect(blacklistedVideo.reason).to.equal('super reason')
116 videoId = blacklistedVideo.video.id
117 }
118 })
119
120 it('Should get the correct sort when sorting by descending id', async function () {
121 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-id')
122 expect(res.body.total).to.equal(2)
123
124 const blacklistedVideos = res.body.data
125 expect(blacklistedVideos).to.be.an('array')
126 expect(blacklistedVideos.length).to.equal(2)
127
128 const result = orderBy(res.body.data, [ 'id' ], [ 'desc' ])
129
130 expect(blacklistedVideos).to.deep.equal(result)
131 })
132
133 it('Should get the correct sort when sorting by descending video name', async function () {
134 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
135 expect(res.body.total).to.equal(2)
136
137 const blacklistedVideos = res.body.data
138 expect(blacklistedVideos).to.be.an('array')
139 expect(blacklistedVideos.length).to.equal(2)
140
141 const result = orderBy(res.body.data, [ 'name' ], [ 'desc' ])
142
143 expect(blacklistedVideos).to.deep.equal(result)
144 })
145
146 it('Should get the correct sort when sorting by ascending creation date', async function () {
147 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, 'createdAt')
148 expect(res.body.total).to.equal(2)
149
150 const blacklistedVideos = res.body.data
151 expect(blacklistedVideos).to.be.an('array')
152 expect(blacklistedVideos.length).to.equal(2)
153
154 const result = orderBy(res.body.data, [ 'createdAt' ])
155
156 expect(blacklistedVideos).to.deep.equal(result)
157 })
55 }) 158 })
56 159
57 it('Should not have the video blacklisted in videos list on server 1', async function () { 160 describe('When updating blacklisted videos', function () {
58 const res = await getVideosList(servers[0].url) 161 it('Should change the reason', async function () {
162 await updateVideoBlacklist(servers[0].url, servers[0].accessToken, videoId, 'my super reason updated')
163
164 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
165 const video = res.body.data.find(b => b.video.id === videoId)
59 166
60 expect(res.body.total).to.equal(0) 167 expect(video.reason).to.equal('my super reason updated')
61 expect(res.body.data).to.be.an('array') 168 })
62 expect(res.body.data.length).to.equal(0)
63 }) 169 })
64 170
65 it('Should not have the video blacklisted in videos search on server 1', async function () { 171 describe('When listing my videos', function () {
66 const res = await searchVideo(servers[0].url, 'name') 172 it('Should display blacklisted videos', async function () {
173 await blacklistVideosOnServer(servers[1])
174
175 const res = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 5)
67 176
68 expect(res.body.total).to.equal(0) 177 expect(res.body.total).to.equal(2)
69 expect(res.body.data).to.be.an('array') 178 expect(res.body.data).to.have.lengthOf(2)
70 expect(res.body.data.length).to.equal(0) 179
180 for (const video of res.body.data) {
181 expect(video.blacklisted).to.be.true
182 expect(video.blacklistedReason).to.equal('super reason')
183 }
184 })
71 }) 185 })
72 186
73 it('Should have the blacklisted video in videos list on server 2', async function () { 187 describe('When removing a blacklisted video', function () {
74 const res = await getVideosList(servers[1].url) 188 let videoToRemove: VideoBlacklist
189 let blacklist = []
190
191 it('Should not have any video in videos list on server 1', async function () {
192 const res = await getVideosList(servers[0].url)
193 expect(res.body.total).to.equal(0)
194 expect(res.body.data).to.be.an('array')
195 expect(res.body.data.length).to.equal(0)
196 })
197
198 it('Should remove a video from the blacklist on server 1', async function () {
199 // Get one video in the blacklist
200 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
201 videoToRemove = res.body.data[0]
202 blacklist = res.body.data.slice(1)
203
204 // Remove it
205 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, videoToRemove.video.id)
206 })
207
208 it('Should have the ex-blacklisted video in videos list on server 1', async function () {
209 const res = await getVideosList(servers[0].url)
210 expect(res.body.total).to.equal(1)
211
212 const videos = res.body.data
213 expect(videos).to.be.an('array')
214 expect(videos.length).to.equal(1)
215
216 expect(videos[0].name).to.equal(videoToRemove.video.name)
217 expect(videos[0].id).to.equal(videoToRemove.video.id)
218 })
219
220 it('Should not have the ex-blacklisted video in videos blacklist list on server 1', async function () {
221 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
222 expect(res.body.total).to.equal(1)
75 223
76 expect(res.body.total).to.equal(1) 224 const videos = res.body.data
77 expect(res.body.data).to.be.an('array') 225 expect(videos).to.be.an('array')
78 expect(res.body.data.length).to.equal(1) 226 expect(videos.length).to.equal(1)
227 expect(videos).to.deep.equal(blacklist)
228 })
79 }) 229 })
80 230
81 it('Should have the video blacklisted in videos search on server 2', async function () { 231 describe('When blacklisting local videos', function () {
82 const res = await searchVideo(servers[1].url, 'name') 232 let video3UUID: string
233 let video4UUID: string
234
235 before(async function () {
236 this.timeout(10000)
237
238 {
239 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'Video 3' })
240 video3UUID = res.body.video.uuid
241 }
242 {
243 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'Video 4' })
244 video4UUID = res.body.video.uuid
245 }
246
247 await waitJobs(servers)
248 })
249
250 it('Should blacklist video 3 and keep it federated', async function () {
251 this.timeout(10000)
252
253 await addVideoToBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video3UUID, 'super reason', false)
254
255 await waitJobs(servers)
256
257 {
258 const res = await getVideosList(servers[ 0 ].url)
259 expect(res.body.data.find(v => v.uuid === video3UUID)).to.be.undefined
260 }
261
262 {
263 const res = await getVideosList(servers[ 1 ].url)
264 expect(res.body.data.find(v => v.uuid === video3UUID)).to.not.be.undefined
265 }
266 })
267
268 it('Should unfederate the video', async function () {
269 this.timeout(10000)
270
271 await addVideoToBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video4UUID, 'super reason', true)
272
273 await waitJobs(servers)
274
275 for (const server of servers) {
276 const res = await getVideosList(server.url)
277 expect(res.body.data.find(v => v.uuid === video4UUID)).to.be.undefined
278 }
279 })
280
281 it('Should have the video unfederated even after an Update AP message', async function () {
282 this.timeout(10000)
283
284 await updateVideo(servers[ 0 ].url, servers[ 0 ].accessToken, video4UUID, { description: 'super description' })
285
286 await waitJobs(servers)
287
288 for (const server of servers) {
289 const res = await getVideosList(server.url)
290 expect(res.body.data.find(v => v.uuid === video4UUID)).to.be.undefined
291 }
292 })
293
294 it('Should have the correct video blacklist unfederate attribute', async function () {
295 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, 'createdAt')
296
297 const blacklistedVideos: VideoBlacklist[] = res.body.data
298 const video3Blacklisted = blacklistedVideos.find(b => b.video.uuid === video3UUID)
299 const video4Blacklisted = blacklistedVideos.find(b => b.video.uuid === video4UUID)
300
301 expect(video3Blacklisted.unfederated).to.be.false
302 expect(video4Blacklisted.unfederated).to.be.true
303 })
304
305 it('Should remove the video from blacklist and refederate the video', async function () {
306 this.timeout(10000)
307
308 await removeVideoFromBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video4UUID)
309
310 await waitJobs(servers)
311
312 for (const server of servers) {
313 const res = await getVideosList(server.url)
314 expect(res.body.data.find(v => v.uuid === video4UUID)).to.not.be.undefined
315 }
316 })
83 317
84 expect(res.body.total).to.equal(1)
85 expect(res.body.data).to.be.an('array')
86 expect(res.body.data.length).to.equal(1)
87 }) 318 })
88 319
89 after(async function () { 320 after(async function () {
diff --git a/server/tests/api/videos/video-captions.ts b/server/tests/api/videos/video-captions.ts
index 6e441410d..57bee713f 100644
--- a/server/tests/api/videos/video-captions.ts
+++ b/server/tests/api/videos/video-captions.ts
@@ -2,10 +2,17 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { checkVideoFilesWereRemoved, doubleFollow, flushAndRunMultipleServers, removeVideo, uploadVideo, wait } from '../../utils' 5import {
6import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' 6 checkVideoFilesWereRemoved,
7import { waitJobs } from '../../utils/server/jobs' 7 doubleFollow,
8import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions' 8 flushAndRunMultipleServers,
9 removeVideo,
10 uploadVideo,
11 wait
12} from '../../../../shared/utils'
13import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../../../shared/utils/index'
14import { waitJobs } from '../../../../shared/utils/server/jobs'
15import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../../../shared/utils/videos/video-captions'
9import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model' 16import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
10 17
11const expect = chai.expect 18const expect = chai.expect
diff --git a/server/tests/api/videos/video-change-ownership.ts b/server/tests/api/videos/video-change-ownership.ts
index 1578a471d..25675a966 100644
--- a/server/tests/api/videos/video-change-ownership.ts
+++ b/server/tests/api/videos/video-change-ownership.ts
@@ -18,8 +18,8 @@ import {
18 uploadVideo, 18 uploadVideo,
19 userLogin, 19 userLogin,
20 getVideo 20 getVideo
21} from '../../utils' 21} from '../../../../shared/utils'
22import { waitJobs } from '../../utils/server/jobs' 22import { waitJobs } from '../../../../shared/utils/server/jobs'
23import { User } from '../../../../shared/models/users' 23import { User } from '../../../../shared/models/users'
24import { VideoDetails } from '../../../../shared/models/videos' 24import { VideoDetails } from '../../../../shared/models/videos'
25 25
diff --git a/server/tests/api/videos/video-channels.ts b/server/tests/api/videos/video-channels.ts
index 8138c65d6..63514d69c 100644
--- a/server/tests/api/videos/video-channels.ts
+++ b/server/tests/api/videos/video-channels.ts
@@ -7,11 +7,13 @@ import {
7 createUser, 7 createUser,
8 doubleFollow, 8 doubleFollow,
9 flushAndRunMultipleServers, 9 flushAndRunMultipleServers,
10 getVideoChannelVideos, serverLogin, testImage, 10 getVideoChannelVideos,
11 testImage,
11 updateVideo, 12 updateVideo,
12 updateVideoChannelAvatar, 13 updateVideoChannelAvatar,
13 uploadVideo, wait, userLogin 14 uploadVideo,
14} from '../../utils' 15 userLogin
16} from '../../../../shared/utils'
15import { 17import {
16 addVideoChannel, 18 addVideoChannel,
17 deleteVideoChannel, 19 deleteVideoChannel,
@@ -24,8 +26,8 @@ import {
24 ServerInfo, 26 ServerInfo,
25 setAccessTokensToServers, 27 setAccessTokensToServers,
26 updateVideoChannel 28 updateVideoChannel
27} from '../../utils/index' 29} from '../../../../shared/utils/index'
28import { waitJobs } from '../../utils/server/jobs' 30import { waitJobs } from '../../../../shared/utils/server/jobs'
29 31
30const expect = chai.expect 32const expect = chai.expect
31 33
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index d6e07c5b3..ce1b17e35 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -3,7 +3,7 @@
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' 5import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
6import { testImage } from '../../utils' 6import { testImage } from '../../../../shared/utils'
7import { 7import {
8 dateIsValid, 8 dateIsValid,
9 flushTests, 9 flushTests,
@@ -13,14 +13,14 @@ import {
13 setAccessTokensToServers, 13 setAccessTokensToServers,
14 updateMyAvatar, 14 updateMyAvatar,
15 uploadVideo 15 uploadVideo
16} from '../../utils/index' 16} from '../../../../shared/utils/index'
17import { 17import {
18 addVideoCommentReply, 18 addVideoCommentReply,
19 addVideoCommentThread, 19 addVideoCommentThread,
20 deleteVideoComment, 20 deleteVideoComment,
21 getVideoCommentThreads, 21 getVideoCommentThreads,
22 getVideoThreadComments 22 getVideoThreadComments
23} from '../../utils/videos/video-comments' 23} from '../../../../shared/utils/videos/video-comments'
24 24
25const expect = chai.expect 25const expect = chai.expect
26 26
diff --git a/server/tests/api/videos/video-description.ts b/server/tests/api/videos/video-description.ts
index dd5cd78c0..cbda0b9a6 100644
--- a/server/tests/api/videos/video-description.ts
+++ b/server/tests/api/videos/video-description.ts
@@ -12,9 +12,9 @@ import {
12 setAccessTokensToServers, 12 setAccessTokensToServers,
13 updateVideo, 13 updateVideo,
14 uploadVideo 14 uploadVideo
15} from '../../utils/index' 15} from '../../../../shared/utils/index'
16import { doubleFollow } from '../../utils/server/follows' 16import { doubleFollow } from '../../../../shared/utils/server/follows'
17import { waitJobs } from '../../utils/server/jobs' 17import { waitJobs } from '../../../../shared/utils/server/jobs'
18 18
19const expect = chai.expect 19const expect = chai.expect
20 20
diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts
new file mode 100644
index 000000000..a1214bad1
--- /dev/null
+++ b/server/tests/api/videos/video-hls.ts
@@ -0,0 +1,139 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 checkDirectoryIsEmpty,
7 checkSegmentHash,
8 checkTmpIsEmpty,
9 doubleFollow,
10 flushAndRunMultipleServers,
11 flushTests,
12 getPlaylist,
13 getVideo,
14 killallServers,
15 removeVideo,
16 ServerInfo,
17 setAccessTokensToServers,
18 updateVideo,
19 uploadVideo,
20 waitJobs
21} from '../../../../shared/utils'
22import { VideoDetails } from '../../../../shared/models/videos'
23import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
24import { join } from 'path'
25
26const expect = chai.expect
27
28async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
29 const resolutions = [ 240, 360, 480, 720 ]
30
31 for (const server of servers) {
32 const res = await getVideo(server.url, videoUUID)
33 const videoDetails: VideoDetails = res.body
34
35 expect(videoDetails.streamingPlaylists).to.have.lengthOf(1)
36
37 const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
38 expect(hlsPlaylist).to.not.be.undefined
39
40 {
41 const res2 = await getPlaylist(hlsPlaylist.playlistUrl)
42
43 const masterPlaylist = res2.text
44
45 expect(masterPlaylist).to.contain('#EXT-X-STREAM-INF:BANDWIDTH=55472,RESOLUTION=640x360,FRAME-RATE=25')
46
47 for (const resolution of resolutions) {
48 expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
49 }
50 }
51
52 {
53 for (const resolution of resolutions) {
54 const res2 = await getPlaylist(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}.m3u8`)
55
56 const subPlaylist = res2.text
57 expect(subPlaylist).to.contain(`${videoUUID}-${resolution}-fragmented.mp4`)
58 }
59 }
60
61 {
62 const baseUrl = 'http://localhost:9001/static/playlists/hls'
63
64 for (const resolution of resolutions) {
65 await checkSegmentHash(baseUrl, baseUrl, videoUUID, resolution, hlsPlaylist)
66 }
67 }
68 }
69}
70
71describe('Test HLS videos', function () {
72 let servers: ServerInfo[] = []
73 let videoUUID = ''
74
75 before(async function () {
76 this.timeout(120000)
77
78 servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: true, hls: { enabled: true } } })
79
80 // Get the access tokens
81 await setAccessTokensToServers(servers)
82
83 // Server 1 and server 2 follow each other
84 await doubleFollow(servers[0], servers[1])
85 })
86
87 it('Should upload a video and transcode it to HLS', async function () {
88 this.timeout(120000)
89
90 {
91 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' })
92 videoUUID = res.body.video.uuid
93 }
94
95 await waitJobs(servers)
96
97 await checkHlsPlaylist(servers, videoUUID)
98 })
99
100 it('Should update the video', async function () {
101 await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' })
102
103 await waitJobs(servers)
104
105 await checkHlsPlaylist(servers, videoUUID)
106 })
107
108 it('Should delete the video', async function () {
109 await removeVideo(servers[0].url, servers[0].accessToken, videoUUID)
110
111 await waitJobs(servers)
112
113 for (const server of servers) {
114 await getVideo(server.url, videoUUID, 404)
115 }
116 })
117
118 it('Should have the playlists/segment deleted from the disk', async function () {
119 for (const server of servers) {
120 await checkDirectoryIsEmpty(server, 'videos')
121 await checkDirectoryIsEmpty(server, join('playlists', 'hls'))
122 }
123 })
124
125 it('Should have an empty tmp directory', async function () {
126 for (const server of servers) {
127 await checkTmpIsEmpty(server)
128 }
129 })
130
131 after(async function () {
132 killallServers(servers)
133
134 // Keep the logs if the test failed
135 if (this['ok']) {
136 await flushTests()
137 }
138 })
139})
diff --git a/server/tests/api/videos/video-imports.ts b/server/tests/api/videos/video-imports.ts
index b7866d529..cd4988553 100644
--- a/server/tests/api/videos/video-imports.ts
+++ b/server/tests/api/videos/video-imports.ts
@@ -14,9 +14,9 @@ import {
14 killallServers, 14 killallServers,
15 ServerInfo, 15 ServerInfo,
16 setAccessTokensToServers 16 setAccessTokensToServers
17} from '../../utils' 17} from '../../../../shared/utils'
18import { waitJobs } from '../../utils/server/jobs' 18import { waitJobs } from '../../../../shared/utils/server/jobs'
19import { getMagnetURI, getYoutubeVideoUrl, importVideo, getMyVideoImports } from '../../utils/videos/video-imports' 19import { getMagnetURI, getYoutubeVideoUrl, importVideo, getMyVideoImports } from '../../../../shared/utils/videos/video-imports'
20 20
21const expect = chai.expect 21const expect = chai.expect
22 22
@@ -30,7 +30,7 @@ describe('Test video imports', function () {
30 const videoHttp: VideoDetails = resHttp.body 30 const videoHttp: VideoDetails = resHttp.body
31 31
32 expect(videoHttp.name).to.equal('small video - youtube') 32 expect(videoHttp.name).to.equal('small video - youtube')
33 expect(videoHttp.category.label).to.equal('News') 33 expect(videoHttp.category.label).to.equal('News & Politics')
34 expect(videoHttp.licence.label).to.equal('Attribution') 34 expect(videoHttp.licence.label).to.equal('Attribution')
35 expect(videoHttp.language.label).to.equal('Unknown') 35 expect(videoHttp.language.label).to.equal('Unknown')
36 expect(videoHttp.nsfw).to.be.false 36 expect(videoHttp.nsfw).to.be.false
diff --git a/server/tests/api/videos/video-nsfw.ts b/server/tests/api/videos/video-nsfw.ts
index eab7a6991..df1ee2eb9 100644
--- a/server/tests/api/videos/video-nsfw.ts
+++ b/server/tests/api/videos/video-nsfw.ts
@@ -2,10 +2,17 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { flushTests, getVideosList, killallServers, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils/index' 5import {
6import { userLogin } from '../../utils/users/login' 6 flushTests,
7import { createUser } from '../../utils/users/users' 7 getVideosList,
8import { getMyVideos } from '../../utils/videos/videos' 8 killallServers,
9 ServerInfo,
10 setAccessTokensToServers,
11 uploadVideo
12} from '../../../../shared/utils/index'
13import { userLogin } from '../../../../shared/utils/users/login'
14import { createUser } from '../../../../shared/utils/users/users'
15import { getMyVideos } from '../../../../shared/utils/videos/videos'
9import { 16import {
10 getAccountVideos, 17 getAccountVideos,
11 getConfig, 18 getConfig,
@@ -18,7 +25,7 @@ import {
18 searchVideoWithToken, 25 searchVideoWithToken,
19 updateCustomConfig, 26 updateCustomConfig,
20 updateMyUser 27 updateMyUser
21} from '../../utils' 28} from '../../../../shared/utils'
22import { ServerConfig } from '../../../../shared/models' 29import { ServerConfig } from '../../../../shared/models'
23import { CustomConfig } from '../../../../shared/models/server/custom-config.model' 30import { CustomConfig } from '../../../../shared/models/server/custom-config.model'
24import { User } from '../../../../shared/models/users' 31import { User } from '../../../../shared/models/users'
diff --git a/server/tests/api/videos/video-privacy.ts b/server/tests/api/videos/video-privacy.ts
index 9fefca7e3..0b4e66369 100644
--- a/server/tests/api/videos/video-privacy.ts
+++ b/server/tests/api/videos/video-privacy.ts
@@ -10,12 +10,12 @@ import {
10 ServerInfo, 10 ServerInfo,
11 setAccessTokensToServers, 11 setAccessTokensToServers,
12 uploadVideo 12 uploadVideo
13} from '../../utils/index' 13} from '../../../../shared/utils/index'
14import { doubleFollow } from '../../utils/server/follows' 14import { doubleFollow } from '../../../../shared/utils/server/follows'
15import { userLogin } from '../../utils/users/login' 15import { userLogin } from '../../../../shared/utils/users/login'
16import { createUser } from '../../utils/users/users' 16import { createUser } from '../../../../shared/utils/users/users'
17import { getMyVideos, getVideo, getVideoWithToken, updateVideo } from '../../utils/videos/videos' 17import { getMyVideos, getVideo, getVideoWithToken, updateVideo } from '../../../../shared/utils/videos/videos'
18import { waitJobs } from '../../utils/server/jobs' 18import { waitJobs } from '../../../../shared/utils/server/jobs'
19 19
20const expect = chai.expect 20const expect = chai.expect
21 21
diff --git a/server/tests/api/videos/video-schedule-update.ts b/server/tests/api/videos/video-schedule-update.ts
index a260fa4da..632c4244c 100644
--- a/server/tests/api/videos/video-schedule-update.ts
+++ b/server/tests/api/videos/video-schedule-update.ts
@@ -15,9 +15,8 @@ import {
15 updateVideo, 15 updateVideo,
16 uploadVideo, 16 uploadVideo,
17 wait 17 wait
18} from '../../utils' 18} from '../../../../shared/utils'
19import { join } from 'path' 19import { waitJobs } from '../../../../shared/utils/server/jobs'
20import { waitJobs } from '../../utils/server/jobs'
21 20
22const expect = chai.expect 21const expect = chai.expect
23 22
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts
index 0f83d4d57..eefd32ef8 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/videos/video-transcoder.ts
@@ -3,13 +3,13 @@
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import * as ffmpeg from 'fluent-ffmpeg' 6import { getMaxBitrate, VideoDetails, VideoResolution, VideoState } from '../../../../shared/models/videos'
7import { VideoDetails, VideoState } from '../../../../shared/models/videos' 7import { audio, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
8import { getVideoFileFPS, audio } from '../../../helpers/ffmpeg-utils'
9import { 8import {
10 buildAbsoluteFixturePath, 9 buildAbsoluteFixturePath,
11 doubleFollow, 10 doubleFollow,
12 flushAndRunMultipleServers, 11 flushAndRunMultipleServers,
12 generateHighBitrateVideo,
13 getMyVideos, 13 getMyVideos,
14 getVideo, 14 getVideo,
15 getVideosList, 15 getVideosList,
@@ -19,9 +19,10 @@ import {
19 setAccessTokensToServers, 19 setAccessTokensToServers,
20 uploadVideo, 20 uploadVideo,
21 webtorrentAdd 21 webtorrentAdd
22} from '../../utils' 22} from '../../../../shared/utils'
23import { join } from 'path' 23import { extname, join } from 'path'
24import { waitJobs } from '../../utils/server/jobs' 24import { waitJobs } from '../../../../shared/utils/server/jobs'
25import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants'
25 26
26const expect = chai.expect 27const expect = chai.expect
27 28
@@ -121,7 +122,7 @@ describe('Test video transcoding', function () {
121 expect(videoDetails.files).to.have.lengthOf(4) 122 expect(videoDetails.files).to.have.lengthOf(4)
122 123
123 const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') 124 const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4')
124 const probe = await audio.get(ffmpeg, path) 125 const probe = await audio.get(path)
125 126
126 if (probe.audioStream) { 127 if (probe.audioStream) {
127 expect(probe.audioStream[ 'codec_name' ]).to.be.equal('aac') 128 expect(probe.audioStream[ 'codec_name' ]).to.be.equal('aac')
@@ -152,7 +153,7 @@ describe('Test video transcoding', function () {
152 153
153 expect(videoDetails.files).to.have.lengthOf(4) 154 expect(videoDetails.files).to.have.lengthOf(4)
154 const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') 155 const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4')
155 const probe = await audio.get(ffmpeg, path) 156 const probe = await audio.get(path)
156 expect(probe).to.not.have.property('audioStream') 157 expect(probe).to.not.have.property('audioStream')
157 } 158 }
158 }) 159 })
@@ -177,9 +178,9 @@ describe('Test video transcoding', function () {
177 178
178 expect(videoDetails.files).to.have.lengthOf(4) 179 expect(videoDetails.files).to.have.lengthOf(4)
179 const fixturePath = buildAbsoluteFixturePath(videoAttributes.fixture) 180 const fixturePath = buildAbsoluteFixturePath(videoAttributes.fixture)
180 const fixtureVideoProbe = await audio.get(ffmpeg, fixturePath) 181 const fixtureVideoProbe = await audio.get(fixturePath)
181 const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') 182 const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4')
182 const videoProbe = await audio.get(ffmpeg, path) 183 const videoProbe = await audio.get(path)
183 if (videoProbe.audioStream && fixtureVideoProbe.audioStream) { 184 if (videoProbe.audioStream && fixtureVideoProbe.audioStream) {
184 const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ] 185 const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ]
185 expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit)) 186 expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit))
@@ -228,7 +229,7 @@ describe('Test video transcoding', function () {
228 } 229 }
229 }) 230 })
230 231
231 it('Should wait transcoding before publishing the video', async function () { 232 it('Should wait for transcoding before publishing the video', async function () {
232 this.timeout(80000) 233 this.timeout(80000)
233 234
234 { 235 {
@@ -281,6 +282,73 @@ describe('Test video transcoding', function () {
281 } 282 }
282 }) 283 })
283 284
285 it('Should respect maximum bitrate values', async function () {
286 this.timeout(160000)
287
288 let tempFixturePath: string
289
290 {
291 tempFixturePath = await generateHighBitrateVideo()
292
293 const bitrate = await getVideoFileBitrate(tempFixturePath)
294 expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 60, VIDEO_TRANSCODING_FPS))
295 }
296
297 const videoAttributes = {
298 name: 'high bitrate video',
299 description: 'high bitrate video',
300 fixture: tempFixturePath
301 }
302
303 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes)
304
305 await waitJobs(servers)
306
307 for (const server of servers) {
308 const res = await getVideosList(server.url)
309
310 const video = res.body.data.find(v => v.name === videoAttributes.name)
311
312 for (const resolution of ['240', '360', '480', '720', '1080']) {
313 const path = join(root(), 'test2', 'videos', video.uuid + '-' + resolution + '.mp4')
314 const bitrate = await getVideoFileBitrate(path)
315 const fps = await getVideoFileFPS(path)
316 const resolution2 = await getVideoFileResolution(path)
317
318 expect(resolution2.videoFileResolution.toString()).to.equal(resolution)
319 expect(bitrate).to.be.below(getMaxBitrate(resolution2.videoFileResolution, fps, VIDEO_TRANSCODING_FPS))
320 }
321 }
322 })
323
324 it('Should accept and transcode additional extensions', async function () {
325 this.timeout(300000)
326
327 for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) {
328 const videoAttributes = {
329 name: fixture,
330 fixture
331 }
332
333 await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributes)
334
335 await waitJobs(servers)
336
337 for (const server of servers) {
338 const res = await getVideosList(server.url)
339
340 const video = res.body.data.find(v => v.name === videoAttributes.name)
341 const res2 = await getVideo(server.url, video.id)
342 const videoDetails = res2.body
343
344 expect(videoDetails.files).to.have.lengthOf(4)
345
346 const magnetUri = videoDetails.files[ 0 ].magnetUri
347 expect(magnetUri).to.contain('.mp4')
348 }
349 }
350 })
351
284 after(async function () { 352 after(async function () {
285 killallServers(servers) 353 killallServers(servers)
286 }) 354 })
diff --git a/server/tests/api/videos/videos-filter.ts b/server/tests/api/videos/videos-filter.ts
new file mode 100644
index 000000000..59e37ad86
--- /dev/null
+++ b/server/tests/api/videos/videos-filter.ts
@@ -0,0 +1,130 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 createUser,
7 doubleFollow,
8 flushAndRunMultipleServers,
9 flushTests,
10 killallServers,
11 makeGetRequest,
12 ServerInfo,
13 setAccessTokensToServers,
14 uploadVideo,
15 userLogin
16} from '../../../../shared/utils'
17import { Video, VideoPrivacy } from '../../../../shared/models/videos'
18import { UserRole } from '../../../../shared/models/users'
19
20const expect = chai.expect
21
22async function getVideosNames (server: ServerInfo, token: string, filter: string, statusCodeExpected = 200) {
23 const paths = [
24 '/api/v1/video-channels/root_channel/videos',
25 '/api/v1/accounts/root/videos',
26 '/api/v1/videos',
27 '/api/v1/search/videos'
28 ]
29
30 const videosResults: Video[][] = []
31
32 for (const path of paths) {
33 const res = await makeGetRequest({
34 url: server.url,
35 path,
36 token,
37 query: {
38 sort: 'createdAt',
39 filter
40 },
41 statusCodeExpected
42 })
43
44 videosResults.push(res.body.data.map(v => v.name))
45 }
46
47 return videosResults
48}
49
50describe('Test videos filter validator', function () {
51 let servers: ServerInfo[]
52
53 // ---------------------------------------------------------------
54
55 before(async function () {
56 this.timeout(120000)
57
58 await flushTests()
59
60 servers = await flushAndRunMultipleServers(2)
61
62 await setAccessTokensToServers(servers)
63
64 for (const server of servers) {
65 const moderator = { username: 'moderator', password: 'my super password' }
66 await createUser(
67 server.url,
68 server.accessToken,
69 moderator.username,
70 moderator.password,
71 undefined,
72 undefined,
73 UserRole.MODERATOR
74 )
75 server['moderatorAccessToken'] = await userLogin(server, moderator)
76
77 await uploadVideo(server.url, server.accessToken, { name: 'public ' + server.serverNumber })
78
79 {
80 const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED }
81 await uploadVideo(server.url, server.accessToken, attributes)
82 }
83
84 {
85 const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE }
86 await uploadVideo(server.url, server.accessToken, attributes)
87 }
88 }
89
90 await doubleFollow(servers[0], servers[1])
91 })
92
93 describe('Check videos filter', function () {
94
95 it('Should display local videos', async function () {
96 for (const server of servers) {
97 const namesResults = await getVideosNames(server, server.accessToken, 'local')
98 for (const names of namesResults) {
99 expect(names).to.have.lengthOf(1)
100 expect(names[ 0 ]).to.equal('public ' + server.serverNumber)
101 }
102 }
103 })
104
105 it('Should display all local videos by the admin or the moderator', async function () {
106 for (const server of servers) {
107 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) {
108
109 const namesResults = await getVideosNames(server, token, 'all-local')
110 for (const names of namesResults) {
111 expect(names).to.have.lengthOf(3)
112
113 expect(names[ 0 ]).to.equal('public ' + server.serverNumber)
114 expect(names[ 1 ]).to.equal('unlisted ' + server.serverNumber)
115 expect(names[ 2 ]).to.equal('private ' + server.serverNumber)
116 }
117 }
118 }
119 })
120 })
121
122 after(async function () {
123 killallServers(servers)
124
125 // Keep the logs if the test failed
126 if (this['ok']) {
127 await flushTests()
128 }
129 })
130})
diff --git a/server/tests/api/videos/videos-history.ts b/server/tests/api/videos/videos-history.ts
index 6d289b288..f654a422b 100644
--- a/server/tests/api/videos/videos-history.ts
+++ b/server/tests/api/videos/videos-history.ts
@@ -3,17 +3,21 @@
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { 5import {
6 createUser,
6 flushTests, 7 flushTests,
7 getVideosListWithToken, 8 getVideosListWithToken,
8 getVideoWithToken, 9 getVideoWithToken,
9 killallServers, makePutBodyRequest, 10 killallServers,
10 runServer, searchVideoWithToken, 11 runServer,
12 searchVideoWithToken,
11 ServerInfo, 13 ServerInfo,
12 setAccessTokensToServers, 14 setAccessTokensToServers,
13 uploadVideo 15 updateMyUser,
14} from '../../utils' 16 uploadVideo,
17 userLogin
18} from '../../../../shared/utils'
15import { Video, VideoDetails } from '../../../../shared/models/videos' 19import { Video, VideoDetails } from '../../../../shared/models/videos'
16import { userWatchVideo } from '../../utils/videos/video-history' 20import { listMyVideosHistory, removeMyVideosHistory, userWatchVideo } from '../../../../shared/utils/videos/video-history'
17 21
18const expect = chai.expect 22const expect = chai.expect
19 23
@@ -22,6 +26,8 @@ describe('Test videos history', function () {
22 let video1UUID: string 26 let video1UUID: string
23 let video2UUID: string 27 let video2UUID: string
24 let video3UUID: string 28 let video3UUID: string
29 let video3WatchedDate: Date
30 let userAccessToken: string
25 31
26 before(async function () { 32 before(async function () {
27 this.timeout(30000) 33 this.timeout(30000)
@@ -46,6 +52,13 @@ describe('Test videos history', function () {
46 const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' }) 52 const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' })
47 video3UUID = res.body.video.uuid 53 video3UUID = res.body.video.uuid
48 } 54 }
55
56 const user = {
57 username: 'user_1',
58 password: 'super password'
59 }
60 await createUser(server.url, server.accessToken, user.username, user.password)
61 userAccessToken = await userLogin(server, user)
49 }) 62 })
50 63
51 it('Should get videos, without watching history', async function () { 64 it('Should get videos, without watching history', async function () {
@@ -62,8 +75,8 @@ describe('Test videos history', function () {
62 }) 75 })
63 76
64 it('Should watch the first and second video', async function () { 77 it('Should watch the first and second video', async function () {
65 await userWatchVideo(server.url, server.accessToken, video1UUID, 3)
66 await userWatchVideo(server.url, server.accessToken, video2UUID, 8) 78 await userWatchVideo(server.url, server.accessToken, video2UUID, 8)
79 await userWatchVideo(server.url, server.accessToken, video1UUID, 3)
67 }) 80 })
68 81
69 it('Should return the correct history when listing, searching and getting videos', async function () { 82 it('Should return the correct history when listing, searching and getting videos', async function () {
@@ -117,6 +130,68 @@ describe('Test videos history', function () {
117 } 130 }
118 }) 131 })
119 132
133 it('Should have these videos when listing my history', async function () {
134 video3WatchedDate = new Date()
135 await userWatchVideo(server.url, server.accessToken, video3UUID, 2)
136
137 const res = await listMyVideosHistory(server.url, server.accessToken)
138
139 expect(res.body.total).to.equal(3)
140
141 const videos: Video[] = res.body.data
142 expect(videos[0].name).to.equal('video 3')
143 expect(videos[1].name).to.equal('video 1')
144 expect(videos[2].name).to.equal('video 2')
145 })
146
147 it('Should not have videos history on another user', async function () {
148 const res = await listMyVideosHistory(server.url, userAccessToken)
149
150 expect(res.body.total).to.equal(0)
151 expect(res.body.data).to.have.lengthOf(0)
152 })
153
154 it('Should clear my history', async function () {
155 await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString())
156 })
157
158 it('Should have my history cleared', async function () {
159 const res = await listMyVideosHistory(server.url, server.accessToken)
160
161 expect(res.body.total).to.equal(1)
162
163 const videos: Video[] = res.body.data
164 expect(videos[0].name).to.equal('video 3')
165 })
166
167 it('Should disable videos history', async function () {
168 await updateMyUser({
169 url: server.url,
170 accessToken: server.accessToken,
171 videosHistoryEnabled: false
172 })
173
174 await userWatchVideo(server.url, server.accessToken, video2UUID, 8, 409)
175 })
176
177 it('Should re-enable videos history', async function () {
178 await updateMyUser({
179 url: server.url,
180 accessToken: server.accessToken,
181 videosHistoryEnabled: true
182 })
183
184 await userWatchVideo(server.url, server.accessToken, video1UUID, 8)
185
186 const res = await listMyVideosHistory(server.url, server.accessToken)
187
188 expect(res.body.total).to.equal(2)
189
190 const videos: Video[] = res.body.data
191 expect(videos[0].name).to.equal('video 1')
192 expect(videos[1].name).to.equal('video 3')
193 })
194
120 after(async function () { 195 after(async function () {
121 killallServers([ server ]) 196 killallServers([ server ])
122 197
diff --git a/server/tests/api/videos/videos-overview.ts b/server/tests/api/videos/videos-overview.ts
index 7d1f29c92..7221bcae6 100644
--- a/server/tests/api/videos/videos-overview.ts
+++ b/server/tests/api/videos/videos-overview.ts
@@ -2,8 +2,8 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils' 5import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../../../shared/utils'
6import { getVideosOverview } from '../../utils/overviews/overviews' 6import { getVideosOverview } from '../../../../shared/utils/overviews/overviews'
7import { VideosOverview } from '../../../../shared/models/overviews' 7import { VideosOverview } from '../../../../shared/models/overviews'
8 8
9const expect = chai.expect 9const expect = chai.expect
diff --git a/server/tests/cli/create-import-video-file-job.ts b/server/tests/cli/create-import-video-file-job.ts
index 13bcfd209..4acda47b1 100644
--- a/server/tests/cli/create-import-video-file-job.ts
+++ b/server/tests/cli/create-import-video-file-job.ts
@@ -15,8 +15,8 @@ import {
15 ServerInfo, 15 ServerInfo,
16 setAccessTokensToServers, 16 setAccessTokensToServers,
17 uploadVideo 17 uploadVideo
18} from '../utils' 18} from '../../../shared/utils'
19import { waitJobs } from '../utils/server/jobs' 19import { waitJobs } from '../../../shared/utils/server/jobs'
20 20
21const expect = chai.expect 21const expect = chai.expect
22 22
diff --git a/server/tests/cli/create-transcoding-job.ts b/server/tests/cli/create-transcoding-job.ts
index c2e3840c5..50be5fa19 100644
--- a/server/tests/cli/create-transcoding-job.ts
+++ b/server/tests/cli/create-transcoding-job.ts
@@ -15,8 +15,8 @@ import {
15 ServerInfo, 15 ServerInfo,
16 setAccessTokensToServers, 16 setAccessTokensToServers,
17 uploadVideo, wait 17 uploadVideo, wait
18} from '../utils' 18} from '../../../shared/utils'
19import { waitJobs } from '../utils/server/jobs' 19import { waitJobs } from '../../../shared/utils/server/jobs'
20 20
21const expect = chai.expect 21const expect = chai.expect
22 22
diff --git a/server/tests/cli/index.ts b/server/tests/cli/index.ts
index 6201314ce..c6b7ec078 100644
--- a/server/tests/cli/index.ts
+++ b/server/tests/cli/index.ts
@@ -1,6 +1,7 @@
1// Order of the tests we want to execute 1// Order of the tests we want to execute
2import './create-import-video-file-job' 2import './create-import-video-file-job'
3import './create-transcoding-job' 3import './create-transcoding-job'
4import './optimize-old-videos'
4import './peertube' 5import './peertube'
5import './reset-password' 6import './reset-password'
6import './update-host' 7import './update-host'
diff --git a/server/tests/cli/optimize-old-videos.ts b/server/tests/cli/optimize-old-videos.ts
new file mode 100644
index 000000000..6f6bc25a6
--- /dev/null
+++ b/server/tests/cli/optimize-old-videos.ts
@@ -0,0 +1,120 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4import * as chai from 'chai'
5import { getMaxBitrate, Video, VideoDetails, VideoResolution } from '../../../shared/models/videos'
6import {
7 doubleFollow,
8 execCLI,
9 flushAndRunMultipleServers,
10 flushTests, generateHighBitrateVideo,
11 getEnvCli,
12 getVideo,
13 getVideosList,
14 killallServers, root,
15 ServerInfo,
16 setAccessTokensToServers,
17 uploadVideo, viewVideo, wait
18} from '../../../shared/utils'
19import { waitJobs } from '../../../shared/utils/server/jobs'
20import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
21import { VIDEO_TRANSCODING_FPS } from '../../initializers'
22import { join } from 'path'
23
24const expect = chai.expect
25
26describe('Test optimize old videos', function () {
27 let servers: ServerInfo[] = []
28 let video1UUID: string
29 let video2UUID: string
30
31 before(async function () {
32 this.timeout(200000)
33
34 await flushTests()
35
36 // Run server 2 to have transcoding enabled
37 servers = await flushAndRunMultipleServers(2)
38 await setAccessTokensToServers(servers)
39
40 await doubleFollow(servers[0], servers[1])
41
42 let tempFixturePath: string
43
44 {
45 tempFixturePath = await generateHighBitrateVideo()
46
47 const bitrate = await getVideoFileBitrate(tempFixturePath)
48 expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 60, VIDEO_TRANSCODING_FPS))
49 }
50
51 // Upload two videos for our needs
52 const res1 = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video1', fixture: tempFixturePath })
53 video1UUID = res1.body.video.uuid
54 const res2 = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video2', fixture: tempFixturePath })
55 video2UUID = res2.body.video.uuid
56
57 await waitJobs(servers)
58 })
59
60 it('Should have two video files on each server', async function () {
61 this.timeout(30000)
62
63 for (const server of servers) {
64 const res = await getVideosList(server.url)
65 const videos = res.body.data
66 expect(videos).to.have.lengthOf(2)
67
68 for (const video of videos) {
69 const res2 = await getVideo(server.url, video.uuid)
70 const videoDetail: VideoDetails = res2.body
71 expect(videoDetail.files).to.have.lengthOf(1)
72 }
73 }
74 })
75
76 it('Should run optimize script', async function () {
77 this.timeout(120000)
78
79 const env = getEnvCli(servers[0])
80 await execCLI(`${env} npm run optimize-old-videos`)
81
82 await waitJobs(servers)
83
84 for (const server of servers) {
85 const res = await getVideosList(server.url)
86 const videos: Video[] = res.body.data
87
88 expect(videos).to.have.lengthOf(2)
89
90 for (const video of videos) {
91 await viewVideo(server.url, video.uuid)
92
93 // Refresh video
94 await waitJobs(servers)
95 await wait(5000)
96 await waitJobs(servers)
97
98 const res2 = await getVideo(server.url, video.uuid)
99 const videosDetails: VideoDetails = res2.body
100
101 expect(videosDetails.files).to.have.lengthOf(1)
102 const file = videosDetails.files[0]
103
104 expect(file.size).to.be.below(5000000)
105
106 const path = join(root(), 'test1', 'videos', video.uuid + '-' + file.resolution.id + '.mp4')
107 const bitrate = await getVideoFileBitrate(path)
108 const fps = await getVideoFileFPS(path)
109 const resolution = await getVideoFileResolution(path)
110
111 expect(resolution.videoFileResolution).to.equal(file.resolution.id)
112 expect(bitrate).to.be.below(getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS))
113 }
114 }
115 })
116
117 after(async function () {
118 killallServers(servers)
119 })
120})
diff --git a/server/tests/cli/peertube.ts b/server/tests/cli/peertube.ts
index 65cb05a1a..e2836d0c3 100644
--- a/server/tests/cli/peertube.ts
+++ b/server/tests/cli/peertube.ts
@@ -11,7 +11,7 @@ import {
11 runServer, 11 runServer,
12 ServerInfo, 12 ServerInfo,
13 setAccessTokensToServers 13 setAccessTokensToServers
14} from '../utils' 14} from '../../../shared/utils'
15 15
16describe('Test CLI wrapper', function () { 16describe('Test CLI wrapper', function () {
17 let server: ServerInfo 17 let server: ServerInfo
@@ -44,6 +44,8 @@ describe('Test CLI wrapper', function () {
44 }) 44 })
45 45
46 after(async function () { 46 after(async function () {
47 this.timeout(10000)
48
47 await execCLI(cmd + ` auth del ${server.url}`) 49 await execCLI(cmd + ` auth del ${server.url}`)
48 50
49 killallServers([ server ]) 51 killallServers([ server ])
diff --git a/server/tests/cli/reset-password.ts b/server/tests/cli/reset-password.ts
index bf937d1c0..1b65f7e39 100644
--- a/server/tests/cli/reset-password.ts
+++ b/server/tests/cli/reset-password.ts
@@ -10,7 +10,7 @@ import {
10 runServer, 10 runServer,
11 ServerInfo, 11 ServerInfo,
12 setAccessTokensToServers 12 setAccessTokensToServers
13} from '../utils' 13} from '../../../shared/utils'
14 14
15describe('Test reset password scripts', function () { 15describe('Test reset password scripts', function () {
16 let server: ServerInfo 16 let server: ServerInfo
diff --git a/server/tests/cli/update-host.ts b/server/tests/cli/update-host.ts
index b89e72ab7..d38bb4331 100644
--- a/server/tests/cli/update-host.ts
+++ b/server/tests/cli/update-host.ts
@@ -3,8 +3,8 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { VideoDetails } from '../../../shared/models/videos' 5import { VideoDetails } from '../../../shared/models/videos'
6import { waitJobs } from '../utils/server/jobs' 6import { waitJobs } from '../../../shared/utils/server/jobs'
7import { addVideoCommentThread } from '../utils/videos/video-comments' 7import { addVideoCommentThread } from '../../../shared/utils/videos/video-comments'
8import { 8import {
9 addVideoChannel, 9 addVideoChannel,
10 createUser, 10 createUser,
@@ -21,8 +21,8 @@ import {
21 ServerInfo, 21 ServerInfo,
22 setAccessTokensToServers, 22 setAccessTokensToServers,
23 uploadVideo 23 uploadVideo
24} from '../utils' 24} from '../../../shared/utils'
25import { getAccountsList } from '../utils/users/accounts' 25import { getAccountsList } from '../../../shared/utils/users/accounts'
26 26
27const expect = chai.expect 27const expect = chai.expect
28 28
@@ -86,6 +86,13 @@ describe('Test update host scripts', function () {
86 const { body } = await makeActivityPubGetRequest(server.url, '/videos/watch/' + video.uuid) 86 const { body } = await makeActivityPubGetRequest(server.url, '/videos/watch/' + video.uuid)
87 87
88 expect(body.id).to.equal('http://localhost:9002/videos/watch/' + video.uuid) 88 expect(body.id).to.equal('http://localhost:9002/videos/watch/' + video.uuid)
89
90 const res = await getVideo(server.url, video.uuid)
91 const videoDetails: VideoDetails = res.body
92
93 expect(videoDetails.trackerUrls[0]).to.include(server.host)
94 expect(videoDetails.streamingPlaylists[0].playlistUrl).to.include(server.host)
95 expect(videoDetails.streamingPlaylists[0].segmentsSha256Url).to.include(server.host)
89 } 96 }
90 }) 97 })
91 98
@@ -100,7 +107,7 @@ describe('Test update host scripts', function () {
100 } 107 }
101 }) 108 })
102 109
103 it('Should have update accounts url', async function () { 110 it('Should have updated accounts url', async function () {
104 const res = await getAccountsList(server.url) 111 const res = await getAccountsList(server.url)
105 expect(res.body.total).to.equal(3) 112 expect(res.body.total).to.equal(3)
106 113
@@ -112,7 +119,7 @@ describe('Test update host scripts', function () {
112 } 119 }
113 }) 120 })
114 121
115 it('Should update torrent hosts', async function () { 122 it('Should have updated torrent hosts', async function () {
116 this.timeout(30000) 123 this.timeout(30000)
117 124
118 const res = await getVideosList(server.url) 125 const res = await getVideosList(server.url)
diff --git a/server/tests/client.ts b/server/tests/client.ts
index b33a653b1..06b4a9c5a 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -15,7 +15,7 @@ import {
15 updateCustomConfig, 15 updateCustomConfig,
16 updateCustomSubConfig, 16 updateCustomSubConfig,
17 uploadVideo 17 uploadVideo
18} from './utils' 18} from '../../shared/utils'
19 19
20const expect = chai.expect 20const expect = chai.expect
21 21
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 28fe3493b..a771474bc 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -13,10 +13,10 @@ import {
13 ServerInfo, 13 ServerInfo,
14 setAccessTokensToServers, 14 setAccessTokensToServers,
15 uploadVideo, userLogin 15 uploadVideo, userLogin
16} from '../utils' 16} from '../../../shared/utils'
17import * as libxmljs from 'libxmljs' 17import * as libxmljs from 'libxmljs'
18import { addVideoCommentThread } from '../utils/videos/video-comments' 18import { addVideoCommentThread } from '../../../shared/utils/videos/video-comments'
19import { waitJobs } from '../utils/server/jobs' 19import { waitJobs } from '../../../shared/utils/server/jobs'
20import { User } from '../../../shared/models/users' 20import { User } from '../../../shared/models/users'
21 21
22chai.use(require('chai-xml')) 22chai.use(require('chai-xml'))
diff --git a/server/tests/fixtures/video_short.avi b/server/tests/fixtures/video_short.avi
new file mode 100644
index 000000000..88979cab2
--- /dev/null
+++ b/server/tests/fixtures/video_short.avi
Binary files differ
diff --git a/server/tests/fixtures/video_short.mkv b/server/tests/fixtures/video_short.mkv
new file mode 100644
index 000000000..a67f4f806
--- /dev/null
+++ b/server/tests/fixtures/video_short.mkv
Binary files differ
diff --git a/server/tests/fixtures/video_short_240p.mp4 b/server/tests/fixtures/video_short_240p.mp4
new file mode 100644
index 000000000..db074940b
--- /dev/null
+++ b/server/tests/fixtures/video_short_240p.mp4
Binary files differ
diff --git a/server/tests/helpers/comment-model.ts b/server/tests/helpers/comment-model.ts
new file mode 100644
index 000000000..76bb0f212
--- /dev/null
+++ b/server/tests/helpers/comment-model.ts
@@ -0,0 +1,25 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { VideoCommentModel } from '../../models/video/video-comment'
6
7const expect = chai.expect
8
9class CommentMock {
10 text: string
11
12 extractMentions = VideoCommentModel.prototype.extractMentions
13}
14
15describe('Comment model', function () {
16 it('Should correctly extract mentions', async function () {
17 const comment = new CommentMock()
18
19 comment.text = '@florian @jean@localhost:9000 @flo @another@localhost:9000 @flo2@jean.com hello ' +
20 'email@localhost:9000 coucou.com no? @chocobozzz @chocobozzz @end'
21 const result = comment.extractMentions().sort()
22
23 expect(result).to.deep.equal([ 'another', 'chocobozzz', 'end', 'flo', 'florian', 'jean' ])
24 })
25})
diff --git a/server/tests/helpers/core-utils.ts b/server/tests/helpers/core-utils.ts
new file mode 100644
index 000000000..e604cf7e3
--- /dev/null
+++ b/server/tests/helpers/core-utils.ts
@@ -0,0 +1,98 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { snakeCase, isNumber } from 'lodash'
6import {
7 parseBytes, objectConverter
8} from '../../helpers/core-utils'
9import { isNumeric } from 'validator'
10
11const expect = chai.expect
12
13describe('Parse Bytes', function () {
14
15 it('Should pass when given valid value', async function () {
16 // just return it
17 expect(parseBytes(1024)).to.be.eq(1024)
18 expect(parseBytes(1048576)).to.be.eq(1048576)
19 expect(parseBytes('1024')).to.be.eq(1024)
20 expect(parseBytes('1048576')).to.be.eq(1048576)
21
22 // sizes
23 expect(parseBytes('1B')).to.be.eq(1024)
24 expect(parseBytes('1MB')).to.be.eq(1048576)
25 expect(parseBytes('1GB')).to.be.eq(1073741824)
26 expect(parseBytes('1TB')).to.be.eq(1099511627776)
27
28 expect(parseBytes('5GB')).to.be.eq(5368709120)
29 expect(parseBytes('5TB')).to.be.eq(5497558138880)
30
31 expect(parseBytes('1024B')).to.be.eq(1048576)
32 expect(parseBytes('1024MB')).to.be.eq(1073741824)
33 expect(parseBytes('1024GB')).to.be.eq(1099511627776)
34 expect(parseBytes('1024TB')).to.be.eq(1125899906842624)
35
36 // with whitespace
37 expect(parseBytes('1 GB')).to.be.eq(1073741824)
38 expect(parseBytes('1\tGB')).to.be.eq(1073741824)
39
40 // sum value
41 expect(parseBytes('1TB 1024MB')).to.be.eq(1100585369600)
42 expect(parseBytes('4GB 1024MB')).to.be.eq(5368709120)
43 expect(parseBytes('4TB 1024GB')).to.be.eq(5497558138880)
44 expect(parseBytes('4TB 1024GB 0MB')).to.be.eq(5497558138880)
45 expect(parseBytes('1024TB 1024GB 1024MB')).to.be.eq(1127000492212224)
46 })
47
48 it('Should be invalid when given invalid value', async function () {
49 expect(parseBytes('6GB 1GB')).to.be.eq(6)
50 })
51
52 it('Should convert an object', async function () {
53 function keyConverter (k: string) {
54 return snakeCase(k)
55 }
56
57 function valueConverter (v: any) {
58 if (isNumeric(v + '')) return parseInt('' + v, 10)
59
60 return v
61 }
62
63 const obj = {
64 mySuperKey: 'hello',
65 mySuper2Key: '45',
66 mySuper3Key: {
67 mySuperSubKey: '15',
68 mySuperSub2Key: 'hello',
69 mySuperSub3Key: [ '1', 'hello', 2 ],
70 mySuperSub4Key: 4
71 },
72 mySuper4Key: 45,
73 toto: {
74 super_key: '15',
75 superKey2: 'hello'
76 },
77 super_key: {
78 superKey4: 15
79 }
80 }
81
82 const res = objectConverter(obj, keyConverter, valueConverter)
83
84 expect(res.my_super_key).to.equal('hello')
85 expect(res.my_super_2_key).to.equal(45)
86 expect(res.my_super_3_key.my_super_sub_key).to.equal(15)
87 expect(res.my_super_3_key.my_super_sub_2_key).to.equal('hello')
88 expect(res.my_super_3_key.my_super_sub_3_key).to.deep.equal([ 1, 'hello', 2 ])
89 expect(res.my_super_3_key.my_super_sub_4_key).to.equal(4)
90 expect(res.toto.super_key).to.equal(15)
91 expect(res.toto.super_key_2).to.equal('hello')
92 expect(res.super_key.super_key_4).to.equal(15)
93
94 // Immutable
95 expect(res.mySuperKey).to.be.undefined
96 expect(obj['my_super_key']).to.be.undefined
97 })
98})
diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts
new file mode 100644
index 000000000..551208245
--- /dev/null
+++ b/server/tests/helpers/index.ts
@@ -0,0 +1,2 @@
1import './core-utils'
2import './comment-model'
diff --git a/server/tests/index.ts b/server/tests/index.ts
index e659fd3df..ed16d65dd 100644
--- a/server/tests/index.ts
+++ b/server/tests/index.ts
@@ -1,6 +1,5 @@
1// Order of the tests we want to execute 1// Order of the tests we want to execute
2import './client' 2import './client'
3import './activitypub'
4import './feeds/' 3import './feeds/'
5import './cli/' 4import './cli/'
6import './api/' 5import './api/'
diff --git a/server/tests/misc-endpoints.ts b/server/tests/misc-endpoints.ts
index 8fab20971..5f82719da 100644
--- a/server/tests/misc-endpoints.ts
+++ b/server/tests/misc-endpoints.ts
@@ -2,7 +2,18 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { flushTests, killallServers, makeGetRequest, runServer, ServerInfo } from './utils' 5import {
6 addVideoChannel,
7 createUser,
8 flushTests,
9 killallServers,
10 makeGetRequest,
11 runServer,
12 ServerInfo,
13 setAccessTokensToServers,
14 uploadVideo
15} from '../../shared/utils'
16import { VideoPrivacy } from '../../shared/models/videos'
6 17
7const expect = chai.expect 18const expect = chai.expect
8 19
@@ -15,6 +26,7 @@ describe('Test misc endpoints', function () {
15 await flushTests() 26 await flushTests()
16 27
17 server = await runServer(1) 28 server = await runServer(1)
29 await setAccessTokensToServers([ server ])
18 }) 30 })
19 31
20 describe('Test a well known endpoints', function () { 32 describe('Test a well known endpoints', function () {
@@ -60,6 +72,16 @@ describe('Test misc endpoints', function () {
60 72
61 expect(res.body.tracking).to.equal('N') 73 expect(res.body.tracking).to.equal('N')
62 }) 74 })
75
76 it('Should get change-password location', async function () {
77 const res = await makeGetRequest({
78 url: server.url,
79 path: '/.well-known/change-password',
80 statusCodeExpected: 302
81 })
82
83 expect(res.header.location).to.equal('/my-account/settings')
84 })
63 }) 85 })
64 86
65 describe('Test classic static endpoints', function () { 87 describe('Test classic static endpoints', function () {
@@ -93,6 +115,64 @@ describe('Test misc endpoints', function () {
93 }) 115 })
94 }) 116 })
95 117
118 describe('Test bots endpoints', function () {
119
120 it('Should get the empty sitemap', async function () {
121 const res = await makeGetRequest({
122 url: server.url,
123 path: '/sitemap.xml',
124 statusCodeExpected: 200
125 })
126
127 expect(res.text).to.contain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')
128 expect(res.text).to.contain('<url><loc>http://localhost:9001/about/instance</loc></url>')
129 })
130
131 it('Should get the empty cached sitemap', async function () {
132 const res = await makeGetRequest({
133 url: server.url,
134 path: '/sitemap.xml',
135 statusCodeExpected: 200
136 })
137
138 expect(res.text).to.contain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')
139 expect(res.text).to.contain('<url><loc>http://localhost:9001/about/instance</loc></url>')
140 })
141
142 it('Should add videos, channel and accounts and get sitemap', async function () {
143 this.timeout(35000)
144
145 await uploadVideo(server.url, server.accessToken, { name: 'video 1', nsfw: false })
146 await uploadVideo(server.url, server.accessToken, { name: 'video 2', nsfw: false })
147 await uploadVideo(server.url, server.accessToken, { name: 'video 3', privacy: VideoPrivacy.PRIVATE })
148
149 await addVideoChannel(server.url, server.accessToken, { name: 'channel1', displayName: 'channel 1' })
150 await addVideoChannel(server.url, server.accessToken, { name: 'channel2', displayName: 'channel 2' })
151
152 await createUser(server.url, server.accessToken, 'user1', 'password')
153 await createUser(server.url, server.accessToken, 'user2', 'password')
154
155 const res = await makeGetRequest({
156 url: server.url,
157 path: '/sitemap.xml?t=1', // avoid using cache
158 statusCodeExpected: 200
159 })
160
161 expect(res.text).to.contain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')
162 expect(res.text).to.contain('<url><loc>http://localhost:9001/about/instance</loc></url>')
163
164 expect(res.text).to.contain('<video:title><![CDATA[video 1]]></video:title>')
165 expect(res.text).to.contain('<video:title><![CDATA[video 2]]></video:title>')
166 expect(res.text).to.not.contain('<video:title><![CDATA[video 3]]></video:title>')
167
168 expect(res.text).to.contain('<url><loc>http://localhost:9001/video-channels/channel1</loc></url>')
169 expect(res.text).to.contain('<url><loc>http://localhost:9001/video-channels/channel2</loc></url>')
170
171 expect(res.text).to.contain('<url><loc>http://localhost:9001/accounts/user1</loc></url>')
172 expect(res.text).to.contain('<url><loc>http://localhost:9001/accounts/user2</loc></url>')
173 })
174 })
175
96 after(async function () { 176 after(async function () {
97 killallServers([ server ]) 177 killallServers([ server ])
98 }) 178 })
diff --git a/server/tests/real-world/populate-database.ts b/server/tests/real-world/populate-database.ts
index a7fdbd1dc..016503498 100644
--- a/server/tests/real-world/populate-database.ts
+++ b/server/tests/real-world/populate-database.ts
@@ -10,7 +10,7 @@ import {
10 ServerInfo, 10 ServerInfo,
11 setAccessTokensToServers, 11 setAccessTokensToServers,
12 uploadVideo 12 uploadVideo
13} from '../utils' 13} from '../../../shared/utils'
14import * as Bluebird from 'bluebird' 14import * as Bluebird from 'bluebird'
15 15
16start() 16start()
diff --git a/server/tests/real-world/real-world.ts b/server/tests/real-world/real-world.ts
index a96469b11..ac3baaf9a 100644
--- a/server/tests/real-world/real-world.ts
+++ b/server/tests/real-world/real-world.ts
@@ -16,8 +16,8 @@ import {
16 updateVideo, 16 updateVideo,
17 uploadVideo, viewVideo, 17 uploadVideo, viewVideo,
18 wait 18 wait
19} from '../utils' 19} from '../../../shared/utils'
20import { getJobsListPaginationAndSort } from '../utils/server/jobs' 20import { getJobsListPaginationAndSort } from '../../../shared/utils/server/jobs'
21 21
22interface ServerInfo extends DefaultServerInfo { 22interface ServerInfo extends DefaultServerInfo {
23 requestsNumber: number 23 requestsNumber: number
diff --git a/server/tests/utils/cli/cli.ts b/server/tests/utils/cli/cli.ts
deleted file mode 100644
index 54d05e9c6..000000000
--- a/server/tests/utils/cli/cli.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { exec } from 'child_process'
2
3import { ServerInfo } from '../server/servers'
4
5function getEnvCli (server?: ServerInfo) {
6 return `NODE_ENV=test NODE_APP_INSTANCE=${server.serverNumber}`
7}
8
9async function execCLI (command: string) {
10 return new Promise<string>((res, rej) => {
11 exec(command, (err, stdout, stderr) => {
12 if (err) return rej(err)
13
14 return res(stdout)
15 })
16 })
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 execCLI,
23 getEnvCli
24}
diff --git a/server/tests/utils/feeds/feeds.ts b/server/tests/utils/feeds/feeds.ts
deleted file mode 100644
index af6df2b20..000000000
--- a/server/tests/utils/feeds/feeds.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import * as request from 'supertest'
2
3type FeedType = 'videos' | 'video-comments'
4
5function getXMLfeed (url: string, feed: FeedType, format?: string) {
6 const path = '/feeds/' + feed + '.xml'
7
8 return request(url)
9 .get(path)
10 .query((format) ? { format: format } : {})
11 .set('Accept', 'application/xml')
12 .expect(200)
13 .expect('Content-Type', /xml/)
14}
15
16function getJSONfeed (url: string, feed: FeedType, query: any = {}) {
17 const path = '/feeds/' + feed + '.json'
18
19 return request(url)
20 .get(path)
21 .query(query)
22 .set('Accept', 'application/json')
23 .expect(200)
24 .expect('Content-Type', /json/)
25}
26
27// ---------------------------------------------------------------------------
28
29export {
30 getXMLfeed,
31 getJSONfeed
32}
diff --git a/server/tests/utils/index.ts b/server/tests/utils/index.ts
deleted file mode 100644
index 897389824..000000000
--- a/server/tests/utils/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1export * from './server/activitypub'
2export * from './cli/cli'
3export * from './server/clients'
4export * from './server/config'
5export * from './users/login'
6export * from './miscs/miscs'
7export * from './server/follows'
8export * from './requests/requests'
9export * from './server/servers'
10export * from './videos/services'
11export * from './users/users'
12export * from './videos/video-abuses'
13export * from './videos/video-blacklist'
14export * from './videos/video-channels'
15export * from './videos/videos'
16export * from './videos/video-change-ownership'
17export * from './feeds/feeds'
18export * from './search/videos'
diff --git a/server/tests/utils/miscs/email.ts b/server/tests/utils/miscs/email.ts
deleted file mode 100644
index 21accd09d..000000000
--- a/server/tests/utils/miscs/email.ts
+++ /dev/null
@@ -1,25 +0,0 @@
1import * as MailDev from 'maildev'
2
3function mockSmtpServer (emailsCollection: object[]) {
4 const maildev = new MailDev({
5 ip: '127.0.0.1',
6 smtp: 1025,
7 disableWeb: true,
8 silent: true
9 })
10 maildev.on('new', email => emailsCollection.push(email))
11
12 return new Promise((res, rej) => {
13 maildev.listen(err => {
14 if (err) return rej(err)
15
16 return res()
17 })
18 })
19}
20
21// ---------------------------------------------------------------------------
22
23export {
24 mockSmtpServer
25}
diff --git a/server/tests/utils/miscs/miscs.ts b/server/tests/utils/miscs/miscs.ts
deleted file mode 100644
index b2f80e9b1..000000000
--- a/server/tests/utils/miscs/miscs.ts
+++ /dev/null
@@ -1,72 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import { isAbsolute, join } from 'path'
5import * as request from 'supertest'
6import * as WebTorrent from 'webtorrent'
7import { readFile } from 'fs-extra'
8
9const expect = chai.expect
10let webtorrent = new WebTorrent()
11
12function immutableAssign <T, U> (target: T, source: U) {
13 return Object.assign<{}, T, U>({}, target, source)
14}
15
16 // Default interval -> 5 minutes
17function dateIsValid (dateString: string, interval = 300000) {
18 const dateToCheck = new Date(dateString)
19 const now = new Date()
20
21 return Math.abs(now.getTime() - dateToCheck.getTime()) <= interval
22}
23
24function wait (milliseconds: number) {
25 return new Promise(resolve => setTimeout(resolve, milliseconds))
26}
27
28function webtorrentAdd (torrent: string, refreshWebTorrent = false) {
29 if (refreshWebTorrent === true) webtorrent = new WebTorrent()
30
31 return new Promise<WebTorrent.Torrent>(res => webtorrent.add(torrent, res))
32}
33
34function root () {
35 // We are in server/tests/utils/miscs
36 return join(__dirname, '..', '..', '..', '..')
37}
38
39async function testImage (url: string, imageName: string, imagePath: string, extension = '.jpg') {
40 const res = await request(url)
41 .get(imagePath)
42 .expect(200)
43
44 const body = res.body
45
46 const data = await readFile(join(__dirname, '..', '..', 'fixtures', imageName + extension))
47 const minLength = body.length - ((20 * body.length) / 100)
48 const maxLength = body.length + ((20 * body.length) / 100)
49
50 expect(data.length).to.be.above(minLength)
51 expect(data.length).to.be.below(maxLength)
52}
53
54function buildAbsoluteFixturePath (path: string) {
55 if (isAbsolute(path)) {
56 return path
57 }
58
59 return join(__dirname, '..', '..', 'fixtures', path)
60}
61
62// ---------------------------------------------------------------------------
63
64export {
65 dateIsValid,
66 wait,
67 webtorrentAdd,
68 immutableAssign,
69 testImage,
70 buildAbsoluteFixturePath,
71 root
72}
diff --git a/server/tests/utils/overviews/overviews.ts b/server/tests/utils/overviews/overviews.ts
deleted file mode 100644
index 23e3ceb1e..000000000
--- a/server/tests/utils/overviews/overviews.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import { makeGetRequest } from '../requests/requests'
2
3function getVideosOverview (url: string, useCache = false) {
4 const path = '/api/v1/overviews/videos'
5
6 const query = {
7 t: useCache ? undefined : new Date().getTime()
8 }
9
10 return makeGetRequest({
11 url,
12 path,
13 query,
14 statusCodeExpected: 200
15 })
16}
17
18export { getVideosOverview }
diff --git a/server/tests/utils/requests/check-api-params.ts b/server/tests/utils/requests/check-api-params.ts
deleted file mode 100644
index edb47e0e9..000000000
--- a/server/tests/utils/requests/check-api-params.ts
+++ /dev/null
@@ -1,40 +0,0 @@
1import { makeGetRequest } from './requests'
2import { immutableAssign } from '..'
3
4function checkBadStartPagination (url: string, path: string, token?: string, query = {}) {
5 return makeGetRequest({
6 url,
7 path,
8 token,
9 query: immutableAssign(query, { start: 'hello' }),
10 statusCodeExpected: 400
11 })
12}
13
14function checkBadCountPagination (url: string, path: string, token?: string, query = {}) {
15 return makeGetRequest({
16 url,
17 path,
18 token,
19 query: immutableAssign(query, { count: 'hello' }),
20 statusCodeExpected: 400
21 })
22}
23
24function checkBadSortPagination (url: string, path: string, token?: string, query = {}) {
25 return makeGetRequest({
26 url,
27 path,
28 token,
29 query: immutableAssign(query, { sort: 'hello' }),
30 statusCodeExpected: 400
31 })
32}
33
34// ---------------------------------------------------------------------------
35
36export {
37 checkBadStartPagination,
38 checkBadCountPagination,
39 checkBadSortPagination
40}
diff --git a/server/tests/utils/requests/requests.ts b/server/tests/utils/requests/requests.ts
deleted file mode 100644
index 27a529eda..000000000
--- a/server/tests/utils/requests/requests.ts
+++ /dev/null
@@ -1,170 +0,0 @@
1import * as request from 'supertest'
2import { buildAbsoluteFixturePath } from '../miscs/miscs'
3import { isAbsolute, join } from 'path'
4
5function makeGetRequest (options: {
6 url: string,
7 path: string,
8 query?: any,
9 token?: string,
10 statusCodeExpected?: number,
11 contentType?: string
12}) {
13 if (!options.statusCodeExpected) options.statusCodeExpected = 400
14 if (options.contentType === undefined) options.contentType = 'application/json'
15
16 const req = request(options.url)
17 .get(options.path)
18
19 if (options.contentType) req.set('Accept', options.contentType)
20 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
21 if (options.query) req.query(options.query)
22
23 return req.expect(options.statusCodeExpected)
24}
25
26function makeDeleteRequest (options: {
27 url: string,
28 path: string,
29 token?: string,
30 statusCodeExpected?: number
31}) {
32 if (!options.statusCodeExpected) options.statusCodeExpected = 400
33
34 const req = request(options.url)
35 .delete(options.path)
36 .set('Accept', 'application/json')
37
38 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
39
40 return req
41 .expect('Content-Type', /json/)
42 .expect(options.statusCodeExpected)
43}
44
45function makeUploadRequest (options: {
46 url: string,
47 method?: 'POST' | 'PUT',
48 path: string,
49 token?: string,
50 fields: { [ fieldName: string ]: any },
51 attaches: { [ attachName: string ]: any | any[] },
52 statusCodeExpected?: number
53}) {
54 if (!options.statusCodeExpected) options.statusCodeExpected = 400
55
56 let req: request.Test
57 if (options.method === 'PUT') {
58 req = request(options.url).put(options.path)
59 } else {
60 req = request(options.url).post(options.path)
61 }
62
63 req.set('Accept', 'application/json')
64
65 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
66
67 Object.keys(options.fields).forEach(field => {
68 const value = options.fields[field]
69
70 if (Array.isArray(value)) {
71 for (let i = 0; i < value.length; i++) {
72 req.field(field + '[' + i + ']', value[i])
73 }
74 } else {
75 req.field(field, value)
76 }
77 })
78
79 Object.keys(options.attaches).forEach(attach => {
80 const value = options.attaches[attach]
81 if (Array.isArray(value)) {
82 req.attach(attach, buildAbsoluteFixturePath(value[0]), value[1])
83 } else {
84 req.attach(attach, buildAbsoluteFixturePath(value))
85 }
86 })
87
88 return req.expect(options.statusCodeExpected)
89}
90
91function makePostBodyRequest (options: {
92 url: string,
93 path: string,
94 token?: string,
95 fields?: { [ fieldName: string ]: any },
96 statusCodeExpected?: number
97}) {
98 if (!options.fields) options.fields = {}
99 if (!options.statusCodeExpected) options.statusCodeExpected = 400
100
101 const req = request(options.url)
102 .post(options.path)
103 .set('Accept', 'application/json')
104
105 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
106
107 return req.send(options.fields)
108 .expect(options.statusCodeExpected)
109}
110
111function makePutBodyRequest (options: {
112 url: string,
113 path: string,
114 token?: string,
115 fields: { [ fieldName: string ]: any },
116 statusCodeExpected?: number
117}) {
118 if (!options.statusCodeExpected) options.statusCodeExpected = 400
119
120 const req = request(options.url)
121 .put(options.path)
122 .set('Accept', 'application/json')
123
124 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
125
126 return req.send(options.fields)
127 .expect(options.statusCodeExpected)
128}
129
130function makeHTMLRequest (url: string, path: string) {
131 return request(url)
132 .get(path)
133 .set('Accept', 'text/html')
134 .expect(200)
135}
136
137function updateAvatarRequest (options: {
138 url: string,
139 path: string,
140 accessToken: string,
141 fixture: string
142}) {
143 let filePath = ''
144 if (isAbsolute(options.fixture)) {
145 filePath = options.fixture
146 } else {
147 filePath = join(__dirname, '..', '..', 'fixtures', options.fixture)
148 }
149
150 return makeUploadRequest({
151 url: options.url,
152 path: options.path,
153 token: options.accessToken,
154 fields: {},
155 attaches: { avatarfile: filePath },
156 statusCodeExpected: 200
157 })
158}
159
160// ---------------------------------------------------------------------------
161
162export {
163 makeHTMLRequest,
164 makeGetRequest,
165 makeUploadRequest,
166 makePostBodyRequest,
167 makePutBodyRequest,
168 makeDeleteRequest,
169 updateAvatarRequest
170}
diff --git a/server/tests/utils/search/video-channels.ts b/server/tests/utils/search/video-channels.ts
deleted file mode 100644
index 0532134ae..000000000
--- a/server/tests/utils/search/video-channels.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { makeGetRequest } from '../requests/requests'
2
3function searchVideoChannel (url: string, search: string, token?: string, statusCodeExpected = 200) {
4 const path = '/api/v1/search/video-channels'
5
6 return makeGetRequest({
7 url,
8 path,
9 query: {
10 sort: '-createdAt',
11 search
12 },
13 token,
14 statusCodeExpected
15 })
16}
17
18// ---------------------------------------------------------------------------
19
20export {
21 searchVideoChannel
22}
diff --git a/server/tests/utils/search/videos.ts b/server/tests/utils/search/videos.ts
deleted file mode 100644
index 3a0c10e42..000000000
--- a/server/tests/utils/search/videos.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import * as request from 'supertest'
4import { VideosSearchQuery } from '../../../../shared/models/search'
5import { immutableAssign } from '..'
6
7function searchVideo (url: string, search: string) {
8 const path = '/api/v1/search/videos'
9 const req = request(url)
10 .get(path)
11 .query({ sort: '-publishedAt', search })
12 .set('Accept', 'application/json')
13
14 return req.expect(200)
15 .expect('Content-Type', /json/)
16}
17
18function searchVideoWithToken (url: string, search: string, token: string, query: { nsfw?: boolean } = {}) {
19 const path = '/api/v1/search/videos'
20 const req = request(url)
21 .get(path)
22 .set('Authorization', 'Bearer ' + token)
23 .query(immutableAssign(query, { sort: '-publishedAt', search }))
24 .set('Accept', 'application/json')
25
26 return req.expect(200)
27 .expect('Content-Type', /json/)
28}
29
30function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) {
31 const path = '/api/v1/search/videos'
32
33 const req = request(url)
34 .get(path)
35 .query({ start })
36 .query({ search })
37 .query({ count })
38
39 if (sort) req.query({ sort })
40
41 return req.set('Accept', 'application/json')
42 .expect(200)
43 .expect('Content-Type', /json/)
44}
45
46function searchVideoWithSort (url: string, search: string, sort: string) {
47 const path = '/api/v1/search/videos'
48
49 return request(url)
50 .get(path)
51 .query({ search })
52 .query({ sort })
53 .set('Accept', 'application/json')
54 .expect(200)
55 .expect('Content-Type', /json/)
56}
57
58function advancedVideosSearch (url: string, options: VideosSearchQuery) {
59 const path = '/api/v1/search/videos'
60
61 return request(url)
62 .get(path)
63 .query(options)
64 .set('Accept', 'application/json')
65 .expect(200)
66 .expect('Content-Type', /json/)
67}
68
69// ---------------------------------------------------------------------------
70
71export {
72 searchVideo,
73 advancedVideosSearch,
74 searchVideoWithToken,
75 searchVideoWithPagination,
76 searchVideoWithSort
77}
diff --git a/server/tests/utils/server/activitypub.ts b/server/tests/utils/server/activitypub.ts
deleted file mode 100644
index cf3c1c3b3..000000000
--- a/server/tests/utils/server/activitypub.ts
+++ /dev/null
@@ -1,15 +0,0 @@
1import * as request from 'supertest'
2
3function makeActivityPubGetRequest (url: string, path: string) {
4 return request(url)
5 .get(path)
6 .set('Accept', 'application/activity+json,text/html;q=0.9,\\*/\\*;q=0.8')
7 .expect(200)
8 .expect('Content-Type', /json/)
9}
10
11// ---------------------------------------------------------------------------
12
13export {
14 makeActivityPubGetRequest
15}
diff --git a/server/tests/utils/server/clients.ts b/server/tests/utils/server/clients.ts
deleted file mode 100644
index 273aac747..000000000
--- a/server/tests/utils/server/clients.ts
+++ /dev/null
@@ -1,19 +0,0 @@
1import * as request from 'supertest'
2import * as urlUtil from 'url'
3
4function getClient (url: string) {
5 const path = '/api/v1/oauth-clients/local'
6
7 return request(url)
8 .get(path)
9 .set('Host', urlUtil.parse(url).host)
10 .set('Accept', 'application/json')
11 .expect(200)
12 .expect('Content-Type', /json/)
13}
14
15// ---------------------------------------------------------------------------
16
17export {
18 getClient
19}
diff --git a/server/tests/utils/server/config.ts b/server/tests/utils/server/config.ts
deleted file mode 100644
index b85e02ab7..000000000
--- a/server/tests/utils/server/config.ts
+++ /dev/null
@@ -1,135 +0,0 @@
1import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../'
2import { CustomConfig } from '../../../../shared/models/server/custom-config.model'
3
4function getConfig (url: string) {
5 const path = '/api/v1/config'
6
7 return makeGetRequest({
8 url,
9 path,
10 statusCodeExpected: 200
11 })
12}
13
14function getAbout (url: string) {
15 const path = '/api/v1/config/about'
16
17 return makeGetRequest({
18 url,
19 path,
20 statusCodeExpected: 200
21 })
22}
23
24function getCustomConfig (url: string, token: string, statusCodeExpected = 200) {
25 const path = '/api/v1/config/custom'
26
27 return makeGetRequest({
28 url,
29 token,
30 path,
31 statusCodeExpected
32 })
33}
34
35function updateCustomConfig (url: string, token: string, newCustomConfig: CustomConfig, statusCodeExpected = 200) {
36 const path = '/api/v1/config/custom'
37
38 return makePutBodyRequest({
39 url,
40 token,
41 path,
42 fields: newCustomConfig,
43 statusCodeExpected
44 })
45}
46
47function updateCustomSubConfig (url: string, token: string, newConfig: any) {
48 const updateParams: CustomConfig = {
49 instance: {
50 name: 'PeerTube updated',
51 shortDescription: 'my short description',
52 description: 'my super description',
53 terms: 'my super terms',
54 defaultClientRoute: '/videos/recently-added',
55 defaultNSFWPolicy: 'blur',
56 customizations: {
57 javascript: 'alert("coucou")',
58 css: 'body { background-color: red; }'
59 }
60 },
61 services: {
62 twitter: {
63 username: '@MySuperUsername',
64 whitelisted: true
65 }
66 },
67 cache: {
68 previews: {
69 size: 2
70 },
71 captions: {
72 size: 3
73 }
74 },
75 signup: {
76 enabled: false,
77 limit: 5,
78 requiresEmailVerification: false
79 },
80 admin: {
81 email: 'superadmin1@example.com'
82 },
83 user: {
84 videoQuota: 5242881,
85 videoQuotaDaily: 318742
86 },
87 transcoding: {
88 enabled: true,
89 threads: 1,
90 resolutions: {
91 '240p': false,
92 '360p': true,
93 '480p': true,
94 '720p': false,
95 '1080p': false
96 }
97 },
98 import: {
99 videos: {
100 http: {
101 enabled: false
102 },
103 torrent: {
104 enabled: false
105 }
106 }
107 }
108 }
109
110 Object.assign(updateParams, newConfig)
111
112 return updateCustomConfig(url, token, updateParams)
113}
114
115function deleteCustomConfig (url: string, token: string, statusCodeExpected = 200) {
116 const path = '/api/v1/config/custom'
117
118 return makeDeleteRequest({
119 url,
120 token,
121 path,
122 statusCodeExpected
123 })
124}
125
126// ---------------------------------------------------------------------------
127
128export {
129 getConfig,
130 getCustomConfig,
131 updateCustomConfig,
132 getAbout,
133 deleteCustomConfig,
134 updateCustomSubConfig
135}
diff --git a/server/tests/utils/server/follows.ts b/server/tests/utils/server/follows.ts
deleted file mode 100644
index 8a65a958b..000000000
--- a/server/tests/utils/server/follows.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1import * as request from 'supertest'
2import { ServerInfo } from './servers'
3import { waitJobs } from './jobs'
4
5function getFollowersListPaginationAndSort (url: string, start: number, count: number, sort: string) {
6 const path = '/api/v1/server/followers'
7
8 return request(url)
9 .get(path)
10 .query({ start })
11 .query({ count })
12 .query({ sort })
13 .set('Accept', 'application/json')
14 .expect(200)
15 .expect('Content-Type', /json/)
16}
17
18function getFollowingListPaginationAndSort (url: string, start: number, count: number, sort: string) {
19 const path = '/api/v1/server/following'
20
21 return request(url)
22 .get(path)
23 .query({ start })
24 .query({ count })
25 .query({ sort })
26 .set('Accept', 'application/json')
27 .expect(200)
28 .expect('Content-Type', /json/)
29}
30
31async function follow (follower: string, following: string[], accessToken: string, expectedStatus = 204) {
32 const path = '/api/v1/server/following'
33
34 const followingHosts = following.map(f => f.replace(/^http:\/\//, ''))
35 const res = await request(follower)
36 .post(path)
37 .set('Accept', 'application/json')
38 .set('Authorization', 'Bearer ' + accessToken)
39 .send({ 'hosts': followingHosts })
40 .expect(expectedStatus)
41
42 return res
43}
44
45async function unfollow (url: string, accessToken: string, target: ServerInfo, expectedStatus = 204) {
46 const path = '/api/v1/server/following/' + target.host
47
48 const res = await request(url)
49 .delete(path)
50 .set('Accept', 'application/json')
51 .set('Authorization', 'Bearer ' + accessToken)
52 .expect(expectedStatus)
53
54 return res
55}
56
57async function doubleFollow (server1: ServerInfo, server2: ServerInfo) {
58 await Promise.all([
59 follow(server1.url, [ server2.url ], server1.accessToken),
60 follow(server2.url, [ server1.url ], server2.accessToken)
61 ])
62
63 // Wait request propagation
64 await waitJobs([ server1, server2 ])
65
66 return true
67}
68
69// ---------------------------------------------------------------------------
70
71export {
72 getFollowersListPaginationAndSort,
73 getFollowingListPaginationAndSort,
74 unfollow,
75 follow,
76 doubleFollow
77}
diff --git a/server/tests/utils/server/jobs.ts b/server/tests/utils/server/jobs.ts
deleted file mode 100644
index 4c02cace5..000000000
--- a/server/tests/utils/server/jobs.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1import * as request from 'supertest'
2import { Job, JobState } from '../../../../shared/models'
3import { ServerInfo, wait } from '../index'
4
5function getJobsList (url: string, accessToken: string, state: JobState) {
6 const path = '/api/v1/jobs/' + state
7
8 return request(url)
9 .get(path)
10 .set('Accept', 'application/json')
11 .set('Authorization', 'Bearer ' + accessToken)
12 .expect(200)
13 .expect('Content-Type', /json/)
14}
15
16function getJobsListPaginationAndSort (url: string, accessToken: string, state: JobState, start: number, count: number, sort: string) {
17 const path = '/api/v1/jobs/' + state
18
19 return request(url)
20 .get(path)
21 .query({ start })
22 .query({ count })
23 .query({ sort })
24 .set('Accept', 'application/json')
25 .set('Authorization', 'Bearer ' + accessToken)
26 .expect(200)
27 .expect('Content-Type', /json/)
28}
29
30async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
31 let servers: ServerInfo[]
32
33 if (Array.isArray(serversArg) === false) servers = [ serversArg as ServerInfo ]
34 else servers = serversArg as ServerInfo[]
35
36 const states: JobState[] = [ 'waiting', 'active', 'delayed' ]
37 const tasks: Promise<any>[] = []
38 let pendingRequests: boolean
39
40 do {
41 pendingRequests = false
42
43 // Check if each server has pending request
44 for (const server of servers) {
45 for (const state of states) {
46 const p = getJobsListPaginationAndSort(server.url, server.accessToken, state, 0, 10, '-createdAt')
47 .then(res => res.body.data)
48 .then((jobs: Job[]) => jobs.filter(j => j.type !== 'videos-views'))
49 .then(jobs => {
50 if (jobs.length !== 0) pendingRequests = true
51 })
52 tasks.push(p)
53 }
54 }
55
56 await Promise.all(tasks)
57
58 // Retry, in case of new jobs were created
59 if (pendingRequests === false) {
60 await wait(1000)
61
62 await Promise.all(tasks)
63 }
64
65 if (pendingRequests) {
66 await wait(1000)
67 }
68 } while (pendingRequests)
69}
70
71// ---------------------------------------------------------------------------
72
73export {
74 getJobsList,
75 waitJobs,
76 getJobsListPaginationAndSort
77}
diff --git a/server/tests/utils/server/redundancy.ts b/server/tests/utils/server/redundancy.ts
deleted file mode 100644
index c39ff2c8b..000000000
--- a/server/tests/utils/server/redundancy.ts
+++ /dev/null
@@ -1,17 +0,0 @@
1import { makePutBodyRequest } from '../requests/requests'
2
3async function updateRedundancy (url: string, accessToken: string, host: string, redundancyAllowed: boolean, expectedStatus = 204) {
4 const path = '/api/v1/server/redundancy/' + host
5
6 return makePutBodyRequest({
7 url,
8 path,
9 token: accessToken,
10 fields: { redundancyAllowed },
11 statusCodeExpected: expectedStatus
12 })
13}
14
15export {
16 updateRedundancy
17}
diff --git a/server/tests/utils/server/servers.ts b/server/tests/utils/server/servers.ts
deleted file mode 100644
index 3c946db27..000000000
--- a/server/tests/utils/server/servers.ts
+++ /dev/null
@@ -1,185 +0,0 @@
1import { ChildProcess, exec, fork } from 'child_process'
2import { join } from 'path'
3import { root, wait } from '../miscs/miscs'
4import { readFile } from 'fs-extra'
5
6interface ServerInfo {
7 app: ChildProcess,
8 url: string
9 host: string
10 serverNumber: number
11
12 client: {
13 id: string,
14 secret: string
15 }
16
17 user: {
18 username: string,
19 password: string,
20 email?: string
21 }
22
23 accessToken?: string
24
25 video?: {
26 id: number
27 uuid: string
28 name: string
29 account: {
30 name: string
31 }
32 }
33
34 remoteVideo?: {
35 id: number
36 uuid: string
37 }
38}
39
40function flushAndRunMultipleServers (totalServers: number, configOverride?: Object) {
41 let apps = []
42 let i = 0
43
44 return new Promise<ServerInfo[]>(res => {
45 function anotherServerDone (serverNumber, app) {
46 apps[serverNumber - 1] = app
47 i++
48 if (i === totalServers) {
49 return res(apps)
50 }
51 }
52
53 flushTests()
54 .then(() => {
55 for (let j = 1; j <= totalServers; j++) {
56 runServer(j, configOverride).then(app => anotherServerDone(j, app))
57 }
58 })
59 })
60}
61
62function flushTests () {
63 return new Promise<void>((res, rej) => {
64 return exec('npm run clean:server:test', err => {
65 if (err) return rej(err)
66
67 return res()
68 })
69 })
70}
71
72function runServer (serverNumber: number, configOverride?: Object) {
73 const server: ServerInfo = {
74 app: null,
75 serverNumber: serverNumber,
76 url: `http://localhost:${9000 + serverNumber}`,
77 host: `localhost:${9000 + serverNumber}`,
78 client: {
79 id: null,
80 secret: null
81 },
82 user: {
83 username: null,
84 password: null
85 }
86 }
87
88 // These actions are async so we need to be sure that they have both been done
89 const serverRunString = {
90 'Server listening': false
91 }
92 const key = 'Database peertube_test' + serverNumber + ' is ready'
93 serverRunString[key] = false
94
95 const regexps = {
96 client_id: 'Client id: (.+)',
97 client_secret: 'Client secret: (.+)',
98 user_username: 'Username: (.+)',
99 user_password: 'User password: (.+)'
100 }
101
102 // Share the environment
103 const env = Object.create(process.env)
104 env['NODE_ENV'] = 'test'
105 env['NODE_APP_INSTANCE'] = serverNumber.toString()
106
107 if (configOverride !== undefined) {
108 env['NODE_CONFIG'] = JSON.stringify(configOverride)
109 }
110
111 const options = {
112 silent: true,
113 env: env,
114 detached: true
115 }
116
117 return new Promise<ServerInfo>(res => {
118 server.app = fork(join(__dirname, '..', '..', '..', '..', 'dist', 'server.js'), [], options)
119 server.app.stdout.on('data', function onStdout (data) {
120 let dontContinue = false
121
122 // Capture things if we want to
123 for (const key of Object.keys(regexps)) {
124 const regexp = regexps[key]
125 const matches = data.toString().match(regexp)
126 if (matches !== null) {
127 if (key === 'client_id') server.client.id = matches[1]
128 else if (key === 'client_secret') server.client.secret = matches[1]
129 else if (key === 'user_username') server.user.username = matches[1]
130 else if (key === 'user_password') server.user.password = matches[1]
131 }
132 }
133
134 // Check if all required sentences are here
135 for (const key of Object.keys(serverRunString)) {
136 if (data.toString().indexOf(key) !== -1) serverRunString[key] = true
137 if (serverRunString[key] === false) dontContinue = true
138 }
139
140 // If no, there is maybe one thing not already initialized (client/user credentials generation...)
141 if (dontContinue === true) return
142
143 server.app.stdout.removeListener('data', onStdout)
144 res(server)
145 })
146 })
147}
148
149async function reRunServer (server: ServerInfo, configOverride?: any) {
150 const newServer = await runServer(server.serverNumber, configOverride)
151 server.app = newServer.app
152
153 return server
154}
155
156function killallServers (servers: ServerInfo[]) {
157 for (const server of servers) {
158 process.kill(-server.app.pid)
159 }
160}
161
162async function waitUntilLog (server: ServerInfo, str: string, count = 1) {
163 const logfile = join(root(), 'test' + server.serverNumber, 'logs/peertube.log')
164
165 while (true) {
166 const buf = await readFile(logfile)
167
168 const matches = buf.toString().match(new RegExp(str, 'g'))
169 if (matches && matches.length === count) return
170
171 await wait(1000)
172 }
173}
174
175// ---------------------------------------------------------------------------
176
177export {
178 ServerInfo,
179 flushAndRunMultipleServers,
180 flushTests,
181 runServer,
182 killallServers,
183 reRunServer,
184 waitUntilLog
185}
diff --git a/server/tests/utils/server/stats.ts b/server/tests/utils/server/stats.ts
deleted file mode 100644
index 01989d952..000000000
--- a/server/tests/utils/server/stats.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { makeGetRequest } from '../'
2
3function getStats (url: string, useCache = false) {
4 const path = '/api/v1/server/stats'
5
6 const query = {
7 t: useCache ? undefined : new Date().getTime()
8 }
9
10 return makeGetRequest({
11 url,
12 path,
13 query,
14 statusCodeExpected: 200
15 })
16}
17
18// ---------------------------------------------------------------------------
19
20export {
21 getStats
22}
diff --git a/server/tests/utils/users/accounts.ts b/server/tests/utils/users/accounts.ts
deleted file mode 100644
index f82b8d906..000000000
--- a/server/tests/utils/users/accounts.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import { expect } from 'chai'
4import { existsSync, readdir } from 'fs-extra'
5import { join } from 'path'
6import { Account } from '../../../../shared/models/actors'
7import { root } from '../index'
8import { makeGetRequest } from '../requests/requests'
9
10function getAccountsList (url: string, sort = '-createdAt', statusCodeExpected = 200) {
11 const path = '/api/v1/accounts'
12
13 return makeGetRequest({
14 url,
15 query: { sort },
16 path,
17 statusCodeExpected
18 })
19}
20
21function getAccount (url: string, accountName: string, statusCodeExpected = 200) {
22 const path = '/api/v1/accounts/' + accountName
23
24 return makeGetRequest({
25 url,
26 path,
27 statusCodeExpected
28 })
29}
30
31async function expectAccountFollows (url: string, nameWithDomain: string, followersCount: number, followingCount: number) {
32 const res = await getAccountsList(url)
33 const account = res.body.data.find((a: Account) => a.name + '@' + a.host === nameWithDomain)
34
35 const message = `${nameWithDomain} on ${url}`
36 expect(account.followersCount).to.equal(followersCount, message)
37 expect(account.followingCount).to.equal(followingCount, message)
38}
39
40async function checkActorFilesWereRemoved (actorUUID: string, serverNumber: number) {
41 const testDirectory = 'test' + serverNumber
42
43 for (const directory of [ 'avatars' ]) {
44 const directoryPath = join(root(), testDirectory, directory)
45
46 const directoryExists = existsSync(directoryPath)
47 expect(directoryExists).to.be.true
48
49 const files = await readdir(directoryPath)
50 for (const file of files) {
51 expect(file).to.not.contain(actorUUID)
52 }
53 }
54}
55
56// ---------------------------------------------------------------------------
57
58export {
59 getAccount,
60 expectAccountFollows,
61 getAccountsList,
62 checkActorFilesWereRemoved
63}
diff --git a/server/tests/utils/users/login.ts b/server/tests/utils/users/login.ts
deleted file mode 100644
index ddeb9df2a..000000000
--- a/server/tests/utils/users/login.ts
+++ /dev/null
@@ -1,62 +0,0 @@
1import * as request from 'supertest'
2
3import { ServerInfo } from '../server/servers'
4
5type Client = { id: string, secret: string }
6type User = { username: string, password: string }
7type Server = { url: string, client: Client, user: User }
8
9function login (url: string, client: Client, user: User, expectedStatus = 200) {
10 const path = '/api/v1/users/token'
11
12 const body = {
13 client_id: client.id,
14 client_secret: client.secret,
15 username: user.username,
16 password: user.password,
17 response_type: 'code',
18 grant_type: 'password',
19 scope: 'upload'
20 }
21
22 return request(url)
23 .post(path)
24 .type('form')
25 .send(body)
26 .expect(expectedStatus)
27}
28
29async function serverLogin (server: Server) {
30 const res = await login(server.url, server.client, server.user, 200)
31
32 return res.body.access_token as string
33}
34
35async function userLogin (server: Server, user: User, expectedStatus = 200) {
36 const res = await login(server.url, server.client, user, expectedStatus)
37
38 return res.body.access_token as string
39}
40
41function setAccessTokensToServers (servers: ServerInfo[]) {
42 const tasks: Promise<any>[] = []
43
44 for (const server of servers) {
45 const p = serverLogin(server).then(t => server.accessToken = t)
46 tasks.push(p)
47 }
48
49 return Promise.all(tasks)
50}
51
52// ---------------------------------------------------------------------------
53
54export {
55 login,
56 serverLogin,
57 userLogin,
58 setAccessTokensToServers,
59 Server,
60 Client,
61 User
62}
diff --git a/server/tests/utils/users/user-subscriptions.ts b/server/tests/utils/users/user-subscriptions.ts
deleted file mode 100644
index b0e7da7cc..000000000
--- a/server/tests/utils/users/user-subscriptions.ts
+++ /dev/null
@@ -1,82 +0,0 @@
1import { makeDeleteRequest, makeGetRequest, makePostBodyRequest } from '../'
2
3function addUserSubscription (url: string, token: string, targetUri: string, statusCodeExpected = 204) {
4 const path = '/api/v1/users/me/subscriptions'
5
6 return makePostBodyRequest({
7 url,
8 path,
9 token,
10 statusCodeExpected,
11 fields: { uri: targetUri }
12 })
13}
14
15function listUserSubscriptions (url: string, token: string, sort = '-createdAt', statusCodeExpected = 200) {
16 const path = '/api/v1/users/me/subscriptions'
17
18 return makeGetRequest({
19 url,
20 path,
21 token,
22 statusCodeExpected,
23 query: { sort }
24 })
25}
26
27function listUserSubscriptionVideos (url: string, token: string, sort = '-createdAt', statusCodeExpected = 200) {
28 const path = '/api/v1/users/me/subscriptions/videos'
29
30 return makeGetRequest({
31 url,
32 path,
33 token,
34 statusCodeExpected,
35 query: { sort }
36 })
37}
38
39function getUserSubscription (url: string, token: string, uri: string, statusCodeExpected = 200) {
40 const path = '/api/v1/users/me/subscriptions/' + uri
41
42 return makeGetRequest({
43 url,
44 path,
45 token,
46 statusCodeExpected
47 })
48}
49
50function removeUserSubscription (url: string, token: string, uri: string, statusCodeExpected = 204) {
51 const path = '/api/v1/users/me/subscriptions/' + uri
52
53 return makeDeleteRequest({
54 url,
55 path,
56 token,
57 statusCodeExpected
58 })
59}
60
61function areSubscriptionsExist (url: string, token: string, uris: string[], statusCodeExpected = 200) {
62 const path = '/api/v1/users/me/subscriptions/exist'
63
64 return makeGetRequest({
65 url,
66 path,
67 query: { 'uris[]': uris },
68 token,
69 statusCodeExpected
70 })
71}
72
73// ---------------------------------------------------------------------------
74
75export {
76 areSubscriptionsExist,
77 addUserSubscription,
78 listUserSubscriptions,
79 getUserSubscription,
80 listUserSubscriptionVideos,
81 removeUserSubscription
82}
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts
deleted file mode 100644
index 41d8ce265..000000000
--- a/server/tests/utils/users/users.ts
+++ /dev/null
@@ -1,295 +0,0 @@
1import * as request from 'supertest'
2import { makePostBodyRequest, makePutBodyRequest, updateAvatarRequest } from '../'
3
4import { UserRole } from '../../../../shared/index'
5import { NSFWPolicyType } from '../../../../shared/models/videos/nsfw-policy.type'
6
7function createUser (
8 url: string,
9 accessToken: string,
10 username: string,
11 password: string,
12 videoQuota = 1000000,
13 videoQuotaDaily = -1,
14 role: UserRole = UserRole.USER,
15 specialStatus = 200
16) {
17 const path = '/api/v1/users'
18 const body = {
19 username,
20 password,
21 role,
22 email: username + '@example.com',
23 videoQuota,
24 videoQuotaDaily
25 }
26
27 return request(url)
28 .post(path)
29 .set('Accept', 'application/json')
30 .set('Authorization', 'Bearer ' + accessToken)
31 .send(body)
32 .expect(specialStatus)
33}
34
35function registerUser (url: string, username: string, password: string, specialStatus = 204) {
36 const path = '/api/v1/users/register'
37 const body = {
38 username,
39 password,
40 email: username + '@example.com'
41 }
42
43 return request(url)
44 .post(path)
45 .set('Accept', 'application/json')
46 .send(body)
47 .expect(specialStatus)
48}
49
50function getMyUserInformation (url: string, accessToken: string, specialStatus = 200) {
51 const path = '/api/v1/users/me'
52
53 return request(url)
54 .get(path)
55 .set('Accept', 'application/json')
56 .set('Authorization', 'Bearer ' + accessToken)
57 .expect(specialStatus)
58 .expect('Content-Type', /json/)
59}
60
61function deleteMe (url: string, accessToken: string, specialStatus = 204) {
62 const path = '/api/v1/users/me'
63
64 return request(url)
65 .delete(path)
66 .set('Accept', 'application/json')
67 .set('Authorization', 'Bearer ' + accessToken)
68 .expect(specialStatus)
69}
70
71function getMyUserVideoQuotaUsed (url: string, accessToken: string, specialStatus = 200) {
72 const path = '/api/v1/users/me/video-quota-used'
73
74 return request(url)
75 .get(path)
76 .set('Accept', 'application/json')
77 .set('Authorization', 'Bearer ' + accessToken)
78 .expect(specialStatus)
79 .expect('Content-Type', /json/)
80}
81
82function getUserInformation (url: string, accessToken: string, userId: number) {
83 const path = '/api/v1/users/' + userId
84
85 return request(url)
86 .get(path)
87 .set('Accept', 'application/json')
88 .set('Authorization', 'Bearer ' + accessToken)
89 .expect(200)
90 .expect('Content-Type', /json/)
91}
92
93function getMyUserVideoRating (url: string, accessToken: string, videoId: number | string, specialStatus = 200) {
94 const path = '/api/v1/users/me/videos/' + videoId + '/rating'
95
96 return request(url)
97 .get(path)
98 .set('Accept', 'application/json')
99 .set('Authorization', 'Bearer ' + accessToken)
100 .expect(specialStatus)
101 .expect('Content-Type', /json/)
102}
103
104function getUsersList (url: string, accessToken: string) {
105 const path = '/api/v1/users'
106
107 return request(url)
108 .get(path)
109 .set('Accept', 'application/json')
110 .set('Authorization', 'Bearer ' + accessToken)
111 .expect(200)
112 .expect('Content-Type', /json/)
113}
114
115function getUsersListPaginationAndSort (url: string, accessToken: string, start: number, count: number, sort: string) {
116 const path = '/api/v1/users'
117
118 return request(url)
119 .get(path)
120 .query({ start })
121 .query({ count })
122 .query({ sort })
123 .set('Accept', 'application/json')
124 .set('Authorization', 'Bearer ' + accessToken)
125 .expect(200)
126 .expect('Content-Type', /json/)
127}
128
129function removeUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204) {
130 const path = '/api/v1/users'
131
132 return request(url)
133 .delete(path + '/' + userId)
134 .set('Accept', 'application/json')
135 .set('Authorization', 'Bearer ' + accessToken)
136 .expect(expectedStatus)
137}
138
139function blockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204, reason?: string) {
140 const path = '/api/v1/users'
141 let body: any
142 if (reason) body = { reason }
143
144 return request(url)
145 .post(path + '/' + userId + '/block')
146 .send(body)
147 .set('Accept', 'application/json')
148 .set('Authorization', 'Bearer ' + accessToken)
149 .expect(expectedStatus)
150}
151
152function unblockUser (url: string, userId: number | string, accessToken: string, expectedStatus = 204) {
153 const path = '/api/v1/users'
154
155 return request(url)
156 .post(path + '/' + userId + '/unblock')
157 .set('Accept', 'application/json')
158 .set('Authorization', 'Bearer ' + accessToken)
159 .expect(expectedStatus)
160}
161
162function updateMyUser (options: {
163 url: string
164 accessToken: string,
165 currentPassword?: string,
166 newPassword?: string,
167 nsfwPolicy?: NSFWPolicyType,
168 email?: string,
169 autoPlayVideo?: boolean
170 displayName?: string,
171 description?: string
172}) {
173 const path = '/api/v1/users/me'
174
175 const toSend = {}
176 if (options.currentPassword !== undefined && options.currentPassword !== null) toSend['currentPassword'] = options.currentPassword
177 if (options.newPassword !== undefined && options.newPassword !== null) toSend['password'] = options.newPassword
178 if (options.nsfwPolicy !== undefined && options.nsfwPolicy !== null) toSend['nsfwPolicy'] = options.nsfwPolicy
179 if (options.autoPlayVideo !== undefined && options.autoPlayVideo !== null) toSend['autoPlayVideo'] = options.autoPlayVideo
180 if (options.email !== undefined && options.email !== null) toSend['email'] = options.email
181 if (options.description !== undefined && options.description !== null) toSend['description'] = options.description
182 if (options.displayName !== undefined && options.displayName !== null) toSend['displayName'] = options.displayName
183
184 return makePutBodyRequest({
185 url: options.url,
186 path,
187 token: options.accessToken,
188 fields: toSend,
189 statusCodeExpected: 204
190 })
191}
192
193function updateMyAvatar (options: {
194 url: string,
195 accessToken: string,
196 fixture: string
197}) {
198 const path = '/api/v1/users/me/avatar/pick'
199
200 return updateAvatarRequest(Object.assign(options, { path }))
201}
202
203function updateUser (options: {
204 url: string
205 userId: number,
206 accessToken: string,
207 email?: string,
208 videoQuota?: number,
209 videoQuotaDaily?: number,
210 role?: UserRole
211}) {
212 const path = '/api/v1/users/' + options.userId
213
214 const toSend = {}
215 if (options.email !== undefined && options.email !== null) toSend['email'] = options.email
216 if (options.videoQuota !== undefined && options.videoQuota !== null) toSend['videoQuota'] = options.videoQuota
217 if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend['videoQuotaDaily'] = options.videoQuotaDaily
218 if (options.role !== undefined && options.role !== null) toSend['role'] = options.role
219
220 return makePutBodyRequest({
221 url: options.url,
222 path,
223 token: options.accessToken,
224 fields: toSend,
225 statusCodeExpected: 204
226 })
227}
228
229function askResetPassword (url: string, email: string) {
230 const path = '/api/v1/users/ask-reset-password'
231
232 return makePostBodyRequest({
233 url,
234 path,
235 fields: { email },
236 statusCodeExpected: 204
237 })
238}
239
240function resetPassword (url: string, userId: number, verificationString: string, password: string, statusCodeExpected = 204) {
241 const path = '/api/v1/users/' + userId + '/reset-password'
242
243 return makePostBodyRequest({
244 url,
245 path,
246 fields: { password, verificationString },
247 statusCodeExpected
248 })
249}
250
251function askSendVerifyEmail (url: string, email: string) {
252 const path = '/api/v1/users/ask-send-verify-email'
253
254 return makePostBodyRequest({
255 url,
256 path,
257 fields: { email },
258 statusCodeExpected: 204
259 })
260}
261
262function verifyEmail (url: string, userId: number, verificationString: string, statusCodeExpected = 204) {
263 const path = '/api/v1/users/' + userId + '/verify-email'
264
265 return makePostBodyRequest({
266 url,
267 path,
268 fields: { verificationString },
269 statusCodeExpected
270 })
271}
272
273// ---------------------------------------------------------------------------
274
275export {
276 createUser,
277 registerUser,
278 getMyUserInformation,
279 getMyUserVideoRating,
280 deleteMe,
281 getMyUserVideoQuotaUsed,
282 getUsersList,
283 getUsersListPaginationAndSort,
284 removeUser,
285 updateUser,
286 updateMyUser,
287 getUserInformation,
288 blockUser,
289 unblockUser,
290 askResetPassword,
291 resetPassword,
292 updateMyAvatar,
293 askSendVerifyEmail,
294 verifyEmail
295}
diff --git a/server/tests/utils/videos/services.ts b/server/tests/utils/videos/services.ts
deleted file mode 100644
index 1a53dd4cf..000000000
--- a/server/tests/utils/videos/services.ts
+++ /dev/null
@@ -1,23 +0,0 @@
1import * as request from 'supertest'
2
3function getOEmbed (url: string, oembedUrl: string, format?: string, maxHeight?: number, maxWidth?: number) {
4 const path = '/services/oembed'
5 const query = {
6 url: oembedUrl,
7 format,
8 maxheight: maxHeight,
9 maxwidth: maxWidth
10 }
11
12 return request(url)
13 .get(path)
14 .query(query)
15 .set('Accept', 'application/json')
16 .expect(200)
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 getOEmbed
23}
diff --git a/server/tests/utils/videos/video-abuses.ts b/server/tests/utils/videos/video-abuses.ts
deleted file mode 100644
index 14907e6a0..000000000
--- a/server/tests/utils/videos/video-abuses.ts
+++ /dev/null
@@ -1,65 +0,0 @@
1import * as request from 'supertest'
2import { VideoAbuseUpdate } from '../../../../shared/models/videos/abuse/video-abuse-update.model'
3import { makeDeleteRequest, makePutBodyRequest } from '..'
4
5function reportVideoAbuse (url: string, token: string, videoId: number | string, reason: string, specialStatus = 200) {
6 const path = '/api/v1/videos/' + videoId + '/abuse'
7
8 return request(url)
9 .post(path)
10 .set('Accept', 'application/json')
11 .set('Authorization', 'Bearer ' + token)
12 .send({ reason })
13 .expect(specialStatus)
14}
15
16function getVideoAbusesList (url: string, token: string) {
17 const path = '/api/v1/videos/abuse'
18
19 return request(url)
20 .get(path)
21 .query({ sort: 'createdAt' })
22 .set('Accept', 'application/json')
23 .set('Authorization', 'Bearer ' + token)
24 .expect(200)
25 .expect('Content-Type', /json/)
26}
27
28function updateVideoAbuse (
29 url: string,
30 token: string,
31 videoId: string | number,
32 videoAbuseId: number,
33 body: VideoAbuseUpdate,
34 statusCodeExpected = 204
35) {
36 const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
37
38 return makePutBodyRequest({
39 url,
40 token,
41 path,
42 fields: body,
43 statusCodeExpected
44 })
45}
46
47function deleteVideoAbuse (url: string, token: string, videoId: string | number, videoAbuseId: number, statusCodeExpected = 204) {
48 const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
49
50 return makeDeleteRequest({
51 url,
52 token,
53 path,
54 statusCodeExpected
55 })
56}
57
58// ---------------------------------------------------------------------------
59
60export {
61 reportVideoAbuse,
62 getVideoAbusesList,
63 updateVideoAbuse,
64 deleteVideoAbuse
65}
diff --git a/server/tests/utils/videos/video-blacklist.ts b/server/tests/utils/videos/video-blacklist.ts
deleted file mode 100644
index 2c176fde0..000000000
--- a/server/tests/utils/videos/video-blacklist.ts
+++ /dev/null
@@ -1,67 +0,0 @@
1import * as request from 'supertest'
2
3function addVideoToBlacklist (url: string, token: string, videoId: number | string, reason?: string, specialStatus = 204) {
4 const path = '/api/v1/videos/' + videoId + '/blacklist'
5
6 return request(url)
7 .post(path)
8 .send({ reason })
9 .set('Accept', 'application/json')
10 .set('Authorization', 'Bearer ' + token)
11 .expect(specialStatus)
12}
13
14function updateVideoBlacklist (url: string, token: string, videoId: number, reason?: string, specialStatus = 204) {
15 const path = '/api/v1/videos/' + videoId + '/blacklist'
16
17 return request(url)
18 .put(path)
19 .send({ reason })
20 .set('Accept', 'application/json')
21 .set('Authorization', 'Bearer ' + token)
22 .expect(specialStatus)
23}
24
25function removeVideoFromBlacklist (url: string, token: string, videoId: number | string, specialStatus = 204) {
26 const path = '/api/v1/videos/' + videoId + '/blacklist'
27
28 return request(url)
29 .delete(path)
30 .set('Accept', 'application/json')
31 .set('Authorization', 'Bearer ' + token)
32 .expect(specialStatus)
33}
34
35function getBlacklistedVideosList (url: string, token: string, specialStatus = 200) {
36 const path = '/api/v1/videos/blacklist/'
37
38 return request(url)
39 .get(path)
40 .query({ sort: 'createdAt' })
41 .set('Accept', 'application/json')
42 .set('Authorization', 'Bearer ' + token)
43 .expect(specialStatus)
44 .expect('Content-Type', /json/)
45}
46
47function getSortedBlacklistedVideosList (url: string, token: string, sort: string, specialStatus = 200) {
48 const path = '/api/v1/videos/blacklist/'
49
50 return request(url)
51 .get(path)
52 .query({ sort: sort })
53 .set('Accept', 'application/json')
54 .set('Authorization', 'Bearer ' + token)
55 .expect(specialStatus)
56 .expect('Content-Type', /json/)
57}
58
59// ---------------------------------------------------------------------------
60
61export {
62 addVideoToBlacklist,
63 removeVideoFromBlacklist,
64 getBlacklistedVideosList,
65 getSortedBlacklistedVideosList,
66 updateVideoBlacklist
67}
diff --git a/server/tests/utils/videos/video-captions.ts b/server/tests/utils/videos/video-captions.ts
deleted file mode 100644
index 41e52be07..000000000
--- a/server/tests/utils/videos/video-captions.ts
+++ /dev/null
@@ -1,71 +0,0 @@
1import { makeDeleteRequest, makeGetRequest } from '../'
2import { buildAbsoluteFixturePath, makeUploadRequest } from '../index'
3import * as request from 'supertest'
4import * as chai from 'chai'
5
6const expect = chai.expect
7
8function createVideoCaption (args: {
9 url: string,
10 accessToken: string
11 videoId: string | number
12 language: string
13 fixture: string,
14 mimeType?: string,
15 statusCodeExpected?: number
16}) {
17 const path = '/api/v1/videos/' + args.videoId + '/captions/' + args.language
18
19 const captionfile = buildAbsoluteFixturePath(args.fixture)
20 const captionfileAttach = args.mimeType ? [ captionfile, { contentType: args.mimeType } ] : captionfile
21
22 return makeUploadRequest({
23 method: 'PUT',
24 url: args.url,
25 path,
26 token: args.accessToken,
27 fields: {},
28 attaches: {
29 captionfile: captionfileAttach
30 },
31 statusCodeExpected: args.statusCodeExpected || 204
32 })
33}
34
35function listVideoCaptions (url: string, videoId: string | number) {
36 const path = '/api/v1/videos/' + videoId + '/captions'
37
38 return makeGetRequest({
39 url,
40 path,
41 statusCodeExpected: 200
42 })
43}
44
45function deleteVideoCaption (url: string, token: string, videoId: string | number, language: string) {
46 const path = '/api/v1/videos/' + videoId + '/captions/' + language
47
48 return makeDeleteRequest({
49 url,
50 token,
51 path,
52 statusCodeExpected: 204
53 })
54}
55
56async function testCaptionFile (url: string, captionPath: string, containsString: string) {
57 const res = await request(url)
58 .get(captionPath)
59 .expect(200)
60
61 expect(res.text).to.contain(containsString)
62}
63
64// ---------------------------------------------------------------------------
65
66export {
67 createVideoCaption,
68 listVideoCaptions,
69 testCaptionFile,
70 deleteVideoCaption
71}
diff --git a/server/tests/utils/videos/video-change-ownership.ts b/server/tests/utils/videos/video-change-ownership.ts
deleted file mode 100644
index f288692ea..000000000
--- a/server/tests/utils/videos/video-change-ownership.ts
+++ /dev/null
@@ -1,54 +0,0 @@
1import * as request from 'supertest'
2
3function changeVideoOwnership (url: string, token: string, videoId: number | string, username) {
4 const path = '/api/v1/videos/' + videoId + '/give-ownership'
5
6 return request(url)
7 .post(path)
8 .set('Accept', 'application/json')
9 .set('Authorization', 'Bearer ' + token)
10 .send({ username })
11 .expect(204)
12}
13
14function getVideoChangeOwnershipList (url: string, token: string) {
15 const path = '/api/v1/videos/ownership'
16
17 return request(url)
18 .get(path)
19 .query({ sort: '-createdAt' })
20 .set('Accept', 'application/json')
21 .set('Authorization', 'Bearer ' + token)
22 .expect(200)
23 .expect('Content-Type', /json/)
24}
25
26function acceptChangeOwnership (url: string, token: string, ownershipId: string, channelId: number, expectedStatus = 204) {
27 const path = '/api/v1/videos/ownership/' + ownershipId + '/accept'
28
29 return request(url)
30 .post(path)
31 .set('Accept', 'application/json')
32 .set('Authorization', 'Bearer ' + token)
33 .send({ channelId })
34 .expect(expectedStatus)
35}
36
37function refuseChangeOwnership (url: string, token: string, ownershipId: string, expectedStatus = 204) {
38 const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse'
39
40 return request(url)
41 .post(path)
42 .set('Accept', 'application/json')
43 .set('Authorization', 'Bearer ' + token)
44 .expect(expectedStatus)
45}
46
47// ---------------------------------------------------------------------------
48
49export {
50 changeVideoOwnership,
51 getVideoChangeOwnershipList,
52 acceptChangeOwnership,
53 refuseChangeOwnership
54}
diff --git a/server/tests/utils/videos/video-channels.ts b/server/tests/utils/videos/video-channels.ts
deleted file mode 100644
index 092985777..000000000
--- a/server/tests/utils/videos/video-channels.ts
+++ /dev/null
@@ -1,118 +0,0 @@
1import * as request from 'supertest'
2import { VideoChannelCreate, VideoChannelUpdate } from '../../../../shared/models/videos'
3import { updateAvatarRequest } from '../index'
4
5function getVideoChannelsList (url: string, start: number, count: number, sort?: string) {
6 const path = '/api/v1/video-channels'
7
8 const req = request(url)
9 .get(path)
10 .query({ start: start })
11 .query({ count: count })
12
13 if (sort) req.query({ sort })
14
15 return req.set('Accept', 'application/json')
16 .expect(200)
17 .expect('Content-Type', /json/)
18}
19
20function getAccountVideoChannelsList (url: string, accountName: string, specialStatus = 200) {
21 const path = '/api/v1/accounts/' + accountName + '/video-channels'
22
23 return request(url)
24 .get(path)
25 .set('Accept', 'application/json')
26 .expect(specialStatus)
27 .expect('Content-Type', /json/)
28}
29
30function addVideoChannel (
31 url: string,
32 token: string,
33 videoChannelAttributesArg: VideoChannelCreate,
34 expectedStatus = 200
35) {
36 const path = '/api/v1/video-channels/'
37
38 // Default attributes
39 let attributes = {
40 displayName: 'my super video channel',
41 description: 'my super channel description',
42 support: 'my super channel support'
43 }
44 attributes = Object.assign(attributes, videoChannelAttributesArg)
45
46 return request(url)
47 .post(path)
48 .send(attributes)
49 .set('Accept', 'application/json')
50 .set('Authorization', 'Bearer ' + token)
51 .expect(expectedStatus)
52}
53
54function updateVideoChannel (
55 url: string,
56 token: string,
57 channelName: string,
58 attributes: VideoChannelUpdate,
59 expectedStatus = 204
60) {
61 const body = {}
62 const path = '/api/v1/video-channels/' + channelName
63
64 if (attributes.displayName) body['displayName'] = attributes.displayName
65 if (attributes.description) body['description'] = attributes.description
66 if (attributes.support) body['support'] = attributes.support
67
68 return request(url)
69 .put(path)
70 .send(body)
71 .set('Accept', 'application/json')
72 .set('Authorization', 'Bearer ' + token)
73 .expect(expectedStatus)
74}
75
76function deleteVideoChannel (url: string, token: string, channelName: string, expectedStatus = 204) {
77 const path = '/api/v1/video-channels/' + channelName
78
79 return request(url)
80 .delete(path)
81 .set('Accept', 'application/json')
82 .set('Authorization', 'Bearer ' + token)
83 .expect(expectedStatus)
84}
85
86function getVideoChannel (url: string, channelName: string) {
87 const path = '/api/v1/video-channels/' + channelName
88
89 return request(url)
90 .get(path)
91 .set('Accept', 'application/json')
92 .expect(200)
93 .expect('Content-Type', /json/)
94}
95
96function updateVideoChannelAvatar (options: {
97 url: string,
98 accessToken: string,
99 fixture: string,
100 videoChannelName: string | number
101}) {
102
103 const path = '/api/v1/video-channels/' + options.videoChannelName + '/avatar/pick'
104
105 return updateAvatarRequest(Object.assign(options, { path }))
106}
107
108// ---------------------------------------------------------------------------
109
110export {
111 updateVideoChannelAvatar,
112 getVideoChannelsList,
113 getAccountVideoChannelsList,
114 addVideoChannel,
115 updateVideoChannel,
116 deleteVideoChannel,
117 getVideoChannel
118}
diff --git a/server/tests/utils/videos/video-comments.ts b/server/tests/utils/videos/video-comments.ts
deleted file mode 100644
index 1b9ee452e..000000000
--- a/server/tests/utils/videos/video-comments.ts
+++ /dev/null
@@ -1,83 +0,0 @@
1import * as request from 'supertest'
2import { makeDeleteRequest } from '../'
3
4function getVideoCommentThreads (url: string, videoId: number | string, start: number, count: number, sort?: string) {
5 const path = '/api/v1/videos/' + videoId + '/comment-threads'
6
7 const req = request(url)
8 .get(path)
9 .query({ start: start })
10 .query({ count: count })
11
12 if (sort) req.query({ sort })
13
14 return req.set('Accept', 'application/json')
15 .expect(200)
16 .expect('Content-Type', /json/)
17}
18
19function getVideoThreadComments (url: string, videoId: number | string, threadId: number) {
20 const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId
21
22 return request(url)
23 .get(path)
24 .set('Accept', 'application/json')
25 .expect(200)
26 .expect('Content-Type', /json/)
27}
28
29function addVideoCommentThread (url: string, token: string, videoId: number | string, text: string, expectedStatus = 200) {
30 const path = '/api/v1/videos/' + videoId + '/comment-threads'
31
32 return request(url)
33 .post(path)
34 .send({ text })
35 .set('Accept', 'application/json')
36 .set('Authorization', 'Bearer ' + token)
37 .expect(expectedStatus)
38}
39
40function addVideoCommentReply (
41 url: string,
42 token: string,
43 videoId: number | string,
44 inReplyToCommentId: number,
45 text: string,
46 expectedStatus = 200
47) {
48 const path = '/api/v1/videos/' + videoId + '/comments/' + inReplyToCommentId
49
50 return request(url)
51 .post(path)
52 .send({ text })
53 .set('Accept', 'application/json')
54 .set('Authorization', 'Bearer ' + token)
55 .expect(expectedStatus)
56}
57
58function deleteVideoComment (
59 url: string,
60 token: string,
61 videoId: number | string,
62 commentId: number,
63 statusCodeExpected = 204
64) {
65 const path = '/api/v1/videos/' + videoId + '/comments/' + commentId
66
67 return makeDeleteRequest({
68 url,
69 path,
70 token,
71 statusCodeExpected
72 })
73}
74
75// ---------------------------------------------------------------------------
76
77export {
78 getVideoCommentThreads,
79 getVideoThreadComments,
80 addVideoCommentThread,
81 addVideoCommentReply,
82 deleteVideoComment
83}
diff --git a/server/tests/utils/videos/video-history.ts b/server/tests/utils/videos/video-history.ts
deleted file mode 100644
index 7635478f7..000000000
--- a/server/tests/utils/videos/video-history.ts
+++ /dev/null
@@ -1,14 +0,0 @@
1import { makePutBodyRequest } from '../requests/requests'
2
3function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number) {
4 const path = '/api/v1/videos/' + videoId + '/watching'
5 const fields = { currentTime }
6
7 return makePutBodyRequest({ url, path, token, fields, statusCodeExpected: 204 })
8}
9
10// ---------------------------------------------------------------------------
11
12export {
13 userWatchVideo
14}
diff --git a/server/tests/utils/videos/video-imports.ts b/server/tests/utils/videos/video-imports.ts
deleted file mode 100644
index 59dfd481a..000000000
--- a/server/tests/utils/videos/video-imports.ts
+++ /dev/null
@@ -1,51 +0,0 @@
1import { VideoImportCreate } from '../../../../shared/models/videos'
2import { makeGetRequest, makeUploadRequest } from '..'
3
4function getYoutubeVideoUrl () {
5 return 'https://youtu.be/msX3jv1XdvM'
6}
7
8function getMagnetURI () {
9 // tslint:disable:max-line-length
10 return 'magnet:?xs=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Ftorrents%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.torrent&xt=urn:btih:0f498834733e8057ed5c6f2ee2b4efd8d84a76ee&dn=super+peertube2+video&tr=wss%3A%2F%2Fpeertube2.cpy.re%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube2.cpy.re%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Fwebseed%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.mp4'
11}
12
13function importVideo (url: string, token: string, attributes: VideoImportCreate) {
14 const path = '/api/v1/videos/imports'
15
16 let attaches: any = {}
17 if (attributes.torrentfile) attaches = { torrentfile: attributes.torrentfile }
18
19 return makeUploadRequest({
20 url,
21 path,
22 token,
23 attaches,
24 fields: attributes,
25 statusCodeExpected: 200
26 })
27}
28
29function getMyVideoImports (url: string, token: string, sort?: string) {
30 const path = '/api/v1/users/me/videos/imports'
31
32 const query = {}
33 if (sort) query['sort'] = sort
34
35 return makeGetRequest({
36 url,
37 query,
38 path,
39 token,
40 statusCodeExpected: 200
41 })
42}
43
44// ---------------------------------------------------------------------------
45
46export {
47 getYoutubeVideoUrl,
48 importVideo,
49 getMagnetURI,
50 getMyVideoImports
51}
diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts
deleted file mode 100644
index bc878b039..000000000
--- a/server/tests/utils/videos/videos.ts
+++ /dev/null
@@ -1,582 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import { expect } from 'chai'
4import { existsSync, readdir, readFile } from 'fs-extra'
5import * as parseTorrent from 'parse-torrent'
6import { extname, join } from 'path'
7import * as request from 'supertest'
8import {
9 buildAbsoluteFixturePath,
10 getMyUserInformation,
11 immutableAssign,
12 makeGetRequest,
13 makePutBodyRequest,
14 makeUploadRequest,
15 root,
16 ServerInfo,
17 testImage
18} from '../'
19import { VideoDetails, VideoPrivacy } from '../../../../shared/models/videos'
20import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers'
21import { dateIsValid, webtorrentAdd } from '../index'
22
23type VideoAttributes = {
24 name?: string
25 category?: number
26 licence?: number
27 language?: string
28 nsfw?: boolean
29 commentsEnabled?: boolean
30 downloadEnabled?: boolean
31 waitTranscoding?: boolean
32 description?: string
33 tags?: string[]
34 channelId?: number
35 privacy?: VideoPrivacy
36 fixture?: string
37 thumbnailfile?: string
38 previewfile?: string
39 scheduleUpdate?: {
40 updateAt: string
41 privacy?: VideoPrivacy
42 }
43}
44
45function getVideoCategories (url: string) {
46 const path = '/api/v1/videos/categories'
47
48 return makeGetRequest({
49 url,
50 path,
51 statusCodeExpected: 200
52 })
53}
54
55function getVideoLicences (url: string) {
56 const path = '/api/v1/videos/licences'
57
58 return makeGetRequest({
59 url,
60 path,
61 statusCodeExpected: 200
62 })
63}
64
65function getVideoLanguages (url: string) {
66 const path = '/api/v1/videos/languages'
67
68 return makeGetRequest({
69 url,
70 path,
71 statusCodeExpected: 200
72 })
73}
74
75function getVideoPrivacies (url: string) {
76 const path = '/api/v1/videos/privacies'
77
78 return makeGetRequest({
79 url,
80 path,
81 statusCodeExpected: 200
82 })
83}
84
85function getVideo (url: string, id: number | string, expectedStatus = 200) {
86 const path = '/api/v1/videos/' + id
87
88 return request(url)
89 .get(path)
90 .set('Accept', 'application/json')
91 .expect(expectedStatus)
92}
93
94function viewVideo (url: string, id: number | string, expectedStatus = 204, xForwardedFor?: string) {
95 const path = '/api/v1/videos/' + id + '/views'
96
97 const req = request(url)
98 .post(path)
99 .set('Accept', 'application/json')
100
101 if (xForwardedFor) {
102 req.set('X-Forwarded-For', xForwardedFor)
103 }
104
105 return req.expect(expectedStatus)
106}
107
108function getVideoWithToken (url: string, token: string, id: number | string, expectedStatus = 200) {
109 const path = '/api/v1/videos/' + id
110
111 return request(url)
112 .get(path)
113 .set('Authorization', 'Bearer ' + token)
114 .set('Accept', 'application/json')
115 .expect(expectedStatus)
116}
117
118function getVideoDescription (url: string, descriptionPath: string) {
119 return request(url)
120 .get(descriptionPath)
121 .set('Accept', 'application/json')
122 .expect(200)
123 .expect('Content-Type', /json/)
124}
125
126function getVideosList (url: string) {
127 const path = '/api/v1/videos'
128
129 return request(url)
130 .get(path)
131 .query({ sort: 'name' })
132 .set('Accept', 'application/json')
133 .expect(200)
134 .expect('Content-Type', /json/)
135}
136
137function getVideosListWithToken (url: string, token: string, query: { nsfw?: boolean } = {}) {
138 const path = '/api/v1/videos'
139
140 return request(url)
141 .get(path)
142 .set('Authorization', 'Bearer ' + token)
143 .query(immutableAssign(query, { sort: 'name' }))
144 .set('Accept', 'application/json')
145 .expect(200)
146 .expect('Content-Type', /json/)
147}
148
149function getLocalVideos (url: string) {
150 const path = '/api/v1/videos'
151
152 return request(url)
153 .get(path)
154 .query({ sort: 'name', filter: 'local' })
155 .set('Accept', 'application/json')
156 .expect(200)
157 .expect('Content-Type', /json/)
158}
159
160function getMyVideos (url: string, accessToken: string, start: number, count: number, sort?: string) {
161 const path = '/api/v1/users/me/videos'
162
163 const req = request(url)
164 .get(path)
165 .query({ start: start })
166 .query({ count: count })
167
168 if (sort) req.query({ sort })
169
170 return req.set('Accept', 'application/json')
171 .set('Authorization', 'Bearer ' + accessToken)
172 .expect(200)
173 .expect('Content-Type', /json/)
174}
175
176function getAccountVideos (
177 url: string,
178 accessToken: string,
179 accountName: string,
180 start: number,
181 count: number,
182 sort?: string,
183 query: { nsfw?: boolean } = {}
184) {
185 const path = '/api/v1/accounts/' + accountName + '/videos'
186
187 return makeGetRequest({
188 url,
189 path,
190 query: immutableAssign(query, {
191 start,
192 count,
193 sort
194 }),
195 token: accessToken,
196 statusCodeExpected: 200
197 })
198}
199
200function getVideoChannelVideos (
201 url: string,
202 accessToken: string,
203 videoChannelName: string,
204 start: number,
205 count: number,
206 sort?: string,
207 query: { nsfw?: boolean } = {}
208) {
209 const path = '/api/v1/video-channels/' + videoChannelName + '/videos'
210
211 return makeGetRequest({
212 url,
213 path,
214 query: immutableAssign(query, {
215 start,
216 count,
217 sort
218 }),
219 token: accessToken,
220 statusCodeExpected: 200
221 })
222}
223
224function getVideosListPagination (url: string, start: number, count: number, sort?: string) {
225 const path = '/api/v1/videos'
226
227 const req = request(url)
228 .get(path)
229 .query({ start: start })
230 .query({ count: count })
231
232 if (sort) req.query({ sort })
233
234 return req.set('Accept', 'application/json')
235 .expect(200)
236 .expect('Content-Type', /json/)
237}
238
239function getVideosListSort (url: string, sort: string) {
240 const path = '/api/v1/videos'
241
242 return request(url)
243 .get(path)
244 .query({ sort: sort })
245 .set('Accept', 'application/json')
246 .expect(200)
247 .expect('Content-Type', /json/)
248}
249
250function getVideosWithFilters (url: string, query: { tagsAllOf: string[], categoryOneOf: number[] | number }) {
251 const path = '/api/v1/videos'
252
253 return request(url)
254 .get(path)
255 .query(query)
256 .set('Accept', 'application/json')
257 .expect(200)
258 .expect('Content-Type', /json/)
259}
260
261function removeVideo (url: string, token: string, id: number | string, expectedStatus = 204) {
262 const path = '/api/v1/videos'
263
264 return request(url)
265 .delete(path + '/' + id)
266 .set('Accept', 'application/json')
267 .set('Authorization', 'Bearer ' + token)
268 .expect(expectedStatus)
269}
270
271async function checkVideoFilesWereRemoved (
272 videoUUID: string,
273 serverNumber: number,
274 directories = [ 'videos', 'thumbnails', 'torrents', 'previews', 'captions' ]
275) {
276 const testDirectory = 'test' + serverNumber
277
278 for (const directory of directories) {
279 const directoryPath = join(root(), testDirectory, directory)
280
281 const directoryExists = existsSync(directoryPath)
282 expect(directoryExists).to.be.true
283
284 const files = await readdir(directoryPath)
285 for (const file of files) {
286 expect(file).to.not.contain(videoUUID)
287 }
288 }
289}
290
291async function uploadVideo (url: string, accessToken: string, videoAttributesArg: VideoAttributes, specialStatus = 200) {
292 const path = '/api/v1/videos/upload'
293 let defaultChannelId = '1'
294
295 try {
296 const res = await getMyUserInformation(url, accessToken)
297 defaultChannelId = res.body.videoChannels[0].id
298 } catch (e) { /* empty */ }
299
300 // Override default attributes
301 const attributes = Object.assign({
302 name: 'my super video',
303 category: 5,
304 licence: 4,
305 language: 'zh',
306 channelId: defaultChannelId,
307 nsfw: true,
308 waitTranscoding: false,
309 description: 'my super description',
310 support: 'my super support text',
311 tags: [ 'tag' ],
312 privacy: VideoPrivacy.PUBLIC,
313 commentsEnabled: true,
314 downloadEnabled: true,
315 fixture: 'video_short.webm'
316 }, videoAttributesArg)
317
318 const req = request(url)
319 .post(path)
320 .set('Accept', 'application/json')
321 .set('Authorization', 'Bearer ' + accessToken)
322 .field('name', attributes.name)
323 .field('nsfw', JSON.stringify(attributes.nsfw))
324 .field('commentsEnabled', JSON.stringify(attributes.commentsEnabled))
325 .field('downloadEnabled', JSON.stringify(attributes.downloadEnabled))
326 .field('waitTranscoding', JSON.stringify(attributes.waitTranscoding))
327 .field('privacy', attributes.privacy.toString())
328 .field('channelId', attributes.channelId)
329
330 if (attributes.description !== undefined) {
331 req.field('description', attributes.description)
332 }
333 if (attributes.language !== undefined) {
334 req.field('language', attributes.language.toString())
335 }
336 if (attributes.category !== undefined) {
337 req.field('category', attributes.category.toString())
338 }
339 if (attributes.licence !== undefined) {
340 req.field('licence', attributes.licence.toString())
341 }
342
343 for (let i = 0; i < attributes.tags.length; i++) {
344 req.field('tags[' + i + ']', attributes.tags[i])
345 }
346
347 if (attributes.thumbnailfile !== undefined) {
348 req.attach('thumbnailfile', buildAbsoluteFixturePath(attributes.thumbnailfile))
349 }
350 if (attributes.previewfile !== undefined) {
351 req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile))
352 }
353
354 if (attributes.scheduleUpdate) {
355 req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt)
356
357 if (attributes.scheduleUpdate.privacy) {
358 req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy)
359 }
360 }
361
362 return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
363 .expect(specialStatus)
364}
365
366function updateVideo (url: string, accessToken: string, id: number | string, attributes: VideoAttributes, statusCodeExpected = 204) {
367 const path = '/api/v1/videos/' + id
368 const body = {}
369
370 if (attributes.name) body['name'] = attributes.name
371 if (attributes.category) body['category'] = attributes.category
372 if (attributes.licence) body['licence'] = attributes.licence
373 if (attributes.language) body['language'] = attributes.language
374 if (attributes.nsfw !== undefined) body['nsfw'] = JSON.stringify(attributes.nsfw)
375 if (attributes.commentsEnabled !== undefined) body['commentsEnabled'] = JSON.stringify(attributes.commentsEnabled)
376 if (attributes.downloadEnabled !== undefined) body['downloadEnabled'] = JSON.stringify(attributes.downloadEnabled)
377 if (attributes.description) body['description'] = attributes.description
378 if (attributes.tags) body['tags'] = attributes.tags
379 if (attributes.privacy) body['privacy'] = attributes.privacy
380 if (attributes.channelId) body['channelId'] = attributes.channelId
381 if (attributes.scheduleUpdate) body['scheduleUpdate'] = attributes.scheduleUpdate
382
383 // Upload request
384 if (attributes.thumbnailfile || attributes.previewfile) {
385 const attaches: any = {}
386 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
387 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
388
389 return makeUploadRequest({
390 url,
391 method: 'PUT',
392 path,
393 token: accessToken,
394 fields: body,
395 attaches,
396 statusCodeExpected
397 })
398 }
399
400 return makePutBodyRequest({
401 url,
402 path,
403 fields: body,
404 token: accessToken,
405 statusCodeExpected
406 })
407}
408
409function rateVideo (url: string, accessToken: string, id: number, rating: string, specialStatus = 204) {
410 const path = '/api/v1/videos/' + id + '/rate'
411
412 return request(url)
413 .put(path)
414 .set('Accept', 'application/json')
415 .set('Authorization', 'Bearer ' + accessToken)
416 .send({ rating })
417 .expect(specialStatus)
418}
419
420function parseTorrentVideo (server: ServerInfo, videoUUID: string, resolution: number) {
421 return new Promise<any>((res, rej) => {
422 const torrentName = videoUUID + '-' + resolution + '.torrent'
423 const torrentPath = join(__dirname, '..', '..', '..', '..', 'test' + server.serverNumber, 'torrents', torrentName)
424 readFile(torrentPath, (err, data) => {
425 if (err) return rej(err)
426
427 return res(parseTorrent(data))
428 })
429 })
430}
431
432async function completeVideoCheck (
433 url: string,
434 video: any,
435 attributes: {
436 name: string
437 category: number
438 licence: number
439 language: string
440 nsfw: boolean
441 commentsEnabled: boolean
442 downloadEnabled: boolean
443 description: string
444 publishedAt?: string
445 support: string
446 account: {
447 name: string
448 host: string
449 }
450 isLocal: boolean
451 tags: string[]
452 privacy: number
453 likes?: number
454 dislikes?: number
455 duration: number
456 channel: {
457 displayName: string
458 name: string
459 description
460 isLocal: boolean
461 }
462 fixture: string
463 files: {
464 resolution: number
465 size: number
466 }[],
467 thumbnailfile?: string
468 previewfile?: string
469 }
470) {
471 if (!attributes.likes) attributes.likes = 0
472 if (!attributes.dislikes) attributes.dislikes = 0
473
474 expect(video.name).to.equal(attributes.name)
475 expect(video.category.id).to.equal(attributes.category)
476 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc')
477 expect(video.licence.id).to.equal(attributes.licence)
478 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
479 expect(video.language.id).to.equal(attributes.language)
480 expect(video.language.label).to.equal(attributes.language !== null ? VIDEO_LANGUAGES[attributes.language] : 'Unknown')
481 expect(video.privacy.id).to.deep.equal(attributes.privacy)
482 expect(video.privacy.label).to.deep.equal(VIDEO_PRIVACIES[attributes.privacy])
483 expect(video.nsfw).to.equal(attributes.nsfw)
484 expect(video.description).to.equal(attributes.description)
485 expect(video.account.id).to.be.a('number')
486 expect(video.account.uuid).to.be.a('string')
487 expect(video.account.host).to.equal(attributes.account.host)
488 expect(video.account.name).to.equal(attributes.account.name)
489 expect(video.channel.displayName).to.equal(attributes.channel.displayName)
490 expect(video.channel.name).to.equal(attributes.channel.name)
491 expect(video.likes).to.equal(attributes.likes)
492 expect(video.dislikes).to.equal(attributes.dislikes)
493 expect(video.isLocal).to.equal(attributes.isLocal)
494 expect(video.duration).to.equal(attributes.duration)
495 expect(dateIsValid(video.createdAt)).to.be.true
496 expect(dateIsValid(video.publishedAt)).to.be.true
497 expect(dateIsValid(video.updatedAt)).to.be.true
498
499 if (attributes.publishedAt) {
500 expect(video.publishedAt).to.equal(attributes.publishedAt)
501 }
502
503 const res = await getVideo(url, video.uuid)
504 const videoDetails: VideoDetails = res.body
505
506 expect(videoDetails.files).to.have.lengthOf(attributes.files.length)
507 expect(videoDetails.tags).to.deep.equal(attributes.tags)
508 expect(videoDetails.account.name).to.equal(attributes.account.name)
509 expect(videoDetails.account.host).to.equal(attributes.account.host)
510 expect(video.channel.displayName).to.equal(attributes.channel.displayName)
511 expect(video.channel.name).to.equal(attributes.channel.name)
512 expect(videoDetails.channel.host).to.equal(attributes.account.host)
513 expect(videoDetails.channel.isLocal).to.equal(attributes.channel.isLocal)
514 expect(dateIsValid(videoDetails.channel.createdAt.toString())).to.be.true
515 expect(dateIsValid(videoDetails.channel.updatedAt.toString())).to.be.true
516 expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled)
517 expect(videoDetails.downloadEnabled).to.equal(attributes.downloadEnabled)
518
519 for (const attributeFile of attributes.files) {
520 const file = videoDetails.files.find(f => f.resolution.id === attributeFile.resolution)
521 expect(file).not.to.be.undefined
522
523 let extension = extname(attributes.fixture)
524 // Transcoding enabled on server 2, extension will always be .mp4
525 if (attributes.account.host === 'localhost:9002') extension = '.mp4'
526
527 const magnetUri = file.magnetUri
528 expect(file.magnetUri).to.have.lengthOf.above(2)
529 expect(file.torrentUrl).to.equal(`http://${attributes.account.host}/static/torrents/${videoDetails.uuid}-${file.resolution.id}.torrent`)
530 expect(file.fileUrl).to.equal(`http://${attributes.account.host}/static/webseed/${videoDetails.uuid}-${file.resolution.id}${extension}`)
531 expect(file.resolution.id).to.equal(attributeFile.resolution)
532 expect(file.resolution.label).to.equal(attributeFile.resolution + 'p')
533
534 const minSize = attributeFile.size - ((10 * attributeFile.size) / 100)
535 const maxSize = attributeFile.size + ((10 * attributeFile.size) / 100)
536 expect(file.size,
537 'File size for resolution ' + file.resolution.label + ' outside confidence interval (' + minSize + '> size <' + maxSize + ')')
538 .to.be.above(minSize).and.below(maxSize)
539
540 {
541 await testImage(url, attributes.thumbnailfile || attributes.fixture, videoDetails.thumbnailPath)
542 }
543
544 if (attributes.previewfile) {
545 await testImage(url, attributes.previewfile, videoDetails.previewPath)
546 }
547
548 const torrent = await webtorrentAdd(magnetUri, true)
549 expect(torrent.files).to.be.an('array')
550 expect(torrent.files.length).to.equal(1)
551 expect(torrent.files[0].path).to.exist.and.to.not.equal('')
552 }
553}
554
555// ---------------------------------------------------------------------------
556
557export {
558 getVideoDescription,
559 getVideoCategories,
560 getVideoLicences,
561 getVideoPrivacies,
562 getVideoLanguages,
563 getMyVideos,
564 getAccountVideos,
565 getVideoChannelVideos,
566 getVideo,
567 getVideoWithToken,
568 getVideosList,
569 getVideosListPagination,
570 getVideosListSort,
571 removeVideo,
572 getVideosListWithToken,
573 uploadVideo,
574 getVideosWithFilters,
575 updateVideo,
576 rateVideo,
577 viewVideo,
578 parseTorrentVideo,
579 getLocalVideos,
580 completeVideoCheck,
581 checkVideoFilesWereRemoved
582}
diff --git a/server/tools/README.md b/server/tools/README.md
new file mode 100644
index 000000000..6b94d74e5
--- /dev/null
+++ b/server/tools/README.md
@@ -0,0 +1,82 @@
1peertube(8) -- companion CLI for PeerTube
2=========================================
3
4SYNOPSIS
5--------
6
7```
8peertube [command] [options]
9```
10
11DESCRIPTION
12-----------
13
14`peertube` wraps various utilities around PeerTube that are used either on a running local, running remote, or cold local instance.
15
16COMMANDS
17--------
18
19Unless otherwise specified, every command can be queried for its own help or manual by passing its name to the `help` command, or by using the `--help` option.
20
21`auth [action]`: stores credentials for your accounts on remote instances so that you don't need to pass them at every command
22
23`upload|up`: upload a video to a remote instance
24
25 $ peertube upload \
26 -u "PEERTUBE_URL" \
27 -U "PEERTUBE_USER" \
28 --password "PEERTUBE_PASSWORD"
29
30`import-videos|import`: import a video from a streaming platform to a remote instance
31
32 $ peertube import \
33 -u "PEERTUBE_URL" \
34 -U "PEERTUBE_USER" \
35 --password "PEERTUBE_PASSWORD" \
36 -t "TARGET_URL"
37
38 The target URL can be directly the video file, or any of the supported sites of youtube-dl. The video is downloaded locally and then uploaded. Already downloaded videos will not be uploaded twice, so you can run and re-run the script in case of crash, disconnection…
39
40`watch|w`: watch a video in the terminal ✩°。⋆
41
42 -g, --gui <player> player type (default: ascii)
43 -i, --invert invert colors (ascii player only)
44 -r, --resolution <res> video resolution (default: 720)
45
46 It provides support for different players:
47
48 - ascii (default ; plays in ascii art in your terminal!)
49 - mpv
50 - mplayer
51 - vlc
52 - stdout
53 - xbmc
54 - airplay
55 - chromecast
56
57`repl`: interact with the application libraries and objects even when PeerTube is not running
58
59 Type .help to see the repl-only functions, or to see the available PeerTube core functions:
60
61 repl> lodash.keys(context)
62
63`help [cmd]`: display help for [cmd]
64
65EXAMPLES
66--------
67
68 $ peertube auth add -u "PEERTUBE_URL" -U "PEERTUBE_USER" --password "PEERTUBE_PASSWORD"
69 $ peertube up <videoFile>
70 $ peertube watch https://peertube.cpy.re/videos/watch/e8a1af4e-414a-4d58-bfe6-2146eed06d10
71
72SEE ALSO
73--------
74
75[PeerTube Tools Documentation](https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/tools.md)
76
77[PeerTube Admin Documentation](https://docs.joinpeertube.org/lang/en/docs/)
78
79REPORTING BUGS
80--------------
81
82See [PeerTube repository](https://github.com/Chocobozzz/PeerTube).
diff --git a/server/tools/cli.ts b/server/tools/cli.ts
index 53b20964e..108c44218 100644
--- a/server/tools/cli.ts
+++ b/server/tools/cli.ts
@@ -23,7 +23,7 @@ async function getSettings () {
23 if (err) { 23 if (err) {
24 return rej(err) 24 return rej(err)
25 } 25 }
26 return res(data || settings) 26 return res(Object.keys(data).length === 0 ? settings : data)
27 }) 27 })
28 }) 28 })
29} 29}
diff --git a/server/tools/peertube-auth.ts b/server/tools/peertube-auth.ts
index 33438811e..a962944a4 100644
--- a/server/tools/peertube-auth.ts
+++ b/server/tools/peertube-auth.ts
@@ -5,34 +5,25 @@ import { getSettings, writeSettings, netrc } from './cli'
5import { isHostValid } from '../helpers/custom-validators/servers' 5import { isHostValid } from '../helpers/custom-validators/servers'
6import { isUserUsernameValid } from '../helpers/custom-validators/users' 6import { isUserUsernameValid } from '../helpers/custom-validators/users'
7 7
8function delInstance (url: string) { 8async function delInstance (url: string) {
9 return new Promise((res, rej): void => { 9 const settings = await getSettings()
10 getSettings() 10
11 .then(async (settings) => { 11 settings.remotes.splice(settings.remotes.indexOf(url))
12 settings.remotes.splice(settings.remotes.indexOf(url)) 12 await writeSettings(settings)
13 await writeSettings(settings) 13
14 delete netrc.machines[url] 14 delete netrc.machines[url]
15 netrc.save() 15 await netrc.save()
16 res()
17 })
18 .catch(err => rej(err))
19 })
20} 16}
21 17
22async function setInstance (url: string, username: string, password: string) { 18async function setInstance (url: string, username: string, password: string) {
23 return new Promise((res, rej): void => { 19 const settings = await getSettings()
24 getSettings() 20 if (settings.remotes.indexOf(url) === -1) {
25 .then(async settings => { 21 settings.remotes.push(url)
26 if (settings.remotes.indexOf(url) === -1) { 22 }
27 settings.remotes.push(url) 23 await writeSettings(settings)
28 } 24
29 await writeSettings(settings) 25 netrc.machines[url] = { login: username, password }
30 netrc.machines[url] = { login: username, password } 26 await netrc.save()
31 netrc.save()
32 res()
33 })
34 .catch(err => rej(err))
35 })
36} 27}
37 28
38function isURLaPeerTubeInstance (url: string) { 29function isURLaPeerTubeInstance (url: string) {
@@ -71,56 +62,60 @@ program
71 required: true 62 required: true
72 } 63 }
73 } 64 }
74 }, (_, result) => { 65 }, async (_, result) => {
75 setInstance(result.url, result.username, result.password) 66 await setInstance(result.url, result.username, result.password)
67
68 process.exit(0)
76 }) 69 })
77 }) 70 })
78 71
79program 72program
80 .command('del <url>') 73 .command('del <url>')
81 .description('unregisters a remote instance') 74 .description('unregisters a remote instance')
82 .action((url) => { 75 .action(async url => {
83 delInstance(url) 76 await delInstance(url)
77
78 process.exit(0)
84 }) 79 })
85 80
86program 81program
87 .command('list') 82 .command('list')
88 .description('lists registered remote instances') 83 .description('lists registered remote instances')
89 .action(() => { 84 .action(async () => {
90 getSettings() 85 const settings = await getSettings()
91 .then(settings => { 86 const table = new Table({
92 const table = new Table({ 87 head: ['instance', 'login'],
93 head: ['instance', 'login'], 88 colWidths: [30, 30]
94 colWidths: [30, 30] 89 })
95 }) 90 netrc.loadSync()
96 netrc.loadSync() 91 settings.remotes.forEach(element => {
97 settings.remotes.forEach(element => { 92 table.push([
98 table.push([ 93 element,
99 element, 94 netrc.machines[element].login
100 netrc.machines[element].login 95 ])
101 ]) 96 })
102 }) 97
103 98 console.log(table.toString())
104 console.log(table.toString()) 99
105 }) 100 process.exit(0)
106 }) 101 })
107 102
108program 103program
109 .command('set-default <url>') 104 .command('set-default <url>')
110 .description('set an existing entry as default') 105 .description('set an existing entry as default')
111 .action((url) => { 106 .action(async url => {
112 getSettings() 107 const settings = await getSettings()
113 .then(settings => { 108 const instanceExists = settings.remotes.indexOf(url) !== -1
114 const instanceExists = settings.remotes.indexOf(url) !== -1 109
115 110 if (instanceExists) {
116 if (instanceExists) { 111 settings.default = settings.remotes.indexOf(url)
117 settings.default = settings.remotes.indexOf(url) 112 await writeSettings(settings)
118 writeSettings(settings) 113
119 } else { 114 process.exit(0)
120 console.log('<url> is not a registered instance.') 115 } else {
121 process.exit(-1) 116 console.log('<url> is not a registered instance.')
122 } 117 process.exit(-1)
123 }) 118 }
124 }) 119 })
125 120
126program.on('--help', function () { 121program.on('--help', function () {
diff --git a/server/tools/peertube-get-access-token.ts b/server/tools/peertube-get-access-token.ts
index eb2571a03..a68665f5b 100644
--- a/server/tools/peertube-get-access-token.ts
+++ b/server/tools/peertube-get-access-token.ts
@@ -6,7 +6,7 @@ import {
6 Server, 6 Server,
7 Client, 7 Client,
8 User 8 User
9} from '../tests/utils/index' 9} from '../../shared/utils'
10 10
11program 11program
12 .option('-u, --url <url>', 'Server url') 12 .option('-u, --url <url>', 'Server url')
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
index 15f517cab..151c5a989 100644
--- a/server/tools/peertube-import-videos.ts
+++ b/server/tools/peertube-import-videos.ts
@@ -6,10 +6,11 @@ import { join } from 'path'
6import { VideoPrivacy } from '../../shared/models/videos' 6import { VideoPrivacy } from '../../shared/models/videos'
7import { doRequestAndSaveToFile } from '../helpers/requests' 7import { doRequestAndSaveToFile } from '../helpers/requests'
8import { CONSTRAINTS_FIELDS } from '../initializers' 8import { CONSTRAINTS_FIELDS } from '../initializers'
9import { getClient, getVideoCategories, login, searchVideoWithSort, uploadVideo } from '../tests/utils' 9import { getClient, getVideoCategories, login, searchVideoWithSort, uploadVideo } from '../../shared/utils/index'
10import { truncate } from 'lodash' 10import { truncate } from 'lodash'
11import * as prompt from 'prompt' 11import * as prompt from 'prompt'
12import { remove } from 'fs-extra' 12import { remove } from 'fs-extra'
13import { sha256 } from '../helpers/core-utils'
13import { safeGetYoutubeDL } from '../helpers/youtube-dl' 14import { safeGetYoutubeDL } from '../helpers/youtube-dl'
14import { getSettings, netrc } from './cli' 15import { getSettings, netrc } from './cli'
15 16
@@ -57,6 +58,7 @@ getSettings()
57 settings.remotes[settings.default] : 58 settings.remotes[settings.default] :
58 settings.remotes[0] 59 settings.remotes[0]
59 } 60 }
61
60 if (!program['username']) program['username'] = netrc.machines[program['url']].login 62 if (!program['username']) program['username'] = netrc.machines[program['url']].login
61 if (!program['password']) program['password'] = netrc.machines[program['url']].password 63 if (!program['password']) program['password'] = netrc.machines[program['url']].password
62 } 64 }
@@ -68,12 +70,19 @@ getSettings()
68 process.exit(-1) 70 process.exit(-1)
69 } 71 }
70 72
73 removeEndSlashes(program['url'])
74 removeEndSlashes(program['targetUrl'])
75
71 const user = { 76 const user = {
72 username: program['username'], 77 username: program['username'],
73 password: program['password'] 78 password: program['password']
74 } 79 }
75 80
76 run(user, program['url']).catch(err => console.error(err)) 81 run(user, program['url'])
82 .catch(err => {
83 console.error(err)
84 process.exit(-1)
85 })
77}) 86})
78 87
79async function promptPassword () { 88async function promptPassword () {
@@ -107,8 +116,12 @@ async function run (user, url: string) {
107 secret: res.body.client_secret 116 secret: res.body.client_secret
108 } 117 }
109 118
110 const res2 = await login(url, client, user) 119 try {
111 accessToken = res2.body.access_token 120 const res = await login(program[ 'url' ], client, user)
121 accessToken = res.body.access_token
122 } catch (err) {
123 throw new Error('Cannot authenticate. Please check your username/password.')
124 }
112 125
113 const youtubeDL = await safeGetYoutubeDL() 126 const youtubeDL = await safeGetYoutubeDL()
114 127
@@ -133,8 +146,7 @@ async function run (user, url: string) {
133 await processVideo(info, program['language'], processOptions.cwd, url, user) 146 await processVideo(info, program['language'], processOptions.cwd, url, user)
134 } 147 }
135 148
136 // https://www.youtube.com/watch?v=2Upx39TBc1s 149 console.log('Video/s for user %s imported: %s', program['username'], program['targetUrl'])
137 console.log('I\'m finished!')
138 process.exit(0) 150 process.exit(0)
139 }) 151 })
140} 152}
@@ -155,7 +167,7 @@ function processVideo (info: any, languageCode: string, cwd: string, url: string
155 return res() 167 return res()
156 } 168 }
157 169
158 const path = join(cwd, new Date().getTime() + '.mp4') 170 const path = join(cwd, sha256(videoInfo.url) + '.mp4')
159 171
160 console.log('Downloading video "%s"...', videoInfo.title) 172 console.log('Downloading video "%s"...', videoInfo.title)
161 173
@@ -192,7 +204,7 @@ async function uploadVideoOnPeerTube (videoInfo: any, videoPath: string, cwd: st
192 204
193 let thumbnailfile 205 let thumbnailfile
194 if (videoInfo.thumbnail) { 206 if (videoInfo.thumbnail) {
195 thumbnailfile = join(cwd, 'thumbnail.jpg') 207 thumbnailfile = join(cwd, sha256(videoInfo.thumbnail) + '.jpg')
196 208
197 await doRequestAndSaveToFile({ 209 await doRequestAndSaveToFile({
198 method: 'GET', 210 method: 'GET',
@@ -322,3 +334,9 @@ function isNSFW (info: any) {
322 334
323 return false 335 return false
324} 336}
337
338function removeEndSlashes (url: string) {
339 while (url.endsWith('/')) {
340 url.slice(0, -1)
341 }
342}
diff --git a/server/tools/peertube-repl.ts b/server/tools/peertube-repl.ts
new file mode 100644
index 000000000..04d8b95a3
--- /dev/null
+++ b/server/tools/peertube-repl.ts
@@ -0,0 +1,76 @@
1import * as repl from 'repl'
2import * as path from 'path'
3import * as _ from 'lodash'
4import * as uuidv1 from 'uuid/v1'
5import * as uuidv3 from 'uuid/v3'
6import * as uuidv4 from 'uuid/v4'
7import * as uuidv5 from 'uuid/v5'
8import * as Sequelize from 'sequelize'
9import * as YoutubeDL from 'youtube-dl'
10
11import { initDatabaseModels, sequelizeTypescript } from '../initializers'
12import * as cli from '../tools/cli'
13import { logger } from '../helpers/logger'
14import * as constants from '../initializers/constants'
15import * as modelsUtils from '../models/utils'
16import * as coreUtils from '../helpers/core-utils'
17import * as ffmpegUtils from '../helpers/ffmpeg-utils'
18import * as peertubeCryptoUtils from '../helpers/peertube-crypto'
19import * as signupUtils from '../helpers/signup'
20import * as utils from '../helpers/utils'
21import * as YoutubeDLUtils from '../helpers/youtube-dl'
22
23const start = async () => {
24 await initDatabaseModels(true)
25
26 const versionCommitHash = await utils.getServerCommit()
27
28 const initContext = (replServer) => {
29 return (context) => {
30 const properties = {
31 context, repl: replServer, env: process.env,
32 lodash: _, path,
33 uuidv1, uuidv3, uuidv4, uuidv5,
34 cli, logger, constants,
35 Sequelize, sequelizeTypescript, modelsUtils,
36 models: sequelizeTypescript.models, transaction: sequelizeTypescript.transaction,
37 query: sequelizeTypescript.query, queryInterface: sequelizeTypescript.getQueryInterface(),
38 YoutubeDL,
39 coreUtils, ffmpegUtils, peertubeCryptoUtils, signupUtils, utils, YoutubeDLUtils
40 }
41
42 for (let prop in properties) {
43 Object.defineProperty(context, prop, {
44 configurable: false,
45 enumerable: true,
46 value: properties[prop]
47 })
48 }
49 }
50 }
51
52 const replServer = repl.start({
53 prompt: `PeerTube [${cli.version}] (${versionCommitHash})> `
54 })
55
56 initContext(replServer)(replServer.context)
57 replServer.on('reset', initContext(replServer))
58 replServer.on('exit', () => process.exit())
59
60 const resetCommand = {
61 help: 'Reset REPL',
62 action () {
63 this.write('.clear\n')
64 this.displayPrompt()
65 }
66 }
67 replServer.defineCommand('reset', resetCommand)
68 replServer.defineCommand('r', resetCommand)
69
70}
71
72start().then((data) => {
73 // do nothing
74}).catch((err) => {
75 console.error(err)
76})
diff --git a/server/tools/peertube-upload.ts b/server/tools/peertube-upload.ts
index b17bc4288..ebc62c965 100644
--- a/server/tools/peertube-upload.ts
+++ b/server/tools/peertube-upload.ts
@@ -1,8 +1,8 @@
1import * as program from 'commander' 1import * as program from 'commander'
2import { access, constants } from 'fs-extra' 2import { access, constants } from 'fs-extra'
3import { isAbsolute } from 'path' 3import { isAbsolute } from 'path'
4import { getClient, login } from '../tests/utils' 4import { getClient, login } from '../../shared/utils'
5import { uploadVideo } from '../tests/utils/index' 5import { uploadVideo } from '../../shared/utils/'
6import { VideoPrivacy } from '../../shared/models/videos' 6import { VideoPrivacy } from '../../shared/models/videos'
7import { netrc, getSettings } from './cli' 7import { netrc, getSettings } from './cli'
8 8
diff --git a/server/tools/peertube.ts b/server/tools/peertube.ts
index ad76bafb4..5d3ab2815 100755
--- a/server/tools/peertube.ts
+++ b/server/tools/peertube.ts
@@ -17,6 +17,7 @@ program
17 .command('import-videos', 'import a video from a streaming platform').alias('import') 17 .command('import-videos', 'import a video from a streaming platform').alias('import')
18 .command('get-access-token', 'get a peertube access token', { noHelp: true }).alias('token') 18 .command('get-access-token', 'get a peertube access token', { noHelp: true }).alias('token')
19 .command('watch', 'watch a video in the terminal ✩°。⋆').alias('w') 19 .command('watch', 'watch a video in the terminal ✩°。⋆').alias('w')
20 .command('repl', 'initiate a REPL to access internals')
20 21
21/* Not Yet Implemented */ 22/* Not Yet Implemented */
22program 23program
@@ -57,7 +58,7 @@ if (!process.argv.slice(2).length) {
57 ,"\\/ 58 ,"\\/
58 _,.__/"\\/_ (the CLI for red chocobos) 59 _,.__/"\\/_ (the CLI for red chocobos)
59 / \\) "./, ". 60 / \\) "./, ".
60 --/---"---" "-) )---- by Chocobozzz et al.`) 61 --/---"---" "-) )---- by Chocobozzz et al.\n`)
61} 62}
62 63
63getSettings() 64getSettings()