Merge from upstream

pull/1357/head
buoyantair 2018-11-16 02:37:16 +05:30
commit ae28cdf327
118 changed files with 4970 additions and 17172 deletions

View File

@ -2,13 +2,15 @@
Interested in contributing? Awesome!
**Quick Links:**
**This guide will present you the following contribution topics:**
* [Translate](#translate)
* [Give your feedback](#give-your-feedback)
* [Write documentation](#write-documentation)
* [Develop](#develop)
* [Improve the website](#improve-the-website)
* [Troubleshooting](#troubleshooting)
* [Tutorials](#tutorials)
## Translate
@ -37,6 +39,15 @@ Some hints:
* Models sent/received by the controllers are defined in [/shared/models](/shared/models) directory
## Improve the website
PeerTube's website is [joinpeertube.org](https://joinpeertube.org), where people can learn about the project and how it works note that it is not a PeerTube instance, but rather the project's homepage.
You can help us improve it too!
It is not hosted on GitHub but on [Framasoft](https://framasoft.org/)'s own [GitLab](https://about.gitlab.com/) instance, [FramaGit](https://framagit.org): https://framagit.org/framasoft/peertube/joinpeertube
## Develop
Don't hesitate to talk about features you want to develop by creating/commenting an issue
@ -122,37 +133,7 @@ and the web server is automatically restarted.
$ npm run dev
```
Depending on your OS, you may face the following error :
```
$ [nodemon] Internal watch failed: ENOSPC: no space left on device, watch '/PeerTube/dist'
```
This is due to your system's limit on the number of files you can monitor for live-checking changes. For example, Ubuntu uses inotify and this limit is set to 8192. Then you need to change this limit :
```
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
```
See more information here : https://github.com/guard/listen/wiki/Increasing-the-amount-of-inotify-watchers
### Configurations for VPS
If you want to develop using a Virtual Private Server, you will need to configure the url for the API and the hostname. First, you need to edit the [client/src/environments/environment.hmr.ts](client/src/environments/environment.hmr.ts) file by replacing the `localhost` in the `apiUrl` field with the address of your VPS. Thus, the [Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/) from Webpack will be set up for developping with live-reload.
Next, you will need to edit the [config/default.yaml](config/default.yaml) file. Just replace the `localhost` with your VPS address in the following `hostname` fields :
```
listen:
hostname: 'my-vps-address.net'
port: 9000
webserver:
https: false
hostname: 'my-vps-address.net'
port: 9000
```
Then, you just need to listen to `https://my-vps-address.net:3000/` in your web browser.
### Federation
### Testing the federation of PeerTube servers
Create a PostgreSQL user **with the same name as your username** in order to avoid using the *postgres* user.
Then, we can create the databases (if they don't already exist):
@ -206,3 +187,11 @@ $ npm run mocha -- --exit --require ts-node/register/type-check --bail server/te
Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`.
Note that only instance 2 has transcoding enabled.
### Troubleshooting
Please check out the issues and [list of common errors](https://docs.joinpeertube.org/lang/en/devdocs/troubleshooting.html).
### Tutorials
Please check out the related section in the [development documentation](https://docs.joinpeertube.org/lang/en/devdocs/index.html#tutorials). Contribute tutorials at [framagit.org/framasoft/peertube/documentation](https://framagit.org/framasoft/peertube/documentation).

View File

@ -1,13 +1,13 @@
<h1 align="center">
<a href="https://joinpeertube.org">
<img src="https://joinpeertube.org/img/brand.png" alt="PeerTube">
<a>
<img src="https://joinpeertube.org/img/brand.png" alt="PeerTube">
</a>
</h1>
<p align=center>
<strong><a href="https://joinpeertube.org">Website</a></strong>
| <strong><a href="https://instances.joinpeertube.org">Join an instance</a></strong>
| <strong><a href="#package-create-your-own-instance">Create one</a></strong>
| <strong><a href="#package-create-your-own-instance">Create an instance</a></strong>
| <strong><a href="#contact">Chat with us</a></strong>
</p>
@ -75,7 +75,7 @@ Just upload your videos, and be sure they will stream anywhere. Add a descriptio
<h3 align="right">Keep in touch with video creators</h3>
<p align="right">
Follow your favorite channels from PeerTube or really any other place. No need to have an account on the instance you watched a video to follow its author, you can do all of that from the Fediverse (Mastodon, Pleroma and plenty others), or just with good ol' RSS.
Follow your favorite channels from PeerTube or really any other place. No need to have an account on the instance you watched a video to follow its author, you can do all of that from the Fediverse (Mastodon, Pleroma, and plenty others), or just with good ol' RSS.
</p>
---
@ -121,6 +121,24 @@ enough because one video could become popular and overload the server. That is
why we need to use a P2P protocol to limit the server load. Thanks to
[WebTorrent](https://github.com/feross/webtorrent), we can make BitTorrent inside the web browser, as of today.
:raised_hands: Contributing
----------------------------------------------------------------
You don't need to be a coder to help!
You can give us your feedback, report bugs, help us translate PeerTube, write documentation, and more. Check out the [contributing
guide](/.github/CONTRIBUTING.md) to know how, it takes less than 2 minutes to get started. :wink:
You can also join the cheerful bunch that makes our community:
* Chat<a name="contact"></a>:
* IRC : **[#peertube on chat.freenode.net:6697](https://kiwiirc.com/client/irc.freenode.net/#peertube)**
* Matrix (bridged on the IRC channel) : **[#peertube:matrix.org](https://matrix.to/#/#peertube:matrix.org)**
* Forum:
* Framacolibri: [https://framacolibri.org/c/peertube](https://framacolibri.org/c/peertube)
Feel free to reach out if you have any questions or ideas! :speech_balloon:
:package: Create your own instance
----------------------------------------------------------------
@ -137,20 +155,6 @@ See the [production guide](/support/doc/production.md), which is the recommended
See the [community packages](https://docs.joinpeertube.org/lang/en/docs/install.html), which cover various platforms (including [YunoHost](https://install-app.yunohost.org/?app=peertube) and [Docker](/support/doc/docker.md)).
:wrench: Contribute/Translate/Test
----------------------------------------------------------------
*Spoiler alert*: you don't need to be a coder to help!
See the [contributing
guide](/.github/CONTRIBUTING.md). Or simply join the cheerful bunch that makes our community:
* Chat<a name="contact"></a>:
* IRC : **[#peertube on chat.freenode.net:6697](https://kiwiirc.com/client/irc.freenode.net/#peertube)**
* Matrix (bridged on the IRC channel) : **[#peertube:matrix.org](https://matrix.to/#/#peertube:matrix.org)**
* Forum:
* Framacolibri: [https://framacolibri.org/c/peertube](https://framacolibri.org/c/peertube)
:book: Documentation
----------------------------------------------------------------
@ -179,9 +183,8 @@ See [ARCHITECTURE.md](/ARCHITECTURE.md) for a more detailed explanation of the a
#### Backend
* REST API:
* Quick Start: [/support/doc/api/quickstart.md](/support/doc/api/quickstart.md)
* Swagger/OpenAPI schema: [/support/doc/api/openapi.yaml](/support/doc/api/openapi.yaml)
* HTML explorer: [/support/doc/api/html/index.html](https://htmlpreview.github.io/?https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/api/html/index.html)
* OpenAPI 3.0.0 schema: [/support/doc/api/openapi.yaml](/support/doc/api/openapi.yaml)
* HTML explorer: [docs.joinpeertube.org/api.html](http://docs.joinpeertube.org/api.html)
* Servers communicate with each other with [Activity
Pub](https://www.w3.org/TR/activitypub/).
* Each server has its own users who query it (search videos, query where the

View File

@ -63,23 +63,23 @@
"setupTestFrameworkScriptFile": "<rootDir>/src/setupJest.ts"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.8.3",
"@angular/animations": "~6.1.4",
"@angular/cli": "~6.2.3",
"@angular/common": "~6.1.4",
"@angular/compiler": "~6.1.4",
"@angular/compiler-cli": "~6.1.4",
"@angular/core": "~6.1.4",
"@angular/forms": "~6.1.4",
"@angular/http": "~6.1.4",
"@angular/language-service": "~6.1.4",
"@angular/platform-browser": "~6.1.4",
"@angular/platform-browser-dynamic": "~6.1.4",
"@angular/router": "~6.1.4",
"@angular/service-worker": "~6.1.4",
"@angular-devkit/build-angular": "~0.10.0",
"@angular/animations": "~7.0.2",
"@angular/cli": "~7.0.4",
"@angular/common": "~7.0.2",
"@angular/compiler": "~7.0.2",
"@angular/compiler-cli": "~7.0.2",
"@angular/core": "~7.0.2",
"@angular/forms": "~7.0.2",
"@angular/http": "~7.0.2",
"@angular/language-service": "~7.0.2",
"@angular/platform-browser": "~7.0.2",
"@angular/platform-browser-dynamic": "~7.0.2",
"@angular/router": "~7.0.2",
"@angular/service-worker": "~7.0.2",
"@angularclass/hmr": "^2.1.3",
"@neos21/bootstrap3-glyphicons": "^1.0.1",
"@ng-bootstrap/ng-bootstrap": "^3.1.0",
"@ng-bootstrap/ng-bootstrap": "^4.0.0",
"@ngx-loading-bar/core": "^2.2.0",
"@ngx-loading-bar/http-client": "^2.2.0",
"@ngx-loading-bar/router": "^2.2.0",
@ -129,7 +129,6 @@
"ngx-clipboard": "11.1.7",
"ngx-pipes": "^2.1.7",
"ngx-qrcode2": "^0.0.9",
"ngx-textarea-autosize": "^2.0.0",
"node-sass": "^4.9.3",
"npm-font-source-sans-pro": "^1.0.2",
"path-browserify": "^1.0.0",
@ -139,17 +138,17 @@
"purify-css": "^1.2.5",
"purifycss-webpack": "^0.7.0",
"raw-loader": "^0.5.1",
"rxjs": "^6.1.0",
"rxjs": "^6.3.3",
"sanitize-html": "^1.18.4",
"sass-loader": "^7.1.0",
"sass-resources-loader": "^1.2.1",
"sass-resources-loader": "^2.0.0",
"stream-browserify": "^2.0.1",
"stream-http": "^2.8.3",
"stream-http": "^3.0.0",
"terser-webpack-plugin": "^1.1.0",
"ts-jest": "^23.1.4",
"tslint": "^5.7.0",
"tslint-config-standard": "^8.0.1",
"typescript": "2.9",
"typescript": "3.1.6",
"video.js": "^7",
"videojs-contextmenu-ui": "^5.0.0",
"videojs-dock": "^2.0.2",

View File

@ -4,10 +4,10 @@ import { USER_ROLE_LABELS, VideoResolution } from '../../../../../../shared'
import { ConfigService } from '@app/+admin/config/shared/config.service'
export abstract class UserEdit extends FormReactive {
videoQuotaOptions: { value: string, label: string }[] = []
videoQuotaDailyOptions: { value: string, label: string }[] = []
roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] }))
username: string
protected abstract serverService: ServerService
protected abstract configService: ConfigService

View File

@ -86,4 +86,4 @@
</ng-template>
</p-table>
<my-user-ban-modal #userBanModal (userBanned)="onUsersBanned()"></my-user-ban-modal>
<my-user-ban-modal #userBanModal (userBanned)="onUserChanged()"></my-user-ban-modal>

View File

@ -66,7 +66,7 @@ export class UserListComponent extends RestTable implements OnInit {
this.userBanModal.openModal(users)
}
onUsersBanned () {
onUserChanged () {
this.loadData()
}

View File

@ -4,7 +4,11 @@ import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
export abstract class MyAccountVideoChannelEdit extends FormReactive {
// We need it even in the create component because it's used in the edit template
videoChannelToUpdate: VideoChannel
instanceHost: string
abstract isCreation (): boolean
abstract getFormButtonTitle (): string
// FIXME: We need this method so angular does not complain in the child template
onAvatarChange (formData: FormData) { /* empty */ }
}

View File

@ -25,7 +25,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
marginContent = false // Disable margin
currentRoute = '/video-channel/videos'
currentRoute = '/video-channels/videos'
loadOnInit = false
private videoChannel: VideoChannel
@ -55,7 +55,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
this.videoChannelSub = this.videoChannelService.videoChannelLoaded
.subscribe(videoChannel => {
this.videoChannel = videoChannel
this.currentRoute = '/video-channel/' + this.videoChannel.uuid + '/videos'
this.currentRoute = '/video-channels/' + this.videoChannel.uuid + '/videos'
this.reloadVideos()
this.generateSyndicationList()

View File

@ -50,7 +50,7 @@
.icon.icon-upload {
@include icon(22px);
background-image: url('../../assets/images/header/upload.svg');
background-image: url('../../assets/images/header/upload-white.svg');
height: 24px;
vertical-align: middle;
margin-right: 6px;

View File

@ -131,10 +131,14 @@ menu {
transition: background-color .1s ease-in-out;
@include disable-default-a-behaviour;
&:hover, &.focus-visible {
&.active {
background-color: rgba(255, 255, 255, 0.15);
}
&:hover, &.focus-visible {
background-color: rgba(255, 255, 255, 0.10);
}
.icon {
@include icon(22px);

View File

@ -1,3 +1,4 @@
export * from './form-validators'
export * from './form-reactive'
export * from './reactive-file.component'
export * from './textarea-autoresize.directive'

View File

@ -0,0 +1,25 @@
// Thanks: https://github.com/evseevdev/ngx-textarea-autosize
import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core'
@Directive({
selector: 'textarea[myAutoResize]'
})
export class TextareaAutoResizeDirective implements AfterViewInit {
@HostBinding('attr.rows') rows = '1'
@HostBinding('style.overflow') overflow = 'hidden'
constructor (private elem: ElementRef) { }
public ngAfterViewInit () {
this.resize()
}
@HostListener('input')
resize () {
const textarea = this.elem.nativeElement as HTMLTextAreaElement
// Reset textarea height to auto that correctly calculate the new height
textarea.style.height = 'auto'
// Set new height
textarea.style.height = `${textarea.scrollHeight}px`
}
}

View File

@ -7,8 +7,9 @@ export class FromNowPipe implements PipeTransform {
constructor (private i18n: I18n) { }
transform (value: number) {
const seconds = Math.floor((Date.now() - value) / 1000)
transform (arg: number | Date | string) {
const argDate = new Date(arg)
const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
let interval = Math.floor(seconds / 31536000)
if (interval > 1) {

View File

@ -37,13 +37,15 @@ import {
LoginValidatorsService,
ReactiveFileComponent,
ResetPasswordValidatorsService,
TextareaAutoResizeDirective,
UserValidatorsService,
VideoAbuseValidatorsService,
VideoAcceptOwnershipValidatorsService,
VideoBlacklistValidatorsService,
VideoChangeOwnershipValidatorsService,
VideoChannelValidatorsService,
VideoCommentValidatorsService,
VideoValidatorsService,
VideoChangeOwnershipValidatorsService, VideoAcceptOwnershipValidatorsService
VideoValidatorsService
} from '@app/shared/forms'
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
import { ScreenService } from '@app/shared/misc/screen.service'
@ -53,7 +55,7 @@ import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.c
import { VideoImportService } from '@app/shared/video-import/video-import.service'
import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component'
import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription'
import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
import { OverviewService } from '@app/shared/overview'
import { UserBanModalComponent } from '@app/shared/moderation'
@ -92,6 +94,7 @@ import { BlocklistService } from '@app/shared/blocklist'
FromNowPipe,
MarkdownTextareaComponent,
InfiniteScrollerDirective,
TextareaAutoResizeDirective,
HelpComponent,
ReactiveFileComponent,
PeertubeCheckboxComponent,
@ -129,6 +132,7 @@ import { BlocklistService } from '@app/shared/blocklist'
ActionDropdownComponent,
MarkdownTextareaComponent,
InfiniteScrollerDirective,
TextareaAutoResizeDirective,
HelpComponent,
ReactiveFileComponent,
PeertubeCheckboxComponent,

View File

@ -7,10 +7,10 @@
class="video-miniature-name"
[routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
>
{{ video.name }}
<span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span>
<span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span>
{{ video.name }}
</a>
<span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>

View File

@ -6,11 +6,11 @@ import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } fr
import { ResultList } from '../../../../../shared/models/result-list.model'
import {
UserVideoRate,
UserVideoRateType,
UserVideoRateUpdate,
VideoConstant,
VideoFilter,
VideoPrivacy,
VideoRateType,
VideoUpdate
} from '../../../../../shared/models/videos'
import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
@ -332,7 +332,7 @@ export class VideoService implements VideosProvider {
return privacies
}
private setVideoRate (id: number, rateType: VideoRateType) {
private setVideoRate (id: number, rateType: UserVideoRateType) {
const url = VideoService.BASE_VIDEO_URL + id + '/rate'
const body: UserVideoRateUpdate = {
rating: rateType

View File

@ -3,7 +3,7 @@
<img [src]="getAvatarUrl()" alt="Avatar" />
<div class="form-group">
<textarea i18n-placeholder placeholder="Add comment..." autosize
<textarea i18n-placeholder placeholder="Add comment..." myAutoResize
[readonly]="(user === null) ? true : false"
(click)="openVisitorModal($event)"
formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }"

View File

@ -29,9 +29,9 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
@Output() commentCreated = new EventEmitter<VideoCommentCreate>()
@ViewChild('visitorModal') visitorModal: NgbModal
@ViewChild('textarea') private textareaElement: ElementRef
@ViewChild('textarea') textareaElement: ElementRef
private addingComment = false
addingComment = false
constructor (
protected formValidatorService: FormValidatorService,

View File

@ -450,7 +450,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.checkUserRating()
}
private setRating (nextRating: VideoRateType) {
private setRating (nextRating: UserVideoRateType) {
let method
switch (nextRating) {
case 'like':
@ -476,7 +476,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
)
}
private updateVideoRating (oldRating: UserVideoRateType, newRating: VideoRateType) {
private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) {
let likesToIncrement = 0
let dislikesToIncrement = 0

View File

@ -17,7 +17,6 @@ import { NgxQRCodeModule } from 'ngx-qrcode2'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { VideoBlacklistComponent } from '@app/videos/+video-watch/modal/video-blacklist.component'
import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
import { TextareaAutosizeModule } from 'ngx-textarea-autosize'
@NgModule({
imports: [
@ -26,7 +25,6 @@ import { TextareaAutosizeModule } from 'ngx-textarea-autosize'
ClipboardModule,
NgbTooltipModule,
NgxQRCodeModule,
TextareaAutosizeModule,
RecommendationsModule
],

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -47,12 +47,6 @@ import 'core-js/es7/object'
// For Google Bot
import 'core-js/es6/reflect'
/**
* Evergreen browsers require these.
*/
// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
import 'core-js/es7/reflect'
/**
* Required to support Web Animations `@angular/platform-browser/animations`.
* Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation

View File

@ -29,7 +29,7 @@
display: block;
/* Fallback for non-webkit */
display: -webkit-box;
max-height: $font-size*$line-height*$lines-to-show + 0.2;
max-height: $font-size * $line-height * $lines-to-show;
/* Fallback for non-webkit */
font-size: $font-size;
line-height: $line-height;
@ -511,4 +511,4 @@
}
}
}
}
}

View File

@ -256,9 +256,8 @@ class PeerTubeEmbed {
}
private async initCore () {
const urlParts = window.location.href.split('/')
const lastPart = urlParts[ urlParts.length - 1 ]
const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[ 0 ]
const urlParts = window.location.pathname.split('/')
const videoId = urlParts[ urlParts.length - 1 ]
const [ , serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
loadLocaleInVideoJS(window.location.origin, vjs, navigator.language),

File diff suppressed because it is too large Load Diff

View File

@ -43,19 +43,18 @@
"dev:server": "scripty",
"dev:client": "scripty",
"start": "node dist/server",
"start:server": "node dist/server --no-client",
"update-host": "node ./dist/scripts/update-host.js",
"create-transcoding-job": "node ./dist/scripts/create-transcoding-job.js",
"create-import-video-file-job": "node ./dist/scripts/create-import-video-file-job.js",
"test": "scripty",
"help": "scripty",
"generate-api-doc": "scripty",
"generate-cli-doc": "scripty",
"parse-log": "node ./dist/scripts/parse-log.js",
"prune-storage": "node ./dist/scripts/prune-storage.js",
"optimize-old-videos": "node ./dist/scripts/optimize-old-videos.js",
"postinstall": "cd client && yarn install --pure-lockfile",
"tsc": "tsc",
"spectacle-docs": "node_modules/spectacle-docs/bin/spectacle.js",
"commander": "commander",
"ng": "ng",
"nodemon": "nodemon",
@ -71,13 +70,21 @@
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
"pre-commit": "./scripts/openapi-peertube-version.sh && lint-staged"
}
},
"lint-staged": {
"*.scss": [
"sass-lint -c client/.sass-lint.yml",
"git add"
],
"support/doc/api/*.yaml": [
"node ./node_modules/swagger-cli/bin/swagger-cli.js validate support/doc/api/openapi.yaml",
"git add"
],
"server/tools/README.md": [
"npm run generate-cli-doc",
"git add"
]
},
"resolutions": {
@ -88,7 +95,7 @@
"async": "^2.0.0",
"async-lock": "^1.1.2",
"async-lru": "^1.1.1",
"bcrypt": "2",
"bcrypt": "3.0.2",
"bittorrent-tracker": "^9.0.0",
"bluebird": "^3.5.0",
"body-parser": "^1.12.4",
@ -113,7 +120,7 @@
"http-signature": "^1.2.0",
"ip-anonymize": "^0.0.6",
"ipaddr.js": "1.8.1",
"is-cidr": "^2.0.5",
"is-cidr": "^3.0.0",
"iso-639-3": "^1.0.1",
"js-yaml": "^3.5.4",
"jsonld": "^1.0.1",
@ -138,9 +145,9 @@
"request": "^2.81.0",
"safe-buffer": "^5.0.1",
"scripty": "^1.5.0",
"sequelize": "4.38.0",
"sequelize": "4.41.2",
"sequelize-typescript": "0.6.6",
"sharp": "^0.20.0",
"sharp": "^0.21.0",
"srt-to-vtt": "^1.1.2",
"summon-install": "^0.4.3",
"useragent": "^2.3.0",
@ -155,7 +162,7 @@
"devDependencies": {
"@types/async": "^2.0.40",
"@types/async-lock": "^1.1.0",
"@types/bcrypt": "^2.0.0",
"@types/bcrypt": "^3.0.0",
"@types/bluebird": "3.5.21",
"@types/body-parser": "^1.16.3",
"@types/bull": "^3.3.12",
@ -183,7 +190,7 @@
"@types/pem": "^1.9.3",
"@types/redis": "^2.8.5",
"@types/request": "^2.0.3",
"@types/sharp": "^0.17.6",
"@types/sharp": "^0.21.0",
"@types/supertest": "^2.0.3",
"@types/validator": "^9.4.0",
"@types/webtorrent": "^0.98.4",
@ -192,19 +199,19 @@
"chai-json-schema": "^1.5.0",
"chai-xml": "^0.3.2",
"husky": "^1.0.0-rc.4",
"libxmljs": "0.19.3",
"lint-staged": "^7.1.0",
"libxmljs": "0.19.5",
"lint-staged": "^8.0.4",
"maildev": "^1.0.0-rc3",
"mocha": "^5.0.0",
"nodemon": "^1.11.0",
"sass-lint": "^1.12.1",
"source-map-support": "^0.5.0",
"spectacle-docs": "^1.0.2",
"supertest": "^3.0.0",
"swagger-cli": "^2.2.0",
"ts-node": "7.0.1",
"tslint": "^5.7.0",
"tslint-config-standard": "^8.0.1",
"typescript": "^2.5.2",
"typescript": "^3.1.6",
"xliff": "^4.0.0"
},
"scripty": {

View File

@ -1,5 +0,0 @@
#!/bin/sh
set -eu
npm run spectacle-docs -- -t support/doc/api/html support/doc/api/openapi.yaml

View File

@ -0,0 +1,4 @@
# Version key/value should be on his own line
PACKAGE_VERSION=$(node -p "require('./package.json').version")
sed -i "s/\(^\s*\)version: .*/\1version: $PACKAGE_VERSION/" support/doc/api/openapi.yaml

View File

@ -12,7 +12,6 @@ killall -q peertube || true
if [ "$1" = "misc" ]; then
npm run build -- --light-fr
mocha --timeout 5000 --exit --require ts-node/register/type-check --bail server/tests/client.ts \
server/tests/activitypub.ts \
server/tests/feeds/index.ts \
server/tests/misc-endpoints.ts \
server/tests/helpers/index.ts
@ -31,7 +30,7 @@ elif [ "$1" = "api-2" ]; then
elif [ "$1" = "api-3" ]; then
npm run build:server
mocha --timeout 5000 --exit --require ts-node/register/type-check --bail server/tests/api/index-3.ts
elif [ "$1" = "api-3" ]; then
elif [ "$1" = "api-4" ]; then
npm run build:server
mocha --timeout 5000 --exit --require ts-node/register/type-check --bail server/tests/api/index-4.ts
elif [ "$1" = "lint" ]; then

View File

@ -4,7 +4,7 @@ import { VideoModel } from '../server/models/video/video'
import { ActorModel } from '../server/models/activitypub/actor'
import {
getAccountActivityPubUrl,
getAnnounceActivityPubUrl,
getVideoAnnounceActivityPubUrl,
getVideoActivityPubUrl, getVideoChannelActivityPubUrl,
getVideoCommentActivityPubUrl
} from '../server/lib/activitypub'
@ -78,7 +78,7 @@ async function run () {
console.log('Updating video share ' + videoShare.url)
videoShare.url = getAnnounceActivityPubUrl(videoShare.Video.url, videoShare.Actor)
videoShare.url = getVideoAnnounceActivityPubUrl(videoShare.Actor, videoShare.Video)
await videoShare.save()
}

View File

@ -16,6 +16,7 @@ import * as cookieParser from 'cookie-parser'
import * as helmet from 'helmet'
import * as useragent from 'useragent'
import * as anonymize from 'ip-anonymize'
import * as cli from 'commander'
process.title = 'peertube'
@ -27,7 +28,7 @@ import { checkMissedConfig, checkFFmpeg } from './server/initializers/checker-be
// Do not use barrels because we don't want to load all modules here (we need to initialize database first)
import { logger } from './server/helpers/logger'
import { API_VERSION, CONFIG, CACHE } from './server/initializers/constants'
import { API_VERSION, CONFIG, CACHE, HTTP_SIGNATURE } from './server/initializers/constants'
const missed = checkMissedConfig()
if (missed.length !== 0) {
@ -95,9 +96,14 @@ import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-
import { UpdateVideosScheduler } from './server/lib/schedulers/update-videos-scheduler'
import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-update-scheduler'
import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler'
import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto'
// ----------- Command line -----------
cli
.option('--no-client', 'Start PeerTube without client interface')
.parse(process.argv)
// ----------- App -----------
// Enable CORS for develop
@ -126,7 +132,11 @@ app.use(morgan('combined', {
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json({
type: [ 'application/json', 'application/*+json' ],
limit: '500kb'
limit: '500kb',
verify: (req: express.Request, _, buf: Buffer, encoding: string) => {
const valid = isHTTPSignatureDigestValid(buf, req)
if (valid !== true) throw new Error('Invalid digest')
}
}))
// Cookies
app.use(cookieParser())
@ -151,7 +161,7 @@ app.use('/', trackerRouter)
app.use('/', staticRouter)
// Client files, last valid routes!
app.use('/', clientsRouter)
if (cli.client) app.use('/', clientsRouter)
// ----------- Errors -----------

View File

@ -3,17 +3,22 @@ import * as express from 'express'
import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos'
import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub'
import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers'
import { buildAnnounceWithVideoAudience } from '../../lib/activitypub/send'
import { buildAnnounceWithVideoAudience, buildDislikeActivity, buildLikeActivity } from '../../lib/activitypub/send'
import { audiencify, getAudience } from '../../lib/activitypub/audience'
import { buildCreateActivity } from '../../lib/activitypub/send/send-create'
import {
asyncMiddleware,
videosShareValidator,
executeIfActivityPub,
localAccountValidator,
localVideoChannelValidator,
videosCustomGetValidator
} from '../../middlewares'
import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators'
import {
getAccountVideoRateValidator,
videoCommentGetValidator,
videosGetValidator
} from '../../middlewares/validators'
import { AccountModel } from '../../models/account/account'
import { ActorModel } from '../../models/activitypub/actor'
import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@ -25,6 +30,7 @@ import { cacheRoute } from '../../middlewares/cache'
import { activityPubResponse } from './utils'
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
import {
getRateUrl,
getVideoCommentsActivityPubUrl,
getVideoDislikesActivityPubUrl,
getVideoLikesActivityPubUrl,
@ -48,6 +54,14 @@ activityPubClientRouter.get('/accounts?/:name/following',
executeIfActivityPub(asyncMiddleware(localAccountValidator)),
executeIfActivityPub(asyncMiddleware(accountFollowingController))
)
activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))),
executeIfActivityPub(getAccountVideoRate('like'))
)
activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId',
executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('dislike'))),
executeIfActivityPub(getAccountVideoRate('dislike'))
)
activityPubClientRouter.get('/videos/watch/:id',
executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))),
@ -62,7 +76,7 @@ activityPubClientRouter.get('/videos/watch/:id/announces',
executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
executeIfActivityPub(asyncMiddleware(videoAnnouncesController))
)
activityPubClientRouter.get('/videos/watch/:id/announces/:accountId',
activityPubClientRouter.get('/videos/watch/:id/announces/:actorId',
executeIfActivityPub(asyncMiddleware(videosShareValidator)),
executeIfActivityPub(asyncMiddleware(videoAnnounceController))
)
@ -133,6 +147,20 @@ async function accountFollowingController (req: express.Request, res: express.Re
return activityPubResponse(activityPubContextify(activityPubResult), res)
}
function getAccountVideoRate (rateType: VideoRateType) {
return (req: express.Request, res: express.Response) => {
const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate
const byActor = accountVideoRate.Account.Actor
const url = getRateUrl(rateType, byActor, accountVideoRate.Video)
const APObject = rateType === 'like'
? buildLikeActivity(url, byActor, accountVideoRate.Video)
: buildCreateActivity(url, byActor, buildDislikeActivity(url, byActor, accountVideoRate.Video))
return activityPubResponse(activityPubContextify(APObject), res)
}
}
async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
const video: VideoModel = res.locals.video
@ -276,7 +304,7 @@ function videoRates (req: express.Request, rateType: VideoRateType, video: Video
const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
return {
total: result.count,
data: result.rows.map(r => r.Account.Actor.url)
data: result.rows.map(r => r.url)
}
}
return activityPubCollectionPagination(url, handler, req.query.page)

View File

@ -43,11 +43,13 @@ export {
// ---------------------------------------------------------------------------
const inboxQueue = queue<{ activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel }, Error>((task, cb) => {
processActivities(task.activities, task.signatureActor, task.inboxActor)
const options = { signatureActor: task.signatureActor, inboxActor: task.inboxActor }
processActivities(task.activities, options)
.then(() => cb())
})
function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
function inboxController (req: express.Request, res: express.Response) {
const rootActivity: RootActivity = req.body
let activities: Activity[] = []

View File

@ -405,7 +405,11 @@ async function viewVideo (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
await sendCreateView(serverActor, videoInstance, undefined)
// Send the event to the origin server
// If we own the video, we'll send an update event when we'll process the views (in our job queue)
if (videoInstance.isOwned() === false) {
await sendCreateView(serverActor, videoInstance, undefined)
}
return res.status(204).end()
}

View File

@ -2,8 +2,8 @@ import * as express from 'express'
import { UserVideoRateUpdate } from '../../../../shared'
import { logger } from '../../../helpers/logger'
import { sequelizeTypescript, VIDEO_RATE_TYPES } from '../../../initializers'
import { sendVideoRateChange } from '../../../lib/activitypub'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoRateValidator } from '../../../middlewares'
import { getRateUrl, sendVideoRateChange } from '../../../lib/activitypub'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoUpdateRateValidator } from '../../../middlewares'
import { AccountModel } from '../../../models/account/account'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { VideoModel } from '../../../models/video/video'
@ -12,7 +12,7 @@ const rateVideoRouter = express.Router()
rateVideoRouter.put('/:id/rate',
authenticate,
asyncMiddleware(videoRateValidator),
asyncMiddleware(videoUpdateRateValidator),
asyncRetryTransactionMiddleware(rateVideo)
)
@ -28,11 +28,12 @@ async function rateVideo (req: express.Request, res: express.Response) {
const body: UserVideoRateUpdate = req.body
const rateType = body.rating
const videoInstance: VideoModel = res.locals.video
const userAccount: AccountModel = res.locals.oauth.token.User.Account
await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = { transaction: t }
const accountInstance = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
const accountInstance = await AccountModel.load(userAccount.id, t)
const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t)
let likesToIncrement = 0
@ -44,20 +45,22 @@ async function rateVideo (req: express.Request, res: express.Response) {
// There was a previous rate, update it
if (previousRate) {
// We will remove the previous rate, so we will need to update the video count attribute
if (previousRate.type === VIDEO_RATE_TYPES.LIKE) likesToIncrement--
else if (previousRate.type === VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement--
if (previousRate.type === 'like') likesToIncrement--
else if (previousRate.type === 'dislike') dislikesToIncrement--
if (rateType === 'none') { // Destroy previous rate
await previousRate.destroy(sequelizeOptions)
} else { // Update previous rate
previousRate.type = rateType
previousRate.url = getRateUrl(rateType, userAccount.Actor, videoInstance)
await previousRate.save(sequelizeOptions)
}
} else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate
const query = {
accountId: accountInstance.id,
videoId: videoInstance.id,
type: rateType
type: rateType,
url: getRateUrl(rateType, userAccount.Actor, videoInstance)
}
await AccountVideoRateModel.create(query, sequelizeOptions)

View File

@ -6,6 +6,7 @@ import { ACTIVITY_PUB } from '../initializers'
import { ActorModel } from '../models/activitypub/actor'
import { signJsonLDObject } from './peertube-crypto'
import { pageToStartAndCount } from './core-utils'
import { parse } from 'url'
function activityPubContextify <T> (data: T) {
return Object.assign(data, {
@ -24,7 +25,7 @@ function activityPubContextify <T> (data: T) {
sensitive: 'as:sensitive',
language: 'sc:inLanguage',
views: 'sc:Number',
stats: 'sc:Number',
state: 'sc:Number',
size: 'sc:Number',
fps: 'sc:Number',
commentsEnabled: 'sc:Boolean',
@ -111,9 +112,17 @@ function getActorUrl (activityActor: string | ActivityPubActor) {
return activityActor.id
}
function checkUrlsSameHost (url1: string, url2: string) {
const idHost = parse(url1).host
const actorHost = parse(url2).host
return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
}
// ---------------------------------------------------------------------------
export {
checkUrlsSameHost,
getActorUrl,
activityPubContextify,
activityPubCollectionPagination,

View File

@ -1,5 +1,5 @@
import * as AsyncLRU from 'async-lru'
import * as jsonld from 'jsonld/'
import * as jsonld from 'jsonld'
import * as jsig from 'jsonld-signatures'
const nodeDocumentLoader = jsonld.documentLoaders.node()
@ -17,4 +17,4 @@ jsonld.documentLoader = (url, cb) => {
jsig.use('jsonld', jsonld)
export { jsig }
export { jsig, jsonld }

View File

@ -310,6 +310,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
.outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution
.outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it
.outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
.outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
.outputOption('-map_metadata -1') // strip all metadata
.outputOption('-movflags faststart')

View File

@ -1,9 +1,12 @@
import { Request } from 'express'
import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers'
import { ActorModel } from '../models/activitypub/actor'
import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey } from './core-utils'
import { jsig } from './custom-jsonld-signature'
import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey, sha256 } from './core-utils'
import { jsig, jsonld } from './custom-jsonld-signature'
import { logger } from './logger'
import { cloneDeep } from 'lodash'
import { createVerify } from 'crypto'
import { buildDigest } from '../lib/job-queue/handlers/utils/activitypub-http-utils'
const httpSignature = require('http-signature')
@ -30,21 +33,36 @@ async function cryptPassword (password: string) {
// HTTP Signature
function isHTTPSignatureVerified (httpSignatureParsed: any, actor: ActorModel) {
function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
return buildDigest(rawBody.toString()) === req.headers['digest']
}
return true
}
function isHTTPSignatureVerified (httpSignatureParsed: any, actor: ActorModel): boolean {
return httpSignature.verifySignature(httpSignatureParsed, actor.publicKey) === true
}
function parseHTTPSignature (req: Request) {
return httpSignature.parse(req, { authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME })
function parseHTTPSignature (req: Request, clockSkew?: number) {
return httpSignature.parse(req, { authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, clockSkew })
}
// JSONLD
function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any) {
async function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any): Promise<boolean> {
if (signedDocument.signature.type === 'RsaSignature2017') {
// Mastodon algorithm
const res = await isJsonLDRSA2017Verified(fromActor, signedDocument)
// Success? If no, try with our library
if (res === true) return true
}
const publicKeyObject = {
'@context': jsig.SECURITY_CONTEXT_URL,
id: fromActor.url,
type: 'CryptographicKey',
type: 'CryptographicKey',
owner: fromActor.url,
publicKeyPem: fromActor.publicKey
}
@ -69,6 +87,44 @@ function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any)
})
}
// Backward compatibility with "other" implementations
async function isJsonLDRSA2017Verified (fromActor: ActorModel, signedDocument: any) {
function hash (obj: any): Promise<any> {
return jsonld.promises
.normalize(obj, {
algorithm: 'URDNA2015',
format: 'application/n-quads'
})
.then(res => sha256(res))
}
const signatureCopy = cloneDeep(signedDocument.signature)
Object.assign(signatureCopy, {
'@context': [
'https://w3id.org/security/v1',
{ RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' }
]
})
delete signatureCopy.type
delete signatureCopy.id
delete signatureCopy.signatureValue
const docWithoutSignature = cloneDeep(signedDocument)
delete docWithoutSignature.signature
const [ documentHash, optionsHash ] = await Promise.all([
hash(docWithoutSignature),
hash(signatureCopy)
])
const toVerify = optionsHash + documentHash
const verify = createVerify('RSA-SHA256')
verify.update(toVerify, 'utf8')
return verify.verify(fromActor.publicKey, signedDocument.signature.signatureValue, 'base64')
}
function signJsonLDObject (byActor: ActorModel, data: any) {
const options = {
privateKeyPem: byActor.privateKey,
@ -82,6 +138,7 @@ function signJsonLDObject (byActor: ActorModel, data: any) {
// ---------------------------------------------------------------------------
export {
isHTTPSignatureDigestValid,
parseHTTPSignature,
isHTTPSignatureVerified,
isJsonLDSignatureVerified,

View File

@ -3,7 +3,7 @@ import { createWriteStream } from 'fs-extra'
import * as request from 'request'
import { ACTIVITY_PUB } from '../initializers'
function doRequest (
function doRequest <T> (
requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean }
): Bluebird<{ response: request.RequestResponse, body: any }> {
if (requestOptions.activityPub === true) {
@ -11,7 +11,7 @@ function doRequest (
requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER
}
return new Bluebird<{ response: request.RequestResponse, body: any }>((res, rej) => {
return new Bluebird<{ response: request.RequestResponse, body: T }>((res, rej) => {
request(requestOptions, (err, response, body) => err ? rej(err) : res({ response, body }))
})
}

View File

@ -16,7 +16,7 @@ let config: IConfig = require('config')
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 285
const LAST_MIGRATION_VERSION = 290
// ---------------------------------------------------------------------------
@ -336,6 +336,9 @@ const CONSTRAINTS_FIELDS = {
VIDEOS_REDUNDANCY: {
URL: { min: 3, max: 2000 } // Length
},
VIDEO_RATES: {
URL: { min: 3, max: 2000 } // Length
},
VIDEOS: {
NAME: { min: 3, max: 120 }, // Length
LANGUAGE: { min: 1, max: 10 }, // Length
@ -535,7 +538,7 @@ const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = {
const HTTP_SIGNATURE = {
HEADER_NAME: 'signature',
ALGORITHM: 'rsa-sha256',
HEADERS_TO_SIGN: [ 'date', 'host', 'digest', '(request-target)' ]
HEADERS_TO_SIGN: [ '(request-target)', 'host', 'date', 'digest' ]
}
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,46 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction,
queryInterface: Sequelize.QueryInterface,
sequelize: Sequelize.Sequelize,
db: any
}): Promise<void> {
{
const data = {
type: Sequelize.STRING(2000),
allowNull: true
}
await utils.queryInterface.addColumn('accountVideoRate', 'url', data)
}
{
const builtUrlQuery = `SELECT "actor"."url" || '/' || "accountVideoRate"."type" || 's/' || "videoId" ` +
'FROM "accountVideoRate" ' +
'INNER JOIN account ON account.id = "accountVideoRate"."accountId" ' +
'INNER JOIN actor ON actor.id = account."actorId" ' +
'WHERE "base".id = "accountVideoRate".id'
const query = 'UPDATE "accountVideoRate" base SET "url" = (' + builtUrlQuery + ') WHERE "url" IS NULL'
await utils.sequelize.query(query)
}
{
const data = {
type: Sequelize.STRING(2000),
allowNull: false,
defaultValue: null
}
await utils.queryInterface.changeColumn('accountVideoRate', 'url', data)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -5,7 +5,7 @@ import * as url from 'url'
import * as uuidv4 from 'uuid/v4'
import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub'
import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
import { getActorUrl } from '../../helpers/activitypub'
import { checkUrlsSameHost, getActorUrl } from '../../helpers/activitypub'
import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
@ -65,8 +65,12 @@ async function getOrCreateActorAndServerAndModel (
const accountAttributedTo = result.attributedTo.find(a => a.type === 'Person')
if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actor.url)
if (checkUrlsSameHost(accountAttributedTo.id, actorUrl) !== true) {
throw new Error(`Account attributed to ${accountAttributedTo.id} does not have the same host than actor url ${actorUrl}`)
}
try {
// Assert we don't recurse another time
// Don't recurse another time
ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false)
} catch (err) {
logger.error('Cannot get or create account attributed to video channel ' + actor.url)
@ -297,12 +301,15 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
normalizeActor(requestResult.body)
const actorJSON: ActivityPubActor = requestResult.body
if (isActorObjectValid(actorJSON) === false) {
logger.debug('Remote actor JSON is not valid.', { actorJSON: actorJSON })
return { result: undefined, statusCode: requestResult.response.statusCode }
}
if (checkUrlsSameHost(actorJSON.id, actorUrl) !== true) {
throw new Error('Actor url ' + actorUrl + ' has not the same host than its AP id ' + actorJSON.id)
}
const followersCount = await fetchActorTotalItems(actorJSON.followers)
const followingCount = await fetchActorTotalItems(actorJSON.following)

View File

@ -2,6 +2,7 @@ import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers'
import { doRequest } from '../../helpers/requests'
import { logger } from '../../helpers/logger'
import * as Bluebird from 'bluebird'
import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) {
logger.info('Crawling ActivityPub data on %s.', uri)
@ -14,7 +15,7 @@ async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Pr
timeout: JOB_REQUEST_TIMEOUT
}
const response = await doRequest(options)
const response = await doRequest<ActivityPubOrderedCollection<T>>(options)
const firstBody = response.body
let limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT
@ -23,7 +24,7 @@ async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Pr
while (nextLink && i < limit) {
options.uri = nextLink
const { body } = await doRequest(options)
const { body } = await doRequest<ActivityPubOrderedCollection<T>>(options)
nextLink = body.next
i++

View File

@ -1,9 +1 @@
export * from './process'
export * from './process-accept'
export * from './process-announce'
export * from './process-create'
export * from './process-delete'
export * from './process-follow'
export * from './process-like'
export * from './process-undo'
export * from './process-update'

View File

@ -12,6 +12,9 @@ import { getOrCreateVideoAndAccountAndChannel } from '../videos'
import { forwardVideoRelatedActivity } from '../send/utils'
import { Redis } from '../../redis'
import { createOrUpdateCacheFile } from '../cache-file'
import { immutableAssign } from '../../../tests/utils'
import { getVideoDislikeActivityPubUrl } from '../url'
import { VideoModel } from '../../../models/video/video'
async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
const activityObject = activity.object
@ -65,9 +68,10 @@ async function processCreateDislike (byActor: ActorModel, activity: ActivityCrea
videoId: video.id,
accountId: byAccount.id
}
const [ , created ] = await AccountVideoRateModel.findOrCreate({
where: rate,
defaults: rate,
defaults: immutableAssign(rate, { url: getVideoDislikeActivityPubUrl(byActor, video) }),
transaction: t
})
if (created === true) await video.increment('dislikes', { transaction: t })
@ -84,19 +88,10 @@ async function processCreateDislike (byActor: ActorModel, activity: ActivityCrea
async function processCreateView (byActor: ActorModel, activity: ActivityCreate) {
const view = activity.object as ViewObject
const options = {
videoObject: view.object,
fetchType: 'only-video' as 'only-video'
}
const { video } = await getOrCreateVideoAndAccountAndChannel(options)
const video = await VideoModel.loadByUrl(view.object)
if (!video || video.isOwned() === false) return
await Redis.Instance.addVideoView(video.id)
if (video.isOwned()) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
}
}
async function processCacheFile (byActor: ActorModel, activity: ActivityCreate) {

View File

@ -5,6 +5,8 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
import { ActorModel } from '../../../models/activitypub/actor'
import { forwardVideoRelatedActivity } from '../send/utils'
import { getOrCreateVideoAndAccountAndChannel } from '../videos'
import { immutableAssign } from '../../../tests/utils'
import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url'
async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) {
return retryTransactionWrapper(processLikeVideo, byActor, activity)
@ -34,7 +36,7 @@ async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) {
}
const [ , created ] = await AccountVideoRateModel.findOrCreate({
where: rate,
defaults: rate,
defaults: immutableAssign(rate, { url: getVideoLikeActivityPubUrl(byActor, video) }),
transaction: t
})
if (created === true) await video.increment('likes', { transaction: t })

View File

@ -55,7 +55,8 @@ async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) {
return sequelizeTypescript.transaction(async t => {
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
let rate = await AccountVideoRateModel.loadByUrl(likeActivity.id, t)
if (!rate) rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
await rate.destroy({ transaction: t })
@ -78,7 +79,8 @@ async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo)
return sequelizeTypescript.transaction(async t => {
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
let rate = await AccountVideoRateModel.loadByUrl(dislike.id, t)
if (!rate) rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
await rate.destroy({ transaction: t })

View File

@ -1,5 +1,5 @@
import { Activity, ActivityType } from '../../../../shared/models/activitypub'
import { getActorUrl } from '../../../helpers/activitypub'
import { checkUrlsSameHost, getActorUrl } from '../../../helpers/activitypub'
import { logger } from '../../../helpers/logger'
import { ActorModel } from '../../../models/activitypub/actor'
import { processAcceptActivity } from './process-accept'
@ -25,11 +25,17 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: Ac
Like: processLikeActivity
}
async function processActivities (activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel) {
async function processActivities (
activities: Activity[],
options: {
signatureActor?: ActorModel
inboxActor?: ActorModel
outboxUrl?: string
} = {}) {
const actorsCache: { [ url: string ]: ActorModel } = {}
for (const activity of activities) {
if (!signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) {
if (!options.signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) {
logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
continue
}
@ -37,12 +43,17 @@ async function processActivities (activities: Activity[], signatureActor?: Actor
const actorUrl = getActorUrl(activity.actor)
// When we fetch remote data, we don't have signature
if (signatureActor && actorUrl !== signatureActor.url) {
logger.warn('Signature mismatch between %s and %s.', actorUrl, signatureActor.url)
if (options.signatureActor && actorUrl !== options.signatureActor.url) {
logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, options.signatureActor.url)
continue
}
const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl)
if (options.outboxUrl && checkUrlsSameHost(options.outboxUrl, actorUrl) !== true) {
logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', options.outboxUrl, actorUrl)
continue
}
const byActor = options.signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl)
actorsCache[actorUrl] = byActor
const activityProcessor = processActivity[activity.type]
@ -52,7 +63,7 @@ async function processActivities (activities: Activity[], signatureActor?: Actor
}
try {
await activityProcessor(activity, byActor, inboxActor)
await activityProcessor(activity, byActor, options.inboxActor)
} catch (err) {
logger.warn('Cannot process activity %s.', activity.type, { err })
}

View File

@ -95,7 +95,7 @@ async function sendCreateView (byActor: ActorModel, video: VideoModel, t: Transa
logger.info('Creating job to send view of %s.', video.url)
const url = getVideoViewActivityPubUrl(byActor, video)
const viewActivity = buildViewActivity(byActor, video)
const viewActivity = buildViewActivity(url, byActor, video)
return sendVideoRelatedCreateActivity({
// Use the server actor to send the view
@ -111,7 +111,7 @@ async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Tra
logger.info('Creating job to dislike %s.', video.url)
const url = getVideoDislikeActivityPubUrl(byActor, video)
const dislikeActivity = buildDislikeActivity(byActor, video)
const dislikeActivity = buildDislikeActivity(url, byActor, video)
return sendVideoRelatedCreateActivity({
byActor,
@ -136,16 +136,18 @@ function buildCreateActivity (url: string, byActor: ActorModel, object: any, aud
)
}
function buildDislikeActivity (byActor: ActorModel, video: VideoModel) {
function buildDislikeActivity (url: string, byActor: ActorModel, video: VideoModel) {
return {
id: url,
type: 'Dislike',
actor: byActor.url,
object: video.url
}
}
function buildViewActivity (byActor: ActorModel, video: VideoModel) {
function buildViewActivity (url: string, byActor: ActorModel, video: VideoModel) {
return {
id: url,
type: 'View',
actor: byActor.url,
object: video.url

View File

@ -24,8 +24,8 @@ function buildLikeActivity (url: string, byActor: ActorModel, video: VideoModel,
return audiencify(
{
type: 'Like' as 'Like',
id: url,
type: 'Like' as 'Like',
actor: byActor.url,
object: video.url
},

View File

@ -64,7 +64,7 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans
logger.info('Creating job to undo a dislike of video %s.', video.url)
const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video)
const dislikeActivity = buildDislikeActivity(byActor, video)
const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
const createDislikeActivity = buildCreateActivity(dislikeUrl, byActor, dislikeActivity)
return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: createDislikeActivity, transaction: t })

View File

@ -4,13 +4,14 @@ import { getServerActor } from '../../helpers/utils'
import { VideoModel } from '../../models/video/video'
import { VideoShareModel } from '../../models/video/video-share'
import { sendUndoAnnounce, sendVideoAnnounce } from './send'
import { getAnnounceActivityPubUrl } from './url'
import { getVideoAnnounceActivityPubUrl } from './url'
import { VideoChannelModel } from '../../models/video/video-channel'
import * as Bluebird from 'bluebird'
import { doRequest } from '../../helpers/requests'
import { getOrCreateActorAndServerAndModel } from './actor'
import { logger } from '../../helpers/logger'
import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers'
import { checkUrlsSameHost, getActorUrl } from '../../helpers/activitypub'
async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) {
if (video.privacy === VideoPrivacy.PRIVATE) return undefined
@ -38,9 +39,13 @@ async function addVideoShares (shareUrls: string[], instance: VideoModel) {
json: true,
activityPub: true
})
if (!body || !body.actor) throw new Error('Body of body actor is invalid')
if (!body || !body.actor) throw new Error('Body or body actor is invalid')
const actorUrl = getActorUrl(body.actor)
if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
}
const actorUrl = body.actor
const actor = await getOrCreateActorAndServerAndModel(actorUrl)
const entry = {
@ -72,7 +77,7 @@ export {
async function shareByServer (video: VideoModel, t: Transaction) {
const serverActor = await getServerActor()
const serverShareUrl = getAnnounceActivityPubUrl(video.url, serverActor)
const serverShareUrl = getVideoAnnounceActivityPubUrl(serverActor, video)
return VideoShareModel.findOrCreate({
defaults: {
actorId: serverActor.id,
@ -91,7 +96,7 @@ async function shareByServer (video: VideoModel, t: Transaction) {
}
async function shareByVideoChannel (video: VideoModel, t: Transaction) {
const videoChannelShareUrl = getAnnounceActivityPubUrl(video.url, video.VideoChannel.Actor)
const videoChannelShareUrl = getVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
return VideoShareModel.findOrCreate({
defaults: {
actorId: video.VideoChannel.actorId,

View File

@ -33,14 +33,14 @@ function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) {
}
function getVideoViewActivityPubUrl (byActor: ActorModel, video: VideoModel) {
return video.url + '/views/' + byActor.uuid + '/' + new Date().toISOString()
return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString()
}
function getVideoLikeActivityPubUrl (byActor: ActorModel, video: VideoModel) {
function getVideoLikeActivityPubUrl (byActor: ActorModel, video: VideoModel | { id: number }) {
return byActor.url + '/likes/' + video.id
}
function getVideoDislikeActivityPubUrl (byActor: ActorModel, video: VideoModel) {
function getVideoDislikeActivityPubUrl (byActor: ActorModel, video: VideoModel | { id: number }) {
return byActor.url + '/dislikes/' + video.id
}
@ -74,8 +74,8 @@ function getActorFollowAcceptActivityPubUrl (actorFollow: ActorFollowModel) {
return follower.url + '/accepts/follows/' + me.id
}
function getAnnounceActivityPubUrl (originalUrl: string, byActor: ActorModel) {
return originalUrl + '/announces/' + byActor.id
function getVideoAnnounceActivityPubUrl (byActor: ActorModel, video: VideoModel) {
return video.url + '/announces/' + byActor.id
}
function getDeleteActivityPubUrl (originalUrl: string) {
@ -97,7 +97,7 @@ export {
getVideoAbuseActivityPubUrl,
getActorFollowActivityPubUrl,
getActorFollowAcceptActivityPubUrl,
getAnnounceActivityPubUrl,
getVideoAnnounceActivityPubUrl,
getUpdateActivityPubUrl,
getUndoActivityPubUrl,
getVideoViewActivityPubUrl,

View File

@ -9,6 +9,7 @@ import { VideoCommentModel } from '../../models/video/video-comment'
import { getOrCreateActorAndServerAndModel } from './actor'
import { getOrCreateVideoAndAccountAndChannel } from './videos'
import * as Bluebird from 'bluebird'
import { checkUrlsSameHost } from '../../helpers/activitypub'
async function videoCommentActivityObjectToDBAttributes (video: VideoModel, actor: ActorModel, comment: VideoCommentObject) {
let originCommentId: number = null
@ -61,6 +62,14 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
const actorUrl = body.attributedTo
if (!actorUrl) return { created: false }
if (checkUrlsSameHost(commentUrl, actorUrl) !== true) {
throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${commentUrl}`)
}
if (checkUrlsSameHost(body.id, commentUrl) !== true) {
throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`)
}
const actor = await getOrCreateActorAndServerAndModel(actorUrl)
const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body)
if (!entry) return { created: false }
@ -134,6 +143,14 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []) {
const actorUrl = body.attributedTo
if (!actorUrl) throw new Error('Miss attributed to in comment')
if (checkUrlsSameHost(url, actorUrl) !== true) {
throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`)
}
if (checkUrlsSameHost(body.id, url) !== true) {
throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`)
}
const actor = await getOrCreateActorAndServerAndModel(actorUrl)
const comment = new VideoCommentModel({
url: body.id,

View File

@ -8,13 +8,35 @@ import { getOrCreateActorAndServerAndModel } from './actor'
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
import { logger } from '../../helpers/logger'
import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers'
import { doRequest } from '../../helpers/requests'
import { checkUrlsSameHost, getActorUrl } from '../../helpers/activitypub'
import { ActorModel } from '../../models/activitypub/actor'
import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from './url'
async function createRates (actorUrls: string[], video: VideoModel, rate: VideoRateType) {
async function createRates (ratesUrl: string[], video: VideoModel, rate: VideoRateType) {
let rateCounts = 0
await Bluebird.map(actorUrls, async actorUrl => {
await Bluebird.map(ratesUrl, async rateUrl => {
try {
// Fetch url
const { body } = await doRequest({
uri: rateUrl,
json: true,
activityPub: true
})
if (!body || !body.actor) throw new Error('Body or body actor is invalid')
const actorUrl = getActorUrl(body.actor)
if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
}
if (checkUrlsSameHost(body.id, rateUrl) !== true) {
throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`)
}
const actor = await getOrCreateActorAndServerAndModel(actorUrl)
const [ , created ] = await AccountVideoRateModel
.findOrCreate({
where: {
@ -24,13 +46,14 @@ async function createRates (actorUrls: string[], video: VideoModel, rate: VideoR
defaults: {
videoId: video.id,
accountId: actor.Account.id,
type: rate
type: rate,
url: body.id
}
})
if (created) rateCounts += 1
} catch (err) {
logger.warn('Cannot add rate %s for actor %s.', rate, actorUrl, { err })
logger.warn('Cannot add rate %s.', rateUrl, { err })
}
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
@ -62,7 +85,12 @@ async function sendVideoRateChange (account: AccountModel,
if (dislikes > 0) await sendCreateDislike(actor, video, t)
}
function getRateUrl (rateType: VideoRateType, actor: ActorModel, video: VideoModel) {
return rateType === 'like' ? getVideoLikeActivityPubUrl(actor, video) : getVideoDislikeActivityPubUrl(actor, video)
}
export {
getRateUrl,
createRates,
sendVideoRateChange
}

View File

@ -29,6 +29,7 @@ import { createRates } from './video-rates'
import { addVideoShares, shareVideoByServerAndChannel } from './share'
import { AccountModel } from '../../models/account/account'
import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
import { checkUrlsSameHost } from '../../helpers/activitypub'
async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it
@ -63,7 +64,7 @@ async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.
const { response, body } = await doRequest(options)
if (sanitizeAndCheckVideoTorrentObject(body) === false) {
if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
logger.debug('Remote video JSON is not valid.', { body })
return { response, videoObject: undefined }
}
@ -107,6 +108,10 @@ function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject
const channel = videoObject.attributedTo.find(a => a.type === 'Group')
if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
}
return getOrCreateActorAndServerAndModel(channel.id, 'all')
}

View File

@ -23,7 +23,7 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
'activity': items => processActivities(items),
'activity': items => processActivities(items, { outboxUrl: payload.uri }),
'video-likes': items => createRates(items, video, 'like'),
'video-dislikes': items => createRates(items, video, 'dislike'),
'video-shares': items => addVideoShares(items, video),

View File

@ -38,15 +38,20 @@ async function buildSignedRequestOptions (payload: Payload) {
}
}
function buildGlobalHeaders (body: object) {
const digest = 'SHA-256=' + sha256(JSON.stringify(body), 'base64')
function buildGlobalHeaders (body: any) {
return {
'Digest': digest
'Digest': buildDigest(body)
}
}
function buildDigest (body: any) {
const rawBody = typeof body === 'string' ? body : JSON.stringify(body)
return 'SHA-256=' + sha256(rawBody, 'base64')
}
export {
buildDigest,
buildGlobalHeaders,
computeBody,
buildSignedRequestOptions

View File

@ -3,8 +3,9 @@ import { logger } from '../../../helpers/logger'
import { VideoModel } from '../../../models/video/video'
import { VideoViewModel } from '../../../models/video/video-views'
import { isTestInstance } from '../../../helpers/core-utils'
import { federateVideoIfNeeded } from '../../activitypub'
async function processVideosViewsViews () {
async function processVideosViews () {
const lastHour = new Date()
// In test mode, we run this function multiple times per hour, so we don't want the values of the previous hour
@ -36,6 +37,9 @@ async function processVideosViewsViews () {
views,
videoId
})
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
if (video.isOwned()) await federateVideoIfNeeded(video, false)
} catch (err) {
logger.debug('Cannot create video views for video %d in hour %d. Maybe the video does not exist anymore?', videoId, hour)
}
@ -51,5 +55,5 @@ async function processVideosViewsViews () {
// ---------------------------------------------------------------------------
export {
processVideosViewsViews
processVideosViews
}

View File

@ -10,7 +10,7 @@ import { EmailPayload, processEmail } from './handlers/email'
import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file'
import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow'
import { processVideoImport, VideoImportPayload } from './handlers/video-import'
import { processVideosViewsViews } from './handlers/video-views'
import { processVideosViews } from './handlers/video-views'
type CreateJobArgument =
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@ -32,7 +32,7 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
'video-file': processVideoFile,
'email': processEmail,
'video-import': processVideoImport,
'videos-views': processVideosViewsViews
'videos-views': processVideosViews
}
const jobTypes: JobType[] = [

View File

@ -185,11 +185,12 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
}
private async isTooHeavy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) {
const maxSize = redundancy.size - this.getTotalFileSizes(filesToDuplicate)
const maxSize = redundancy.size
const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(redundancy.strategy)
const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(filesToDuplicate)
return totalDuplicated > maxSize
return totalWillDuplicate > maxSize
}
private buildNewExpiration (expiresAfterMs: number) {

View File

@ -53,7 +53,8 @@ function executeIfActivityPub (fun: RequestHandler | RequestHandler[]) {
export {
checkSignature,
executeIfActivityPub
executeIfActivityPub,
checkHttpSignature
}
// ---------------------------------------------------------------------------
@ -94,7 +95,7 @@ async function checkHttpSignature (req: Request, res: Response) {
async function checkJsonLDSignature (req: Request, res: Response) {
const signatureObject: ActivityPubSignature = req.body.signature
if (!signatureObject.creator) {
if (!signatureObject || !signatureObject.creator) {
res.sendStatus(403)
return false
}

View File

@ -5,4 +5,6 @@ export * from './video-channels'
export * from './video-comments'
export * from './video-imports'
export * from './video-watch'
export * from './video-rates'
export * from './video-shares'
export * from './videos'

View File

@ -0,0 +1,55 @@
import * as express from 'express'
import 'express-validator'
import { body, param } from 'express-validator/check'
import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
import { isVideoExist, isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
import { logger } from '../../../helpers/logger'
import { areValidationErrors } from '../utils'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { VideoRateType } from '../../../../shared/models/videos'
import { isAccountNameValid } from '../../../helpers/custom-validators/accounts'
const videoUpdateRateValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoRate parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.id, res)) return
return next()
}
]
const getAccountVideoRateValidator = function (rateType: VideoRateType) {
return [
param('name').custom(isAccountNameValid).withMessage('Should have a valid account name'),
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params })
if (areValidationErrors(req, res)) return
const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.name, req.params.videoId)
if (!rate) {
return res.status(404)
.json({ error: 'Video rate not found' })
.end()
}
res.locals.accountVideoRate = rate
return next()
}
]
}
// ---------------------------------------------------------------------------
export {
videoUpdateRateValidator,
getAccountVideoRateValidator
}

View File

@ -0,0 +1,38 @@
import * as express from 'express'
import 'express-validator'
import { param } from 'express-validator/check'
import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
import { isVideoExist } from '../../../helpers/custom-validators/videos'
import { logger } from '../../../helpers/logger'
import { VideoShareModel } from '../../../models/video/video-share'
import { areValidationErrors } from '../utils'
import { VideoModel } from '../../../models/video/video'
const videosShareValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
param('actorId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid actor id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoShare parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.id, res)) return
const video: VideoModel = res.locals.video
const share = await VideoShareModel.load(req.params.actorId, video.id)
if (!share) {
return res.status(404)
.end()
}
res.locals.videoShare = share
return next()
}
]
// ---------------------------------------------------------------------------
export {
videosShareValidator
}

View File

@ -26,14 +26,12 @@ import {
isVideoLicenceValid,
isVideoNameValid,
isVideoPrivacyValid,
isVideoRatingTypeValid,
isVideoSupportValid,
isVideoTagsValid
} from '../../../helpers/custom-validators/videos'
import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
import { logger } from '../../../helpers/logger'
import { CONSTRAINTS_FIELDS } from '../../../initializers'
import { VideoShareModel } from '../../../models/video/video-share'
import { authenticate } from '../../oauth'
import { areValidationErrors } from '../utils'
import { cleanUpReqFiles } from '../../../helpers/express-utils'
@ -188,41 +186,6 @@ const videosRemoveValidator = [
}
]
const videoRateValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoRate parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.id, res)) return
return next()
}
]
const videosShareValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoShare parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.id, res)) return
const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined)
if (!share) {
return res.status(404)
.end()
}
res.locals.videoShare = share
return next()
}
]
const videosChangeOwnershipValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
@ -415,9 +378,6 @@ export {
videosGetValidator,
videosCustomGetValidator,
videosRemoveValidator,
videosShareValidator,
videoRateValidator,
videosChangeOwnershipValidator,
videosTerminateChangeOwnershipValidator,

View File

@ -1,12 +1,14 @@
import { values } from 'lodash'
import { Transaction } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions'
import { VideoRateType } from '../../../shared/models/videos'
import { VIDEO_RATE_TYPES } from '../../initializers'
import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers'
import { VideoModel } from '../video/video'
import { AccountModel } from './account'
import { ActorModel } from '../activitypub/actor'
import { throwIfNotValid } from '../utils'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
/*
Account rates per video.
@ -26,6 +28,10 @@ import { ActorModel } from '../activitypub/actor'
},
{
fields: [ 'videoId', 'type' ]
},
{
fields: [ 'url' ],
unique: true
}
]
})
@ -35,6 +41,11 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
@Column(DataType.ENUM(values(VIDEO_RATE_TYPES)))
type: VideoRateType
@AllowNull(false)
@Is('AccountVideoRateUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_RATES.URL.max))
url: string
@CreatedAt
createdAt: Date
@ -65,7 +76,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
})
Account: AccountModel
static load (accountId: number, videoId: number, transaction: Transaction) {
static load (accountId: number, videoId: number, transaction?: Transaction) {
const options: IFindOptions<AccountVideoRateModel> = {
where: {
accountId,
@ -77,6 +88,49 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
return AccountVideoRateModel.findOne(options)
}
static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) {
const options: IFindOptions<AccountVideoRateModel> = {
where: {
videoId,
type: rateType
},
include: [
{
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: [ 'id', 'url', 'preferredUsername' ],
model: ActorModel.unscoped(),
required: true,
where: {
preferredUsername: accountName
}
}
]
},
{
model: VideoModel.unscoped(),
required: true
}
]
}
if (transaction) options.transaction = transaction
return AccountVideoRateModel.findOne(options)
}
static loadByUrl (url: string, transaction: Transaction) {
const options: IFindOptions<AccountVideoRateModel> = {
where: {
url
}
}
if (transaction) options.transaction = transaction
return AccountVideoRateModel.findOne(options)
}
static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) {
const query = {
offset: start,

View File

@ -47,7 +47,7 @@ enum ScopeNames {
required: true,
include: [
{
attributes: [ 'id' ],
attributes: [ 'id', 'url' ],
model: () => ActorModel.unscoped(),
required: true
}

View File

@ -293,6 +293,11 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
}
return VideoFileModel.sum('size', options as any) // FIXME: typings
.then(v => {
if (!v || isNaN(v)) return 0
return v
})
}
static async listLocalExpired () {

View File

@ -88,7 +88,7 @@ export class VideoShareModel extends Model<VideoShareModel> {
})
Video: VideoModel
static load (actorId: number, videoId: number, t: Sequelize.Transaction) {
static load (actorId: number, videoId: number, t?: Sequelize.Transaction) {
return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
where: {
actorId,

View File

@ -11,6 +11,7 @@ import {
setAccessTokensToServers
} from '../../shared/utils'
const expect = chai.expect
describe('Test activitypub', function () {

View File

@ -0,0 +1,86 @@
/* tslint:disable:no-unused-expression */
import 'mocha'
import {
createUser,
doubleFollow,
flushAndRunMultipleServers,
flushTests,
getVideosListSort,
killallServers,
ServerInfo,
setAccessTokensToServers,
uploadVideo,
userLogin
} from '../../utils'
import * as chai from 'chai'
import { setActorField, setVideoField } from '../../utils/miscs/sql'
import { waitJobs } from '../../utils/server/jobs'
import { Video } from '../../../../shared/models/videos'
const expect = chai.expect
describe('Test ActivityPub fetcher', function () {
let servers: ServerInfo[]
// ---------------------------------------------------------------
before(async function () {
this.timeout(60000)
servers = await flushAndRunMultipleServers(3)
// Get the access tokens
await setAccessTokensToServers(servers)
const user = { username: 'user1', password: 'password' }
for (const server of servers) {
await createUser(server.url, server.accessToken, user.username, user.password)
}
const userAccessToken = await userLogin(servers[0], user)
await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video root' })
const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'bad video root' })
const badVideoUUID = res.body.video.uuid
await uploadVideo(servers[0].url, userAccessToken, { name: 'video user' })
await setActorField(1, 'http://localhost:9001/accounts/user1', 'url', 'http://localhost:9002/accounts/user1')
await setVideoField(1, badVideoUUID, 'url', 'http://localhost:9003/videos/watch/' + badVideoUUID)
})
it('Should add only the video with a valid actor URL', async function () {
this.timeout(60000)
await doubleFollow(servers[0], servers[1])
await waitJobs(servers)
{
const res = await getVideosListSort(servers[0].url, 'createdAt')
expect(res.body.total).to.equal(3)
const data: Video[] = res.body.data
expect(data[0].name).to.equal('video root')
expect(data[1].name).to.equal('bad video root')
expect(data[2].name).to.equal('video user')
}
{
const res = await getVideosListSort(servers[1].url, 'createdAt')
expect(res.body.total).to.equal(1)
const data: Video[] = res.body.data
expect(data[0].name).to.equal('video root')
}
})
after(async function () {
killallServers(servers)
// Keep the logs if the test failed
if (this['ok']) {
await flushTests()
}
})
})

View File

@ -0,0 +1,182 @@
/* tslint:disable:no-unused-expression */
import 'mocha'
import { expect } from 'chai'
import { buildRequestStub } from '../../utils'
import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../../../helpers/peertube-crypto'
import { cloneDeep } from 'lodash'
import { buildSignedActivity } from '../../../helpers/activitypub'
describe('Test activity pub helpers', function () {
describe('When checking the Linked Signature', function () {
it('Should fail with an invalid Mastodon signature', async function () {
const body = require('./json/mastodon/create-bad-signature.json')
const publicKey = require('./json/mastodon/public-key.json').publicKey
const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
const result = await isJsonLDSignatureVerified(fromActor as any, body)
expect(result).to.be.false
})
it('Should fail with an invalid public key', async function () {
const body = require('./json/mastodon/create.json')
const publicKey = require('./json/mastodon/bad-public-key.json').publicKey
const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
const result = await isJsonLDSignatureVerified(fromActor as any, body)
expect(result).to.be.false
})
it('Should succeed with a valid Mastodon signature', async function () {
const body = require('./json/mastodon/create.json')
const publicKey = require('./json/mastodon/public-key.json').publicKey
const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
const result = await isJsonLDSignatureVerified(fromActor as any, body)
expect(result).to.be.true
})
it('Should fail with an invalid PeerTube signature', async function () {
const keys = require('./json/peertube/invalid-keys.json')
const body = require('./json/peertube/announce-without-context.json')
const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
const signedBody = await buildSignedActivity(actorSignature as any, body)
const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' }
const result = await isJsonLDSignatureVerified(fromActor as any, signedBody)
expect(result).to.be.false
})
it('Should fail with an invalid PeerTube URL', async function () {
const keys = require('./json/peertube/keys.json')
const body = require('./json/peertube/announce-without-context.json')
const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
const signedBody = await buildSignedActivity(actorSignature as any, body)
const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9003/accounts/peertube' }
const result = await isJsonLDSignatureVerified(fromActor as any, signedBody)
expect(result).to.be.false
})
it('Should succeed with a valid PeerTube signature', async function () {
const keys = require('./json/peertube/keys.json')
const body = require('./json/peertube/announce-without-context.json')
const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
const signedBody = await buildSignedActivity(actorSignature as any, body)
const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' }
const result = await isJsonLDSignatureVerified(fromActor as any, signedBody)
expect(result).to.be.true
})
})
describe('When checking HTTP signature', function () {
it('Should fail with an invalid http signature', async function () {
const req = buildRequestStub()
req.method = 'POST'
req.url = '/accounts/ronan/inbox'
const mastodonObject = cloneDeep(require('./json/mastodon/bad-http-signature.json'))
req.body = mastodonObject.body
req.headers = mastodonObject.headers
req.headers.signature = 'Signature ' + req.headers.signature
const parsed = parseHTTPSignature(req, 3600 * 365 * 3)
const publicKey = require('./json/mastodon/public-key.json').publicKey
const actor = { publicKey }
const verified = isHTTPSignatureVerified(parsed, actor as any)
expect(verified).to.be.false
})
it('Should fail with an invalid public key', async function () {
const req = buildRequestStub()
req.method = 'POST'
req.url = '/accounts/ronan/inbox'
const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
req.body = mastodonObject.body
req.headers = mastodonObject.headers
req.headers.signature = 'Signature ' + req.headers.signature
const parsed = parseHTTPSignature(req, 3600 * 365 * 3)
const publicKey = require('./json/mastodon/bad-public-key.json').publicKey
const actor = { publicKey }
const verified = isHTTPSignatureVerified(parsed, actor as any)
expect(verified).to.be.false
})
it('Should fail because of clock skew', async function () {
const req = buildRequestStub()
req.method = 'POST'
req.url = '/accounts/ronan/inbox'
const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
req.body = mastodonObject.body
req.headers = mastodonObject.headers
req.headers.signature = 'Signature ' + req.headers.signature
let errored = false
try {
parseHTTPSignature(req)
} catch {
errored = true
}
expect(errored).to.be.true
})
it('Should fail without scheme', async function () {
const req = buildRequestStub()
req.method = 'POST'
req.url = '/accounts/ronan/inbox'
const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
req.body = mastodonObject.body
req.headers = mastodonObject.headers
let errored = false
try {
parseHTTPSignature(req, 3600 * 365 * 3)
} catch {
errored = true
}
expect(errored).to.be.true
})
it('Should succeed with a valid signature', async function () {
const req = buildRequestStub()
req.method = 'POST'
req.url = '/accounts/ronan/inbox'
const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json'))
req.body = mastodonObject.body
req.headers = mastodonObject.headers
req.headers.signature = 'Signature ' + req.headers.signature
const parsed = parseHTTPSignature(req, 3600 * 365 * 3)
const publicKey = require('./json/mastodon/public-key.json').publicKey
const actor = { publicKey }
const verified = isHTTPSignatureVerified(parsed, actor as any)
expect(verified).to.be.true
})
})
})

View File

@ -0,0 +1,4 @@
import './client'
import './fetch'
import './helpers'
import './security'

View File

@ -0,0 +1,93 @@
{
"headers": {
"user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)",
"host": "localhost",
"date": "Mon, 22 Oct 2018 13:34:22 GMT",
"accept-encoding": "gzip",
"digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=",
"content-type": "application/activity+json",
"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==\"",
"content-length": "2815"
},
"body": {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"Hashtag": "as:Hashtag",
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity",
"type": "Create",
"actor": "http://localhost:3000/users/ronan2",
"published": "2018-10-22T13:34:18Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"object": {
"id": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
"type": "Note",
"summary": null,
"inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"published": "2018-10-22T13:34:18Z",
"url": "http://localhost:3000/@ronan2/100939547203370948",
"attributedTo": "http://localhost:3000/users/ronan2",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"sensitive": false,
"atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
"inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
"content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>",
"contentMap": {
"en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>"
},
"attachment": [],
"tag": [
{
"type": "Mention",
"href": "http://localhost:9000/accounts/ronan",
"name": "@ronan@localhost:9000"
}
]
},
"signature": {
"type": "RsaSignature2017",
"creator": "http://localhost:3000/users/ronan2#main-key",
"created": "2018-10-22T13:34:19Z",
"signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q=="
}
}
}

View File

@ -0,0 +1,93 @@
{
"headers": {
"user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)",
"host": "localhost",
"date": "Mon, 22 Oct 2018 13:34:22 GMT",
"accept-encoding": "gzip",
"digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=",
"content-type": "application/activity+json",
"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==\"",
"content-length": "2815"
},
"body": {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"Hashtag": "as:Hashtag",
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity",
"type": "Create",
"actor": "http://localhost:3000/users/ronan2",
"published": "2018-10-22T13:34:18Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"object": {
"id": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
"type": "Note",
"summary": null,
"inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"published": "2018-10-22T13:34:18Z",
"url": "http://localhost:3000/@ronan2/100939547203370948",
"attributedTo": "http://localhost:3000/users/ronan2",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"sensitive": false,
"atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
"inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
"content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>",
"contentMap": {
"en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>"
},
"attachment": [],
"tag": [
{
"type": "Mention",
"href": "http://localhost:9000/accounts/ronan",
"name": "@ronan@localhost:9000"
}
]
},
"signature": {
"type": "RsaSignature2017",
"creator": "http://localhost:3000/users/ronan2#main-key",
"created": "2018-10-22T13:34:19Z",
"signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q=="
}
}
}

View File

@ -0,0 +1,3 @@
{
"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"
}

View File

@ -0,0 +1,81 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"Hashtag": "as:Hashtag",
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity",
"type": "Create",
"actor": "http://localhost:3000/users/ronan2",
"published": "2018-10-22T12:43:07Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"object": {
"id": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
"type": "Note",
"summary": null,
"inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"published": "2018-10-22T12:43:07Z",
"url": "http://localhost:3000/@ronan2/100939345950887698",
"attributedTo": "http://localhost:3000/users/ronan2",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"sensitive": false,
"atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
"inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
"content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>",
"contentMap": {
"en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>"
},
"attachment": [],
"tag": [
{
"type": "Mention",
"href": "http://localhost:9000/accounts/ronan",
"name": "@ronan@localhost:9000"
}
]
},
"signature": {
"type": "RsaSignature2017",
"creator": "http://localhost:3000/users/ronan2#main-key",
"created": "2018-10-22T12:43:08Z",
"signatureValue": "Vgr8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ=="
}
}

View File

@ -0,0 +1,81 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"Hashtag": "as:Hashtag",
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity",
"type": "Create",
"actor": "http://localhost:3000/users/ronan2",
"published": "2018-10-22T12:43:07Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"object": {
"id": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
"type": "Note",
"summary": null,
"inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"published": "2018-10-22T12:43:07Z",
"url": "http://localhost:3000/@ronan2/100939345950887698",
"attributedTo": "http://localhost:3000/users/ronan2",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"sensitive": false,
"atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698",
"inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
"content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>",
"contentMap": {
"en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zerg</p>"
},
"attachment": [],
"tag": [
{
"type": "Mention",
"href": "http://localhost:9000/accounts/ronan",
"name": "@ronan@localhost:9000"
}
]
},
"signature": {
"type": "RsaSignature2017",
"creator": "http://localhost:3000/users/ronan2#main-key",
"created": "2018-10-22T12:43:08Z",
"signatureValue": "VgR8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ=="
}
}

View File

@ -0,0 +1,93 @@
{
"headers": {
"user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)",
"host": "localhost",
"date": "Mon, 22 Oct 2018 13:34:22 GMT",
"accept-encoding": "gzip",
"digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=",
"content-type": "application/activity+json",
"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==\"",
"content-length": "2815"
},
"body": {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"Hashtag": "as:Hashtag",
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity",
"type": "Create",
"actor": "http://localhost:3000/users/ronan2",
"published": "2018-10-22T13:34:18Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"object": {
"id": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
"type": "Note",
"summary": null,
"inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"published": "2018-10-22T13:34:18Z",
"url": "http://localhost:3000/@ronan2/100939547203370948",
"attributedTo": "http://localhost:3000/users/ronan2",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"http://localhost:3000/users/ronan2/followers",
"http://localhost:9000/accounts/ronan"
],
"sensitive": false,
"atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948",
"inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752",
"conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation",
"content": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>",
"contentMap": {
"en": "<p><span class=\"h-card\"><a href=\"http://localhost:9000/accounts/ronan\" class=\"u-url mention\">@<span>ronan</span></a></span> zergzerg</p>"
},
"attachment": [],
"tag": [
{
"type": "Mention",
"href": "http://localhost:9000/accounts/ronan",
"name": "@ronan@localhost:9000"
}
]
},
"signature": {
"type": "RsaSignature2017",
"creator": "http://localhost:3000/users/ronan2#main-key",
"created": "2018-10-22T13:34:19Z",
"signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q=="
}
}
}

View File

@ -0,0 +1,3 @@
{
"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"
}

View File

@ -0,0 +1,13 @@
{
"type": "Announce",
"id": "http://localhost:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05/announces/1",
"actor": "http://localhost:9002/accounts/peertube",
"object": "http://localhost:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05",
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"http://localhost:9002/accounts/peertube/followers",
"http://localhost:9002/video-channels/root_channel/followers",
"http://localhost:9002/accounts/root/followers"
],
"cc": []
}

View File

@ -0,0 +1,6 @@
{
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw2Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n",
"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-----"
}

View File

@ -0,0 +1,4 @@
{
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n",
"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-----"
}

View File

@ -0,0 +1,180 @@
/* tslint:disable:no-unused-expression */
import 'mocha'
import { flushAndRunMultipleServers, flushTests, killallServers, makePOSTAPRequest, makeFollowRequest, ServerInfo } from '../../utils'
import { HTTP_SIGNATURE } from '../../../initializers'
import { buildDigest, buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils'
import * as chai from 'chai'
import { setActorField } from '../../utils/miscs/sql'
import { activityPubContextify, buildSignedActivity } from '../../../helpers/activitypub'
const expect = chai.expect
function setKeysOfServer2 (serverNumber: number, publicKey: string, privateKey: string) {
return Promise.all([
setActorField(serverNumber, 'http://localhost:9002/accounts/peertube', 'publicKey', publicKey),
setActorField(serverNumber, 'http://localhost:9002/accounts/peertube', 'privateKey', privateKey)
])
}
function setKeysOfServer3 (serverNumber: number, publicKey: string, privateKey: string) {
return Promise.all([
setActorField(serverNumber, 'http://localhost:9003/accounts/peertube', 'publicKey', publicKey),
setActorField(serverNumber, 'http://localhost:9003/accounts/peertube', 'privateKey', privateKey)
])
}
describe('Test ActivityPub security', function () {
let servers: ServerInfo[]
let url: string
const keys = require('./json/peertube/keys.json')
const invalidKeys = require('./json/peertube/invalid-keys.json')
const baseHttpSignature = {
algorithm: HTTP_SIGNATURE.ALGORITHM,
authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
keyId: 'acct:peertube@localhost:9002',
key: keys.privateKey,
headers: HTTP_SIGNATURE.HEADERS_TO_SIGN
}
// ---------------------------------------------------------------
before(async function () {
this.timeout(60000)
servers = await flushAndRunMultipleServers(3)
url = servers[0].url + '/inbox'
await setKeysOfServer2(1, keys.publicKey, keys.privateKey)
const to = { url: 'http://localhost:9001/accounts/peertube' }
const by = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
await makeFollowRequest(to, by)
})
describe('When checking HTTP signature', function () {
it('Should fail with an invalid digest', async function () {
const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
const headers = {
Digest: buildDigest({ hello: 'coucou' })
}
const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
expect(response.statusCode).to.equal(403)
})
it('Should fail with an invalid date', async function () {
const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
const headers = buildGlobalHeaders(body)
headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT'
const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
expect(response.statusCode).to.equal(403)
})
it('Should fail with bad keys', async function () {
await setKeysOfServer2(1, invalidKeys.publicKey, invalidKeys.privateKey)
await setKeysOfServer2(2, invalidKeys.publicKey, invalidKeys.privateKey)
const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
const headers = buildGlobalHeaders(body)
const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
expect(response.statusCode).to.equal(403)
})
it('Should succeed with a valid HTTP signature', async function () {
await setKeysOfServer2(1, keys.publicKey, keys.privateKey)
await setKeysOfServer2(2, keys.publicKey, keys.privateKey)
const body = activityPubContextify(require('./json/peertube/announce-without-context.json'))
const headers = buildGlobalHeaders(body)
const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers)
expect(response.statusCode).to.equal(204)
})
})
describe('When checking Linked Data Signature', function () {
before(async () => {
await setKeysOfServer3(3, keys.publicKey, keys.privateKey)
const to = { url: 'http://localhost:9001/accounts/peertube' }
const by = { url: 'http://localhost:9003/accounts/peertube', privateKey: keys.privateKey }
await makeFollowRequest(to, by)
})
it('Should fail with bad keys', async function () {
this.timeout(10000)
await setKeysOfServer3(1, invalidKeys.publicKey, invalidKeys.privateKey)
await setKeysOfServer3(3, invalidKeys.publicKey, invalidKeys.privateKey)
const body = require('./json/peertube/announce-without-context.json')
body.actor = 'http://localhost:9003/accounts/peertube'
const signer: any = { privateKey: invalidKeys.privateKey, url: 'http://localhost:9003/accounts/peertube' }
const signedBody = await buildSignedActivity(signer, body)
const headers = buildGlobalHeaders(signedBody)
const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers)
expect(response.statusCode).to.equal(403)
})
it('Should fail with an altered body', async function () {
this.timeout(10000)
await setKeysOfServer3(1, keys.publicKey, keys.privateKey)
await setKeysOfServer3(3, keys.publicKey, keys.privateKey)
const body = require('./json/peertube/announce-without-context.json')
body.actor = 'http://localhost:9003/accounts/peertube'
const signer: any = { privateKey: keys.privateKey, url: 'http://localhost:9003/accounts/peertube' }
const signedBody = await buildSignedActivity(signer, body)
signedBody.actor = 'http://localhost:9003/account/peertube'
const headers = buildGlobalHeaders(signedBody)
const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers)
expect(response.statusCode).to.equal(403)
})
it('Should succeed with a valid signature', async function () {
this.timeout(10000)
const body = require('./json/peertube/announce-without-context.json')
body.actor = 'http://localhost:9003/accounts/peertube'
const signer: any = { privateKey: keys.privateKey, url: 'http://localhost:9003/accounts/peertube' }
const signedBody = await buildSignedActivity(signer, body)
const headers = buildGlobalHeaders(signedBody)
const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers)
expect(response.statusCode).to.equal(204)
})
})
after(async function () {
killallServers(servers)
// Keep the logs if the test failed
if (this['ok']) {
await flushTests()
}
})
})

View File

@ -1 +1,2 @@
import './redundancy'
import './activitypub'

View File

@ -54,7 +54,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams:
immutableAssign({
min_lifetime: '1 hour',
strategy: strategy,
size: '100KB'
size: '200KB'
}, additionalParams)
]
}
@ -111,8 +111,8 @@ async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
const stat = data.videosRedundancy[0]
expect(stat.strategy).to.equal(strategy)
expect(stat.totalSize).to.equal(102400)
expect(stat.totalUsed).to.be.at.least(1).and.below(102401)
expect(stat.totalSize).to.equal(204800)
expect(stat.totalUsed).to.be.at.least(1).and.below(204801)
expect(stat.totalVideoFiles).to.equal(4)
expect(stat.totalVideos).to.equal(1)
}
@ -125,7 +125,7 @@ async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
const stat = data.videosRedundancy[0]
expect(stat.strategy).to.equal(strategy)
expect(stat.totalSize).to.equal(102400)
expect(stat.totalSize).to.equal(204800)
expect(stat.totalUsed).to.equal(0)
expect(stat.totalVideoFiles).to.equal(0)
expect(stat.totalVideos).to.equal(0)
@ -223,7 +223,7 @@ describe('Test videos redundancy', function () {
return enableRedundancyOnServer1()
})
it('Should have 2 webseed on the first video', async function () {
it('Should have 2 webseeds on the first video', async function () {
this.timeout(40000)
await waitJobs(servers)
@ -270,7 +270,7 @@ describe('Test videos redundancy', function () {
return enableRedundancyOnServer1()
})
it('Should have 2 webseed on the first video', async function () {
it('Should have 2 webseeds on the first video', async function () {
this.timeout(40000)
await waitJobs(servers)
@ -338,7 +338,7 @@ describe('Test videos redundancy', function () {
await waitJobs(servers)
})
it('Should have 2 webseed on the first video', async function () {
it('Should have 2 webseeds on the first video', async function () {
this.timeout(40000)
await waitJobs(servers)
@ -419,7 +419,7 @@ describe('Test videos redundancy', function () {
killallServers([ servers[0] ])
await wait(10000)
await wait(15000)
await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2Flocalhost%3A9001')
})
@ -451,27 +451,23 @@ describe('Test videos redundancy', function () {
video2Server2UUID = res.body.video.uuid
})
it('Should cache video 2 webseed on the first video', async function () {
this.timeout(50000)
it('Should cache video 2 webseeds on the first video', async function () {
this.timeout(120000)
await waitJobs(servers)
await wait(7000)
let checked = false
try {
await check1WebSeed(strategy, video1Server2UUID)
await check2Webseeds(strategy, video2Server2UUID)
} catch {
await wait(3000)
while (checked === false) {
await wait(1000)
try {
await check1WebSeed(strategy, video1Server2UUID)
await check2Webseeds(strategy, video2Server2UUID)
} catch {
await wait(5000)
await check1WebSeed(strategy, video1Server2UUID)
await check2Webseeds(strategy, video2Server2UUID)
checked = true
} catch {
checked = false
}
}
})

View File

@ -5,6 +5,7 @@ import 'mocha'
import { JobState, Video } from '../../../../shared/models'
import { VideoPrivacy } from '../../../../shared/models/videos'
import { VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
import {
completeVideoCheck,
getVideo,
@ -18,6 +19,7 @@ import {
ServerInfo,
setAccessTokensToServers,
uploadVideo,
updateVideo,
wait
} from '../../../../shared/utils'
import { follow, getFollowersListPaginationAndSort } from '../../../../shared/utils/server/follows'
@ -199,15 +201,15 @@ describe('Test handle downs', function () {
expect(res.body.data).to.have.lengthOf(2)
})
it('Should send a view to server 3, and automatically fetch the video', async function () {
it('Should send an update to server 3, and automatically fetch the video', async function () {
this.timeout(15000)
const res1 = await getVideosList(servers[2].url)
expect(res1.body.data).to.be.an('array')
expect(res1.body.data).to.have.lengthOf(11)
await viewVideo(servers[0].url, missedVideo1.uuid)
await viewVideo(servers[0].url, unlistedVideo.uuid)
await updateVideo(servers[0].url, servers[0].accessToken, missedVideo1.uuid, { })
await updateVideo(servers[0].url, servers[0].accessToken, unlistedVideo.uuid, { })
await waitJobs(servers)

View File

@ -6,3 +6,4 @@ import './jobs'
import './reverse-proxy'
import './stats'
import './tracker'
import './no-client'

View File

@ -0,0 +1,36 @@
import 'mocha'
import * as request from 'supertest'
import {
flushTests,
killallServers,
ServerInfo
} from '../../utils/index'
import { runServer } from '../../utils/server/servers'
describe('Start and stop server without web client routes', function () {
let server: ServerInfo
before(async function () {
this.timeout(30000)
await flushTests()
server = await runServer(1, {}, ['--no-client'])
})
it('Should fail getting the client', function () {
const req = request(server.url)
.get('/')
return req.expect(404)
})
after(async function () {
killallServers([ server ])
// Keep the logs if the test failed
if (this['ok']) {
await flushTests()
}
})
})

View File

@ -44,6 +44,8 @@ describe('Test CLI wrapper', function () {
})
after(async function () {
this.timeout(10000)
await execCLI(cmd + ` auth del ${server.url}`)
killallServers([ server ])

View File

@ -1,6 +1,5 @@
// Order of the tests we want to execute
import './client'
import './activitypub'
import './feeds/'
import './cli/'
import './api/'

View File

@ -0,0 +1,38 @@
import * as Sequelize from 'sequelize'
function getSequelize (serverNumber: number) {
const dbname = 'peertube_test' + serverNumber
const username = 'peertube'
const password = 'peertube'
const host = 'localhost'
const port = 5432
return new Sequelize(dbname, username, password, {
dialect: 'postgres',
host,
port,
operatorsAliases: false,
logging: false
})
}
function setActorField (serverNumber: number, to: string, field: string, value: string) {
const seq = getSequelize(serverNumber)
const options = { type: Sequelize.QueryTypes.UPDATE }
return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
}
function setVideoField (serverNumber: number, uuid: string, field: string, value: string) {
const seq = getSequelize(serverNumber)
const options = { type: Sequelize.QueryTypes.UPDATE }
return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
}
export {
setVideoField,
setActorField
}

View File

@ -0,0 +1,14 @@
function buildRequestStub (): any {
return { }
}
function buildResponseStub (): any {
return {
locals: {}
}
}
export {
buildResponseStub,
buildRequestStub
}

View File

@ -0,0 +1,43 @@
import { doRequest } from '../../../helpers/requests'
import { HTTP_SIGNATURE } from '../../../initializers'
import { buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils'
import { activityPubContextify } from '../../../helpers/activitypub'
function makePOSTAPRequest (url: string, body: any, httpSignature: any, headers: any) {
const options = {
method: 'POST',
uri: url,
json: body,
httpSignature,
headers
}
return doRequest(options)
}
async function makeFollowRequest (to: { url: string }, by: { url: string, privateKey }) {
const follow = {
type: 'Follow',
id: by.url + '/toto',
actor: by.url,
object: to.url
}
const body = activityPubContextify(follow)
const httpSignature = {
algorithm: HTTP_SIGNATURE.ALGORITHM,
authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
keyId: by.url,
key: by.privateKey,
headers: HTTP_SIGNATURE.HEADERS_TO_SIGN
}
const headers = buildGlobalHeaders(body)
return makePOSTAPRequest(to.url, body, httpSignature, headers)
}
export {
makePOSTAPRequest,
makeFollowRequest
}

View File

@ -5,34 +5,25 @@ import { getSettings, writeSettings, netrc } from './cli'
import { isHostValid } from '../helpers/custom-validators/servers'
import { isUserUsernameValid } from '../helpers/custom-validators/users'
function delInstance (url: string) {
return new Promise((res, rej): void => {
getSettings()
.then(async (settings) => {
settings.remotes.splice(settings.remotes.indexOf(url))
await writeSettings(settings)
delete netrc.machines[url]
netrc.save()
res()
})
.catch(err => rej(err))
})
async function delInstance (url: string) {
const settings = await getSettings()
settings.remotes.splice(settings.remotes.indexOf(url))
await writeSettings(settings)
delete netrc.machines[url]
await netrc.save()
}
async function setInstance (url: string, username: string, password: string) {
return new Promise((res, rej): void => {
getSettings()
.then(async settings => {
if (settings.remotes.indexOf(url) === -1) {
settings.remotes.push(url)
}
await writeSettings(settings)
netrc.machines[url] = { login: username, password }
netrc.save()
res()
})
.catch(err => rej(err))
})
const settings = await getSettings()
if (settings.remotes.indexOf(url) === -1) {
settings.remotes.push(url)
}
await writeSettings(settings)
netrc.machines[url] = { login: username, password }
await netrc.save()
}
function isURLaPeerTubeInstance (url: string) {
@ -71,56 +62,60 @@ program
required: true
}
}
}, (_, result) => {
setInstance(result.url, result.username, result.password)
}, async (_, result) => {
await setInstance(result.url, result.username, result.password)
process.exit(0)
})
})
program
.command('del <url>')
.description('unregisters a remote instance')
.action((url) => {
delInstance(url)
.action(async url => {
await delInstance(url)
process.exit(0)
})
program
.command('list')
.description('lists registered remote instances')
.action(() => {
getSettings()
.then(settings => {
const table = new Table({
head: ['instance', 'login'],
colWidths: [30, 30]
})
netrc.loadSync()
settings.remotes.forEach(element => {
table.push([
element,
netrc.machines[element].login
])
})
.action(async () => {
const settings = await getSettings()
const table = new Table({
head: ['instance', 'login'],
colWidths: [30, 30]
})
netrc.loadSync()
settings.remotes.forEach(element => {
table.push([
element,
netrc.machines[element].login
])
})
console.log(table.toString())
})
console.log(table.toString())
process.exit(0)
})
program
.command('set-default <url>')
.description('set an existing entry as default')
.action((url) => {
getSettings()
.then(settings => {
const instanceExists = settings.remotes.indexOf(url) !== -1
.action(async url => {
const settings = await getSettings()
const instanceExists = settings.remotes.indexOf(url) !== -1
if (instanceExists) {
settings.default = settings.remotes.indexOf(url)
writeSettings(settings)
} else {
console.log('<url> is not a registered instance.')
process.exit(-1)
}
})
if (instanceExists) {
settings.default = settings.remotes.indexOf(url)
await writeSettings(settings)
process.exit(0)
} else {
console.log('<url> is not a registered instance.')
process.exit(-1)
}
})
program.on('--help', function () {

View File

@ -58,7 +58,7 @@ if (!process.argv.slice(2).length) {
,"\\/
_,.__/"\\/_ (the CLI for red chocobos)
/ \\) "./, ".
--/---"---" "-) )---- by Chocobozzz et al.`)
--/---"---" "-) )---- by Chocobozzz et al.\n`)
}
getSettings()

View File

@ -1,5 +1,6 @@
export interface DislikeObject {
type: 'Dislike',
id: string
type: 'Dislike'
actor: string
object: string
}

View File

@ -1 +1 @@
export type VideoRateType = 'like' | 'dislike' | 'none'
export type VideoRateType = 'like' | 'dislike'

Some files were not shown because too many files have changed in this diff Show More