<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>
</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)">
<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."
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;
-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'
})
export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate {
@Output() firstStepDone = new EventEmitter<string>()
+ @ViewChild('torrentfileInput') torrentfileInput
videoFileName: string
magnetUri = ''
video: VideoEdit
- protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PRIVATE
+ protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
constructor (
protected formValidatorService: FormValidatorService,
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 = {
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)
video: VideoEdit
- protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PRIVATE
+ protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
constructor (
protected formValidatorService: FormValidatorService,
background-color: $background-color;
border-radius: 3px;
width: 100%;
- height: 440px;
+ min-height: 440px;
display: flex;
justify-content: center;
align-items: center;
-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'
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
}
)
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
+
+ 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: magnetName })
+ const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
await processThumbnail(req, video)
await processPreview(req, video)
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
}
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)
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) => {
sanitizeHost,
buildPath,
peertubeTruncate,
+ sha256,
promisify0,
promisify1,
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) {
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)
export {
isVideoImportStateValid,
isVideoImportTargetUrlValid,
- isVideoImportExist
+ isVideoImportExist,
+ isVideoImportTorrentFile
}
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')
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 }
// ---------------------------------------------------------------------------
generateRandomString,
getFormattedObjects,
isSignupAllowed,
+ getSecureTorrentName,
isSignupAllowedForCurrentIP,
computeResolutionsToTranscode,
resetSequelizeInstance,
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))
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
'application/x-subrip': '.srt'
}
+const TORRENT_MIMETYPE_EXT = {
+ 'application/x-bittorrent': '.torrent'
+}
+
// ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube'
FEEDS,
JOB_TTL,
NSFW_POLICY_TYPES,
+ TORRENT_MIMETYPE_EXT,
STATIC_MAX_AGE,
STATIC_PATHS,
ACTIVITY_PUB,
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'
}
type VideoImportTorrentPayload = {
- type: 'magnet-uri'
+ type: 'magnet-uri' | 'torrent-file'
videoImportId: number
}
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)
}
// ---------------------------------------------------------------------------
logger.info('Processing torrent video import in job %d.', job.id)
const videoImport = await getVideoImportOrDie(payload.videoImportId)
+
const options = {
videoImportId: payload.videoImportId,
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) {
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')
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'),
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()
}
return {
id: this.id,
+
targetUrl: this.targetUrl,
+ magnetUri: this.magnetUri,
+ torrentName: this.torrentName,
+
state: {
id: this.state,
label: VideoImportModel.getStateLabel(this.state)
export interface VideoImport {
id: number
+
targetUrl: string
+ magnetUri: string
+ torrentName: string
+
createdAt: string
updatedAt: string
state: VideoConstant<VideoImportState>