Import torrents with webtorrent

pull/918/head
Chocobozzz 2018-08-07 09:54:36 +02:00
parent ce33919c24
commit 990b6a0b0c
16 changed files with 169 additions and 41 deletions

View File

@ -5,7 +5,7 @@
<ng-template pTemplate="header">
<tr>
<th style="width: 40px;"></th>
<th i18n>URL</th>
<th i18n>Target</th>
<th i18n>Video</th>
<th i18n style="width: 150px">State</th>
<th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
@ -22,7 +22,10 @@
</td>
<td>
<a [href]="videoImport.targetUrl" target="_blank" rel="noopener noreferrer">{{ videoImport.targetUrl }}</a>
<a *ngIf="videoImport.targetUrl; else torrent" [href]="videoImport.targetUrl" target="_blank" rel="noopener noreferrer">{{ videoImport.targetUrl }}</a>
<ng-template #torrent>
<span [title]="videoImport.torrentName || videoImport.magnetUri">{{ videoImport.torrentName || videoImport.magnetUri }}</span>
</ng-template>
</td>
<td *ngIf="isVideoImportPending(videoImport)">

View File

@ -2,8 +2,16 @@
<div class="import-video-torrent">
<div class="icon icon-upload"></div>
<div class="form-group">
<label i18n for="magnetUri">Magnet URI</label>
<div class="button-file">
<span i18n>Select the torrent to import</span>
<input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" />
</div>
<span class="button-file-extension">(.torrent)</span>
<div class="torrent-or-magnet">Or</div>
<div class="form-group form-group-magnet-uri">
<label i18n for="magnetUri">Paste magnet URI</label>
<my-help
helpType="custom" i18n-customHtml
customHtml="You can import any torrent file that points to a mp4 file. You should make sure you have diffusion rights over the content it points to, otherwise it could cause legal trouble to yourself and your instance."

View File

@ -20,6 +20,26 @@ $width-size: 190px;
background-image: url('../../../../assets/images/video/upload.svg');
}
.button-file {
@include peertube-button-file(auto);
min-width: 190px;
}
.button-file-extension {
display: block;
font-size: 12px;
margin-top: 5px;
}
.torrent-or-magnet {
margin: 10px 0;
}
.form-group-magnet-uri {
margin-bottom: 40px;
}
input[type=text] {
@include peertube-input-text($width-size);
display: block;

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
import { NotificationsService } from 'angular2-notifications'
import { VideoPrivacy, VideoUpdate } from '../../../../../../shared/models/videos'
@ -23,6 +23,7 @@ import { VideoImportService } from '@app/shared/video-import'
})
export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate {
@Output() firstStepDone = new EventEmitter<string>()
@ViewChild('torrentfileInput') torrentfileInput
videoFileName: string
magnetUri = ''
@ -33,7 +34,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
video: VideoEdit
protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PRIVATE
protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
constructor (
protected formValidatorService: FormValidatorService,
@ -62,7 +63,14 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
return !!this.magnetUri
}
importVideo () {
fileChange () {
const torrentfile = this.torrentfileInput.nativeElement.files[0] as File
if (!torrentfile) return
this.importVideo(torrentfile)
}
importVideo (torrentfile?: Blob) {
this.isImportingVideo = true
const videoUpdate: VideoUpdate = {
@ -74,7 +82,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
this.loadingBar.start()
this.videoImportService.importVideoTorrent(this.magnetUri, videoUpdate).subscribe(
this.videoImportService.importVideoTorrent(torrentfile || this.magnetUri, videoUpdate).subscribe(
res => {
this.loadingBar.complete()
this.firstStepDone.emit(res.video.name)

View File

@ -33,7 +33,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
video: VideoEdit
protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PRIVATE
protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
constructor (
protected formValidatorService: FormValidatorService,

View File

@ -49,7 +49,7 @@ $background-color: #F7F7F7;
background-color: $background-color;
border-radius: 3px;
width: 100%;
height: 440px;
min-height: 440px;
display: flex;
justify-content: center;
align-items: center;

View File

@ -1,8 +1,16 @@
import * as magnetUtil from 'magnet-uri'
import * as express from 'express'
import * as magnetUtil from 'magnet-uri'
import 'multer'
import { auditLoggerFactory, VideoImportAuditView } from '../../../helpers/audit-logger'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
import { CONFIG, IMAGE_MIMETYPE_EXT, PREVIEWS_SIZE, sequelizeTypescript, THUMBNAILS_SIZE } from '../../../initializers'
import {
CONFIG,
IMAGE_MIMETYPE_EXT,
PREVIEWS_SIZE,
sequelizeTypescript,
THUMBNAILS_SIZE,
TORRENT_MIMETYPE_EXT
} from '../../../initializers'
import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
import { createReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
@ -18,16 +26,20 @@ import { isArray } from '../../../helpers/custom-validators/misc'
import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
import { VideoChannelModel } from '../../../models/video/video-channel'
import * as Bluebird from 'bluebird'
import * as parseTorrent from 'parse-torrent'
import { readFileBufferPromise, renamePromise } from '../../../helpers/core-utils'
import { getSecureTorrentName } from '../../../helpers/utils'
const auditLogger = auditLoggerFactory('video-imports')
const videoImportsRouter = express.Router()
const reqVideoFileImport = createReqFiles(
[ 'thumbnailfile', 'previewfile' ],
IMAGE_MIMETYPE_EXT,
[ 'thumbnailfile', 'previewfile', 'torrentfile' ],
Object.assign({}, TORRENT_MIMETYPE_EXT, IMAGE_MIMETYPE_EXT),
{
thumbnailfile: CONFIG.STORAGE.THUMBNAILS_DIR,
previewfile: CONFIG.STORAGE.PREVIEWS_DIR
previewfile: CONFIG.STORAGE.PREVIEWS_DIR,
torrentfile: CONFIG.STORAGE.TORRENTS_DIR
}
)
@ -49,17 +61,37 @@ export {
function addVideoImport (req: express.Request, res: express.Response) {
if (req.body.targetUrl) return addYoutubeDLImport(req, res)
if (req.body.magnetUri) return addTorrentImport(req, res)
const file = req.files['torrentfile'][0]
if (req.body.magnetUri || file) return addTorrentImport(req, res, file)
}
async function addTorrentImport (req: express.Request, res: express.Response) {
async function addTorrentImport (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
const body: VideoImportCreate = req.body
const magnetUri = body.magnetUri
const parsed = magnetUtil.decode(magnetUri)
const magnetName = isArray(parsed.name) ? parsed.name[0] : parsed.name as string
let videoName: string
let torrentName: string
let magnetUri: string
const video = buildVideo(res.locals.videoChannel.id, body, { name: magnetName })
if (torrentfile) {
torrentName = torrentfile.originalname
// Rename the torrent to a secured name
const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
await renamePromise(torrentfile.path, newTorrentPath)
torrentfile.path = newTorrentPath
const buf = await readFileBufferPromise(torrentfile.path)
const parsedTorrent = parseTorrent(buf)
videoName = isArray(parsedTorrent.name) ? parsedTorrent.name[ 0 ] : parsedTorrent.name as string
} else {
magnetUri = body.magnetUri
const parsed = magnetUtil.decode(magnetUri)
videoName = isArray(parsed.name) ? parsed.name[ 0 ] : parsed.name as string
}
const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
await processThumbnail(req, video)
await processPreview(req, video)
@ -67,13 +99,14 @@ async function addTorrentImport (req: express.Request, res: express.Response) {
const tags = null
const videoImportAttributes = {
magnetUri,
torrentName,
state: VideoImportState.PENDING
}
const videoImport: VideoImportModel = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes)
// Create job to import the video
const payload = {
type: 'magnet-uri' as 'magnet-uri',
type: torrentfile ? 'torrent-file' as 'torrent-file' : 'magnet-uri' as 'magnet-uri',
videoImportId: videoImport.id,
magnetUri
}

View File

@ -13,6 +13,7 @@ import * as pem from 'pem'
import * as rimraf from 'rimraf'
import { URL } from 'url'
import { truncate } from 'lodash'
import * as crypto from 'crypto'
function sanitizeUrl (url: string) {
const urlObject = new URL(url)
@ -95,6 +96,10 @@ function peertubeTruncate (str: string, maxLength: number) {
return truncate(str, options)
}
function sha256 (str: string) {
return crypto.createHash('sha256').update(str).digest('hex')
}
function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
return function promisified (): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
@ -165,6 +170,7 @@ export {
sanitizeHost,
buildPath,
peertubeTruncate,
sha256,
promisify0,
promisify1,

View File

@ -1,10 +1,9 @@
import 'express-validator'
import 'multer'
import * as validator from 'validator'
import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers'
import { exists } from './misc'
import { CONSTRAINTS_FIELDS, TORRENT_MIMETYPE_EXT, VIDEO_IMPORT_STATES } from '../../initializers'
import { exists, isFileValid } from './misc'
import * as express from 'express'
import { VideoChannelModel } from '../../models/video/video-channel'
import { VideoImportModel } from '../../models/video/video-import'
function isVideoImportTargetUrlValid (url: string) {
@ -25,6 +24,12 @@ function isVideoImportStateValid (value: any) {
return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined
}
const videoTorrentImportTypes = Object.keys(TORRENT_MIMETYPE_EXT).map(m => `(${m})`)
const videoTorrentImportRegex = videoTorrentImportTypes.join('|')
function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
}
async function isVideoImportExist (id: number, res: express.Response) {
const videoImport = await VideoImportModel.loadAndPopulateVideo(id)
@ -45,5 +50,6 @@ async function isVideoImportExist (id: number, res: express.Response) {
export {
isVideoImportStateValid,
isVideoImportTargetUrlValid,
isVideoImportExist
isVideoImportExist,
isVideoImportTorrentFile
}

View File

@ -6,11 +6,12 @@ import { CONFIG } from '../initializers'
import { UserModel } from '../models/account/user'
import { ActorModel } from '../models/activitypub/actor'
import { ApplicationModel } from '../models/application/application'
import { pseudoRandomBytesPromise, unlinkPromise } from './core-utils'
import { pseudoRandomBytesPromise, sha256, unlinkPromise } from './core-utils'
import { logger } from './logger'
import { isArray } from './custom-validators/misc'
import * as crypto from "crypto"
import { join } from "path"
import { Instance as ParseTorrent } from 'parse-torrent'
const isCidr = require('is-cidr')
@ -183,13 +184,18 @@ async function getServerActor () {
return Promise.resolve(serverActor)
}
function generateVideoTmpPath (id: string) {
const hash = crypto.createHash('sha256').update(id).digest('hex')
function generateVideoTmpPath (target: string | ParseTorrent) {
const id = typeof target === 'string' ? target : target.infoHash
const hash = sha256(id)
return join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4')
}
type SortType = { sortModel: any, sortValue: string }
function getSecureTorrentName (originalName: string) {
return sha256(originalName) + '.torrent'
}
type SortType = { sortModel: any, sortValue: string }
// ---------------------------------------------------------------------------
@ -199,6 +205,7 @@ export {
generateRandomString,
getFormattedObjects,
isSignupAllowed,
getSecureTorrentName,
isSignupAllowedForCurrentIP,
computeResolutionsToTranscode,
resetSequelizeInstance,

View File

@ -2,17 +2,22 @@ import { logger } from './logger'
import { generateVideoTmpPath } from './utils'
import * as WebTorrent from 'webtorrent'
import { createWriteStream } from 'fs'
import { Instance as ParseTorrent } from 'parse-torrent'
import { CONFIG } from '../initializers'
import { join } from 'path'
function downloadWebTorrentVideo (target: string) {
const path = generateVideoTmpPath(target)
function downloadWebTorrentVideo (target: { magnetUri: string, torrentName: string }) {
const id = target.magnetUri || target.torrentName
logger.info('Importing torrent video %s', target)
const path = generateVideoTmpPath(id)
logger.info('Importing torrent video %s', id)
return new Promise<string>((res, rej) => {
const webtorrent = new WebTorrent()
const torrent = webtorrent.add(target, torrent => {
if (torrent.files.length !== 1) throw new Error('The number of files is not equal to 1 for ' + target)
const torrentId = target.magnetUri || join(CONFIG.STORAGE.TORRENTS_DIR, target.torrentName)
const torrent = webtorrent.add(torrentId, torrent => {
if (torrent.files.length !== 1) return rej(new Error('The number of files is not equal to 1 for ' + torrentId))
const file = torrent.files[ 0 ]
file.createReadStream().pipe(createWriteStream(path))

View File

@ -273,6 +273,12 @@ const CONSTRAINTS_FIELDS = {
VIDEO_IMPORTS: {
URL: { min: 3, max: 2000 }, // Length
TORRENT_NAME: { min: 3, max: 255 }, // Length
TORRENT_FILE: {
EXTNAME: [ '.torrent' ],
FILE_SIZE: {
max: 1024 * 200 // 200 KB
}
}
},
VIDEOS: {
NAME: { min: 3, max: 120 }, // Length
@ -417,6 +423,10 @@ const VIDEO_CAPTIONS_MIMETYPE_EXT = {
'application/x-subrip': '.srt'
}
const TORRENT_MIMETYPE_EXT = {
'application/x-bittorrent': '.torrent'
}
// ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube'
@ -596,6 +606,7 @@ export {
FEEDS,
JOB_TTL,
NSFW_POLICY_TYPES,
TORRENT_MIMETYPE_EXT,
STATIC_MAX_AGE,
STATIC_PATHS,
ACTIVITY_PUB,

View File

@ -14,6 +14,7 @@ import { JobQueue } from '../index'
import { federateVideoIfNeeded } from '../../activitypub'
import { VideoModel } from '../../../models/video/video'
import { downloadWebTorrentVideo } from '../../../helpers/webtorrent'
import { getSecureTorrentName } from '../../../helpers/utils'
type VideoImportYoutubeDLPayload = {
type: 'youtube-dl'
@ -25,7 +26,7 @@ type VideoImportYoutubeDLPayload = {
}
type VideoImportTorrentPayload = {
type: 'magnet-uri'
type: 'magnet-uri' | 'torrent-file'
videoImportId: number
}
@ -35,7 +36,7 @@ async function processVideoImport (job: Bull.Job) {
const payload = job.data as VideoImportPayload
if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, payload)
if (payload.type === 'magnet-uri') return processTorrentImport(job, payload)
if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, payload)
}
// ---------------------------------------------------------------------------
@ -50,6 +51,7 @@ async function processTorrentImport (job: Bull.Job, payload: VideoImportTorrentP
logger.info('Processing torrent video import in job %d.', job.id)
const videoImport = await getVideoImportOrDie(payload.videoImportId)
const options = {
videoImportId: payload.videoImportId,
@ -59,7 +61,11 @@ async function processTorrentImport (job: Bull.Job, payload: VideoImportTorrentP
generateThumbnail: true,
generatePreview: true
}
return processFile(() => downloadWebTorrentVideo(videoImport.magnetUri), videoImport, options)
const target = {
torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
magnetUri: videoImport.magnetUri
}
return processFile(() => downloadWebTorrentVideo(target), videoImport, options)
}
async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutubeDLPayload) {

View File

@ -4,10 +4,11 @@ import { isIdValid } from '../../helpers/custom-validators/misc'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
import { getCommonVideoAttributes } from './videos'
import { isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../helpers/custom-validators/video-imports'
import { cleanUpReqFiles } from '../../helpers/utils'
import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos'
import { CONFIG } from '../../initializers/constants'
import { CONSTRAINTS_FIELDS } from '../../initializers'
const videoImportAddValidator = getCommonVideoAttributes().concat([
body('channelId')
@ -19,6 +20,11 @@ const videoImportAddValidator = getCommonVideoAttributes().concat([
body('magnetUri')
.optional()
.custom(isVideoMagnetUriValid).withMessage('Should have a valid video magnet URI'),
body('torrentfile')
.custom((value, { req }) => isVideoImportTorrentFile(req.files)).withMessage(
'This torrent file is not supported or too large. Please, make sure it is of the following type: '
+ CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.EXTNAME.join(', ')
),
body('name')
.optional()
.custom(isVideoNameValid).withMessage('Should have a valid name'),
@ -40,11 +46,12 @@ const videoImportAddValidator = getCommonVideoAttributes().concat([
if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
// Check we have at least 1 required param
if (!req.body.targetUrl && !req.body.magnetUri) {
const file = req.files['torrentfile'][0]
if (!req.body.targetUrl && !req.body.magnetUri && !file) {
cleanUpReqFiles(req)
return res.status(400)
.json({ error: 'Should have a magnetUri or a targetUrl.' })
.json({ error: 'Should have a magnetUri or a targetUrl or a torrent file.' })
.end()
}

View File

@ -171,7 +171,11 @@ export class VideoImportModel extends Model<VideoImportModel> {
return {
id: this.id,
targetUrl: this.targetUrl,
magnetUri: this.magnetUri,
torrentName: this.torrentName,
state: {
id: this.state,
label: VideoImportModel.getStateLabel(this.state)

View File

@ -4,7 +4,11 @@ import { VideoImportState } from './video-import-state.enum'
export interface VideoImport {
id: number
targetUrl: string
magnetUri: string
torrentName: string
createdAt: string
updatedAt: string
state: VideoConstant<VideoImportState>