├── utils ├── common.js ├── recaptcha.js ├── requests.js ├── session_store.js ├── signature.js ├── errdef.js └── torrent.js ├── models ├── sequelize.js ├── node.js ├── file.js ├── user.js ├── cache.js └── task.js ├── controller ├── capi │ ├── captcha.js │ ├── torrent.js │ ├── user.js │ ├── task.js │ └── magnet.js └── api.js ├── config └── rain.conf ├── .gitignore ├── package.json ├── api └── node_api.js ├── README.md ├── config.js ├── cron ├── task_sync.js └── task_purge.js └── app.js /utils/common.js: -------------------------------------------------------------------------------- 1 | 2 | exports.sleep = (time) => { 3 | return new Promise((resolve, reject) => { 4 | setTimeout(() => { resolve() }, time) 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /models/sequelize.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const Sequelize = require('sequelize') 3 | const config = require('../config').sequelize 4 | 5 | const sequelize = new Sequelize(config.database, config.username, config.password, config) 6 | 7 | module.exports = sequelize -------------------------------------------------------------------------------- /models/node.js: -------------------------------------------------------------------------------- 1 | var config = require('../config') 2 | 3 | 4 | exports.getNode = (nodeName) => { 5 | let node 6 | config.node.forEach( x => { 7 | if (x.name == nodeName) { 8 | node = x 9 | return false 10 | } 11 | }) 12 | return node 13 | } 14 | -------------------------------------------------------------------------------- /controller/capi/captcha.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const router = require('koa-router')() 3 | const ccap = require('ccap')() 4 | 5 | 6 | router.prefix('/capi') 7 | 8 | router.get('/captcha', async function (ctx, next) { 9 | return next().then(() => { 10 | let ary = ccap.get() 11 | let txt = ary[0] 12 | let buf = ary[1] 13 | ctx.body = buf 14 | ctx.type = 'image/png' 15 | ctx.session.captcha = txt 16 | }) 17 | }) 18 | 19 | module.exports = router 20 | -------------------------------------------------------------------------------- /utils/recaptcha.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const errdef = require('./errdef') 3 | const axios = require('axios') 4 | const config = require('../config') 5 | 6 | 7 | exports.checkCaptch = async (inputCaptcha) => { 8 | const res = await axios({ 9 | method: 'post', 10 | baseURL: 'https://www.google.com', 11 | url: '/recaptcha/api/siteverify', 12 | params: { 13 | secret: config.recaptcha.secret, 14 | response: inputCaptcha 15 | } 16 | }) 17 | 18 | if (res.data.success === true) { 19 | return true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /models/file.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const sequelize = require("./sequelize.js") 3 | const Sequelize = require('sequelize') 4 | 5 | const File = sequelize.define('files', { 6 | path: { 7 | type: Sequelize.STRING, 8 | allowNull: false 9 | }, 10 | name: { 11 | type: Sequelize.STRING, 12 | allowNull: false 13 | }, 14 | size: { 15 | type: Sequelize.BIGINT, 16 | allowNull: false 17 | }, 18 | torrent_info_hash: { 19 | type: Sequelize.STRING, 20 | allowNull: false 21 | } 22 | } 23 | ) 24 | 25 | File.getFiles = async (where) => { 26 | return await File.findAll({where}) 27 | } 28 | 29 | File.sync() 30 | 31 | module.exports = File -------------------------------------------------------------------------------- /utils/requests.js: -------------------------------------------------------------------------------- 1 | var axios = require('axios') 2 | var logger = require('log4js').getLogger('requests') 3 | 4 | 5 | exports.request = async (config) => { 6 | let res 7 | try { 8 | if (!config.timeout) { 9 | config.timeout = 60000 10 | } 11 | return await axios(config) 12 | } catch (err) { 13 | if (err.response) { 14 | logger.error('fail to request:', 15 | `${err.response.request.res.responseUrl}${err.response.request.path}`, 16 | 'code:', err.response.status, 17 | '\ndata:\n', err.response.data 18 | ) 19 | } else { 20 | logger.error('fail to request:', 21 | `${err.request._options.protocol}//${err.request._options.hostname}${err.request._options.path}` 22 | ) 23 | } 24 | return null 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /config/rain.conf: -------------------------------------------------------------------------------- 1 | limit_req_zone $binary_remote_addr zone=rain:10m rate=1r/s; 2 | 3 | upstream rain_server{ 4 | server 127.0.0.1:3001; 5 | keepalive 65; 6 | } 7 | 8 | server { 9 | server_name ${RAIN_DOMAIN}; 10 | listen 80; 11 | 12 | access_log /var/log/nginx/rain.accree.log; 13 | error_log /var/log/nginx/rain.error.log; 14 | 15 | location /api/ { 16 | proxy_set_header X-Forwarded-For $remote_addr; 17 | limit_req zone=rain burst=10; 18 | proxy_pass http://rain_server; 19 | } 20 | 21 | location /capi/ { 22 | proxy_set_header X-Forwarded-For $remote_addr; 23 | limit_req zone=rain burst=10; 24 | proxy_pass http://rain_server; 25 | } 26 | 27 | location / { 28 | rewrite ^/(\w+)$ /index.html; 29 | alias ${FRONTEND_DIST_PATH}; 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const sequelize = require("./sequelize.js") 3 | const Sequelize = require('sequelize') 4 | 5 | const User = sequelize.define('users', { 6 | class: { 7 | type: Sequelize.INTEGER, 8 | allowNull: false, 9 | unique: false, 10 | defaultValue: 0, 11 | comment: '用户等级' 12 | }, 13 | password: { 14 | type: Sequelize.STRING, 15 | allowNull: false, 16 | comment: '用户登录密码' 17 | }, 18 | email: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | unique: true, 22 | comment: '用户登录邮箱' 23 | }, 24 | register_ip: { 25 | type: Sequelize.STRING, 26 | comment: '注册所用ip地址' 27 | }, 28 | login_ip: { 29 | type: Sequelize.STRING, 30 | comment: '最后登录所用ip地址' 31 | }, 32 | ip: { 33 | type: Sequelize.STRING 34 | } 35 | } 36 | ) 37 | 38 | User.sync() 39 | 40 | module.exports = User -------------------------------------------------------------------------------- /utils/session_store.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Redis = require("ioredis") 3 | const { Store } = require("koa-session2") 4 | const config = require('../config') 5 | 6 | 7 | class RedisStore extends Store { 8 | constructor() { 9 | super() 10 | this.redis = new Redis(config.redis) 11 | } 12 | 13 | async get(sid) { 14 | let data = await this.redis.get(`SESSION:${sid}`) 15 | return JSON.parse(data) 16 | } 17 | 18 | async set(session, { sid = this.getID(24), maxAge = 24 * 3600 * 1000 } = {}) { 19 | try { 20 | // Use redis set EX to automatically drop expired sessions 21 | await this.redis.set(`SESSION:${sid}`, JSON.stringify(session), 'EX', maxAge / 1000) 22 | } catch (e) {} 23 | return sid 24 | } 25 | 26 | async destroy(sid) { 27 | return await this.redis.del(`SESSION:${sid}`) 28 | } 29 | } 30 | 31 | module.exports = RedisStore 32 | -------------------------------------------------------------------------------- /models/cache.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const sequelize = require("./sequelize.js") 3 | const Sequelize = require('sequelize') 4 | 5 | const Cache = sequelize.define('caches', { 6 | info_hash: { 7 | type: Sequelize.STRING, 8 | allowNull: false 9 | }, 10 | hit: { 11 | type: Sequelize.INTEGER, 12 | allowNull: false 13 | }, 14 | create_time: { 15 | type: Sequelize.DATE, 16 | allowNull: false 17 | }, 18 | last_hit_time: { 19 | type: Sequelize.DATE, 20 | allowNull: false 21 | }, 22 | info_hash: { 23 | type: Sequelize.STRING, 24 | allowNull: false 25 | }, 26 | key: { 27 | type: Sequelize.STRING 28 | }, 29 | name: { 30 | type: Sequelize.STRING 31 | } 32 | } 33 | ) 34 | 35 | Cache.getCacheByInfoHash = async (infoHash) => { 36 | return await Cache.findOne({where: {info_hash: infoHash}}) 37 | } 38 | 39 | Cache.sync() 40 | 41 | module.exports = Cache 42 | -------------------------------------------------------------------------------- /utils/signature.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var bencode = require('bencode') 3 | var decode = require('codepage').utils.decode 4 | var sha1 = require('sha1') 5 | var {Base64} = require('js-base64') 6 | var config = require('../config') 7 | var md5 = require('md5') 8 | 9 | 10 | exports.getSignatureUrl = (userId, taskId, node, file) => { 11 | let filePath = `${file.torrent_info_hash}/${file.path}${file.name}` 12 | let filePathB64 = Base64.encode(filePath).replace('/', '-') 13 | let timeStamp = Date.parse(new Date()) / 1000 14 | let signStr = `${filePathB64}${taskId}${userId}${timeStamp}${config.core.file_signature_key}` 15 | let signature = md5(signStr) 16 | let fileName = encodeURI(file.name) 17 | let links = [] 18 | node.cdn.forEach((cdn, idx) => { 19 | let url = `${cdn.url}/files/${filePathB64}/${taskId}/${userId}/${timeStamp}/${signature}/${fileName}` 20 | links.push({url: url, location: cdn.location}) 21 | }) 22 | return links 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | .vscode 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rain", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "node app.js", 7 | "pm2": "pm2 start app.js" 8 | }, 9 | "dependencies": { 10 | "async-busboy": "^0.6.1", 11 | "axios": "^0.16.2", 12 | "bencode": "^0.12.0", 13 | "busboy": "^0.2.14", 14 | "ccap": "^0.6.10", 15 | "codepage": "^1.9.0", 16 | "debug": "^2.6.8", 17 | "emailjs": "^1.0.9", 18 | "form-data": "^2.2.0", 19 | "ioredis": "^2.5.0", 20 | "js-base64": "^2.1.9", 21 | "koa": "^2.2.0", 22 | "koa-bodyparser": "^3.2.0", 23 | "koa-convert": "^1.2.0", 24 | "koa-json": "^2.0.2", 25 | "koa-json-error": "^3.1.2", 26 | "koa-logger": "^2.0.1", 27 | "koa-onerror": "^1.2.1", 28 | "koa-router": "^7.2.0", 29 | "koa-session2": "^2.2.4", 30 | "koa-simple-ratelimit": "^2.2.3", 31 | "log4js": "^1.1.1", 32 | "md5": "^2.2.1", 33 | "methods": "^1.1.2", 34 | "moment": "^2.18.1", 35 | "mysql": "^2.13.0", 36 | "mysql2": "^1.3.5", 37 | "node-schedule": "^1.2.3", 38 | "path-to-regexp": "^1.7.0", 39 | "sequelize": "^4.1.0", 40 | "sha1": "^1.1.1", 41 | "uuid": "^3.1.0" 42 | }, 43 | "devDependencies": { 44 | "nodemon": "^1.8.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/node_api.js: -------------------------------------------------------------------------------- 1 | var requests = require('../utils/requests') 2 | var Node = require('../models/node') 3 | var {Base64} = require('js-base64') 4 | var qs = require('qs') 5 | 6 | 7 | var request = async (node, method, url, timeout, data) => { 8 | let config = { 9 | method: method, 10 | url: url, 11 | baseURL: node.baseURL, 12 | timeout: timeout || 10000, 13 | params: { 14 | token: node.token 15 | } 16 | } 17 | if (method == 'post') { 18 | config.data = qs.stringify(data) 19 | } 20 | let res = await requests.request(config) 21 | if (!res || typeof res.data.errCode === 'undefined') { 22 | return { 23 | errCode: -1, 24 | errMsg: 'internal error please contact admin' 25 | } 26 | } 27 | return res.data 28 | } 29 | 30 | exports.ping = async (node) => { 31 | return await request(node, 'get', `/api/ping`) 32 | } 33 | 34 | exports.getTasks = async (node) => { 35 | return await request(node, 'get', `/api/node/tasks`) 36 | } 37 | 38 | exports.createTasks = async (node, torrentBin, infoHash) => { 39 | let data = { 40 | torrent_data: torrentBin.toString('base64'), 41 | info_hash: infoHash 42 | } 43 | return await request(node, 'post', `/api/node/task/create`, 30000, data) 44 | } 45 | 46 | exports.deleteTasks = async (node, infoHash) => { 47 | return await request(node, 'delete', `/api/node/task/delete/${infoHash}`) 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rain 2 | =================== 3 | rain frontend api 4 | 5 | What is this? 6 | ------------ 7 | 8 | A webgui for easy to build bittorrent client distributed system. 9 | But everything is just beginning ... 10 | 11 | Feature 12 | ------------ 13 | Based on html5 online preview 14 | 15 | Each user's task independent 16 | 17 | Generate http file links with expire time 18 | 19 | Roadmap 20 | ------------ 21 | Add provider for storage (c14,oss,etc..) 22 | 23 | Improved multi-node support (task balance etc..) 24 | 25 | Improved mangnet support 26 | 27 | Improved config 28 | 29 | Beautify web UI 30 | 31 | Setup 32 | ------------ 33 | Install node v8.0.0+ 34 | 35 | Configure: 36 | 37 | /config.js 38 | /config/rain.conf 39 | 40 | Install dependents 41 | 42 | > cd ${PATH_TO_REPO} 43 | > npm install 44 | 45 | Run 46 | 47 | > node app.js 48 | > cd cron & node task_sysnc.js # run sync task for sync task from frog 49 | > cd cron & node task_purge.js # run purge task for cleanup expire task 50 | 51 | Test and Reload OpenResty with config: 52 | 53 | > ln -s ${PATH_TO_REPO}/config/rain.conf ${PATH_TO_OPENRESTY}/nginx/conf/site-enabled/ 54 | > nginx -t 55 | > nginx -s reload 56 | 57 | Contributing 58 | ------------ 59 | 60 | Contributions, complaints, criticisms, and whatever else are welcome. The source 61 | code and issue tracker can be found on GitHub. 62 | 63 | License 64 | ------- 65 | MIT license. See ``LICENSE`` for details. 66 | 67 | Koa: https://github.com/koajs/koa 68 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | sequelize: { 3 | dialect: 'mysql', 4 | database: '${MYSQL_DB}', 5 | username: '${MYSQL_USERNAME}', 6 | password: '${MYSQL_PASSWORD}', 7 | host: '${MYSQL_HOST}', 8 | port: 3306, 9 | pool: { 10 | max: 10, 11 | min: 0, 12 | idle: 10000 13 | }, 14 | define: { 15 | underscored: true, 16 | freezeTableName: true, 17 | charset: 'utf8mb4', 18 | collate: 'utf8mb4_general_ci' 19 | }, 20 | timezone: '+08:00' 21 | }, 22 | redis: { 23 | host: '127.0.0.1' 24 | }, 25 | core: { 26 | torrent_bin_max_length: 1 * 1024 * 1024, // 1MB 27 | torrent_files_max_length: 10 * 1024 * 1024 * 1024, // 10GB 28 | torrent_files_max_count: 1024, 29 | torrent_expire_days: 30, 30 | torrent_failed_days: 7, // todo 31 | torrent_failed_percent: 0.1, // todo 32 | file_signature_key: '${SAME_AS_OPENRESTY_CONF}', 33 | download_node: 'node-0', // todo 34 | torrent_cucurrent_max: 6, // todo 35 | api_token: 'changeme', 36 | force_purge: true, 37 | force_purge_space: 5 * 1024 * 1024 * 1024 // if node space lower than 5GB 38 | }, 39 | node: [ 40 | { 41 | name: 'node-0', 42 | cdn: [ 43 | { 44 | location: 'CA', 45 | url: 'http://${FROG_NODE_URL}' 46 | } 47 | ], 48 | baseURL: 'http://${FROG_NODE_URL}', 49 | weight: 5, 50 | token: 'changeme' 51 | } 52 | ] 53 | } 54 | 55 | module.exports = config 56 | -------------------------------------------------------------------------------- /cron/task_sync.js: -------------------------------------------------------------------------------- 1 | var requests = require('../utils/requests') 2 | var Node = require('../models/node') 3 | var Task = require('../models/task') 4 | const config = require('../config') 5 | const _ = require('lodash') 6 | const common = require('../utils/common') 7 | 8 | const API_GW = 'http://127.0.0.1:3001' 9 | 10 | async function taskSync(node) { 11 | let res = await requests.request({ 12 | method: 'get', 13 | url: `${API_GW}/api/nodes/${node.name}/tasks`, 14 | params: { 15 | token: config.core.api_token 16 | } 17 | }) 18 | let updated_cnt = 0 19 | for (let infoHash in res.data.data) { 20 | let task = res.data.data[infoHash] 21 | let ret = await Task.update({ 22 | num_peers: task.num_peers, 23 | progress: task.progress, 24 | download_payload_rate: task.download_payload_rate, 25 | eta: task.eta, 26 | state: task.state, 27 | total_size: task.total_size, 28 | total_done: task.total_done 29 | },{ 30 | where:{ 31 | info_hash: infoHash, 32 | state: ['Created', 'Queued', 'Downloading', 'Seeding', 'Paused', 'Error'] 33 | } 34 | }) 35 | updated_cnt += ret[0] 36 | } 37 | return updated_cnt 38 | } 39 | 40 | async function loooooop () { 41 | while (1) { 42 | for (let idx in config.node) { 43 | try { 44 | let node = config.node[idx] 45 | let cnt = await taskSync(node) 46 | console.log(`sync: ${cnt} task info from node ${node.name} to db`) 47 | } catch (e) { 48 | console.log(e) 49 | } finally { 50 | await common.sleep(5000) 51 | } 52 | } 53 | } 54 | } 55 | 56 | loooooop() 57 | -------------------------------------------------------------------------------- /controller/capi/torrent.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const router = require('koa-router')() 3 | const errdef = require('./../../utils/errdef') 4 | const config = require('./../../config') 5 | const Redis = require("ioredis") 6 | const redis = new Redis(config.redis) 7 | const asyncBusboy = require('async-busboy') 8 | const fs = require('fs') 9 | const util = require('util') 10 | const readFile = util.promisify(fs.readFile) 11 | const deleteFile = util.promisify(fs.unlink) 12 | const {getTorrentExtraInfo} = require('./../../utils/torrent') 13 | const regMagnetHash = /([a-fA-F0-9]{40})/ 14 | const regBtcacheKey = /name="key" value="([\s\S]+?)"/ 15 | 16 | router.prefix('/capi/torrent') 17 | 18 | router.post('/upload', uploadTorrent) 19 | 20 | 21 | // fetch torrent from 3rd site 22 | async function uploadTorrent(ctx, next) { 23 | let {files, fields} = await asyncBusboy(ctx.req, {limits: { 24 | fileSize: 1 * 1024 * 1024 25 | }}) 26 | 27 | let torrentBin = await readFile(files[0].path) 28 | await deleteFile(files[0].path) 29 | if (torrentBin.length > config.core.torrent_bin_max_length) { 30 | ctx.throw(500, errdef.ERR_TORRENT_BIN_TOO_LARGE) 31 | } 32 | 33 | // parser torrent 34 | let torrentExtraData = null 35 | try { 36 | torrentExtraData = getTorrentExtraInfo(torrentBin) 37 | } catch (e) { 38 | // console.log(e) 39 | ctx.throw(500, errdef.ERR_TORRENT_BIN_INVAILD) 40 | } 41 | if (torrentExtraData.torrentFiles.length > config.core.torrent_files_max_count) { 42 | ctx.throw(500, errdef.ERR_TORRENT_FILES_TOO_MUCH) 43 | } else if (torrentExtraData.torrentLength > config.core.torrent_files_max_length) { 44 | ctx.throw(500, errdef.ERR_TORRENT_FILES_LENGTH_TOO_LARGE) 45 | } 46 | 47 | // put torrent to redis 48 | let torrentKey = `torrent:bin:${torrentExtraData.infoHash}` 49 | await redis.set(torrentKey, torrentBin, 'EX', 300) 50 | 51 | ctx.body = { 52 | data: torrentExtraData.infoHash, 53 | errorMsg: 'upload success', 54 | errCode: 0} 55 | } 56 | 57 | module.exports = router 58 | -------------------------------------------------------------------------------- /utils/errdef.js: -------------------------------------------------------------------------------- 1 | 2 | exports.ERR_MISSING_ARGUMENT = {errCode: 10001, errMsg: 'missing request argument'} 3 | exports.ERR_MISSING_SESSION = {errCode: 10002, errMsg: 'please login'} 4 | exports.ERR_SEND_MAIL = {errCode: 10003, errMsg: 'email send failed please retry later'} 5 | exports.ERR_INVAILD_ANSWERED = {errCode: 10003, errMsg: 'check your captcha answered'} 6 | // user 7 | exports.ERR_INVAILD_EMAIL = {errCode: 10100, errMsg: 'invalid email'} 8 | exports.ERR_INVAILD_PASSWORD = {errCode: 10101, errMsg: 'invalid password'} 9 | exports.ERR_INVAILD_CAPTCHA = {errCode: 10102, errMsg: 'invalid captcha'} 10 | exports.ERR_EMAIL_ALREAD_EXIST = {errCode: 10103, errMsg: 'email already exist'} 11 | exports.ERR_INVAILD_INFOHASH = {errCode: 10104, errMsg: 'invalid torrent info_hash'} 12 | exports.ERR_INVAILD_EMAIL_OR_PASSWORD = {errCode: 10105, errMsg: 'invalid email or password'} 13 | // torrent 14 | exports.ERR_TORRENT_BIN_NOT_FOUND = {errCode: 10200, errMsg: 'please upload torrent'} 15 | exports.ERR_TORRENT_BIN_INVAILD = {errCode: 10201, errMsg: 'invalid torrent'} 16 | exports.ERR_TORRENT_BIN_TOO_LARGE = {errCode: 10202, errMsg: 'torrent too large'} 17 | exports.ERR_TORRENT_FILES_LENGTH_TOO_LARGE = {errCode: 10203, errMsg: 'torrent files length too large'} 18 | exports.ERR_TORRENT_FILES_TOO_MUCH = {errCode: 10204, errMsg: 'torrent fils too much'} 19 | // task 20 | exports.ERR_TASK_NOT_FOUND = {errCode: 10300, errMsg: 'task not found'} 21 | exports.ERR_TASK_EXISTS = {errCode: 10301, errMsg: 'task already exists'} 22 | exports.ERR_TOO_MANY_TASH = {errCode: 10302, errMsg: 'too many task in processing'} 23 | // file 24 | exports.ERR_FILE_NOT_FOUND = {errCode: 10400, errMsg: 'file not found'} 25 | // api 26 | exports.ERR_NODE_RESPONSE_ERROR = {errCode: 10500, errMsg: 'node response error'} 27 | // magnet 28 | exports.ERR_MAGNET_FORMAT = {errCode: 10600, errMsg: 'please input magnet or hash'} 29 | exports.ERR_MAGNET_NOT_FOUND = {errCode: 10600, errMsg: 'magnet not found'} 30 | exports.ERR_MAGNET_FETCH_ERROR = {errCode: 10601, errMsg: 'magnet fetch error'} 31 | 32 | exports.formatError = (err) => { 33 | if (err.message === 'Not Found') { 34 | return {errCode: 10001, errMsg: 'resource your request not found'} 35 | } else { 36 | console.log(err) 37 | return { 38 | errCode: err.errCode || 10000, 39 | errMsg: err.errMsg || 'undefine error' 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /utils/torrent.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var bencode = require('bencode') 3 | var decode = require('codepage').utils.decode 4 | var sha1 = require('sha1') 5 | 6 | exports.getTorrentExtraInfo = (torrentBin) => { 7 | const torrent = bencode.decode(torrentBin) 8 | let codepage = 65001 // magic utf8 9 | if (torrent.encoding) { 10 | // rutorrent 11 | switch (decode(codepage, torrent.encoding).toLowerCase().replace('-', '')) { 12 | case ('big5'): 13 | codepage = 950 14 | break 15 | case ('gbk'): 16 | codepage = 936 17 | break 18 | case ('shiftjis'): 19 | codepage = 932 20 | break 21 | case ('utf16'): 22 | encoding = 1201 // magic 23 | break 24 | } 25 | } else if (torrent.codepage) { 26 | codepage = torrent.codepage 27 | } 28 | 29 | let torrentLength = 0 30 | let torrentFiles = [] 31 | let torrentName = decode(codepage, torrent.info.name) 32 | if (!torrent.info.files) { 33 | // single file 34 | torrentLength = torrent.info.length 35 | torrentFiles.push({ 36 | path: '', 37 | name: torrentName, 38 | length: torrentLength 39 | }) 40 | } else { 41 | torrent.info.files.forEach((file, idx) => { 42 | let path = decode(codepage, file.path[0]) 43 | if (path.startsWith('_____padding_file_')) { 44 | // fucking BitComet padding file 45 | } else { 46 | let filePath = torrentName + '/' 47 | let fileName = '' 48 | if (file.path.length > 1) { 49 | file.path.forEach((subPath, idx) => { 50 | if (file.path.length == idx + 1) { 51 | fileName = decode(codepage, subPath) 52 | } else { 53 | filePath += decode(codepage, subPath) + '/' 54 | } 55 | }) 56 | } else { 57 | fileName = decode(codepage, file.path[0]) 58 | } 59 | torrentFiles.push({ 60 | path: filePath, 61 | name: fileName, 62 | length: file.length 63 | }) 64 | torrentLength += file.length 65 | } 66 | }) 67 | } 68 | 69 | // calc infoHash 70 | let infoHash = sha1(bencode.encode(torrent.info)) 71 | 72 | return { 73 | torrentLength, torrentFiles, torrentName, infoHash 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /controller/api.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var router = require('koa-router')() 3 | var errdef = require('../utils/errdef') 4 | var Task = require('../models/task') 5 | var Node = require('../models/node') 6 | var Cache = require('../models/cache') 7 | var NodeApi = require('../api/node_api') 8 | 9 | 10 | router.prefix('/api') 11 | 12 | router.get('/tasks', tasks) 13 | router.delete('/tasks/:infoHash/delete', tasksDelete) 14 | 15 | router.get('/nodes/:nodeName/ping', nodePing) 16 | router.get('/nodes/:nodeName/tasks', nodeTasks) 17 | 18 | 19 | async function tasks(ctx, next) { 20 | let tasks = null 21 | if (ctx.query.expire === 'true') { 22 | tasks = await Task.getExpireTask() 23 | } else if (ctx.query.info_hash) { 24 | tasks = await Task.getTask({ 25 | info_hash: ctx.query.info_hash 26 | }) 27 | } else if (ctx.query.unstorage === 'true') { 28 | tasks = await Task.getUnstorageTask() 29 | } else if (ctx.query.oldest === 'true') { 30 | tasks = await Task.findAll({ 31 | where: { 32 | node_name: ctx.query.node_name 33 | }, 34 | order: [['create_time', 'ASC']], 35 | limit: 1 36 | }) 37 | } else { 38 | ctx.throw(400, errdef.ERR_MISSING_ARGUMENT) 39 | } 40 | ctx.body = { 41 | data: tasks 42 | } 43 | } 44 | 45 | async function tasksDelete(ctx, next) { 46 | let tasks = await Task.getTask({ 47 | info_hash: ctx.params.infoHash 48 | }) 49 | if (tasks.length === 0) { 50 | ctx.throw(500, errdef.ERR_TASK_NOT_FOUND) 51 | } 52 | let node = await Node.getNode(tasks[0].node_name) 53 | let res = await NodeApi.deleteTasks(node, tasks[0].info_hash) 54 | // note: if node call rpc success or task not exists errCode 0 55 | if (res.errCode !== 0) { 56 | ctx.throw(500, errdef.ERR_NODE_RESPONSE_ERROR) 57 | } 58 | // delete 59 | for (let idx in tasks) { 60 | tasks[idx].destroy() 61 | } 62 | ctx.body = { 63 | 'errCode': 0, 64 | 'data': '' 65 | } 66 | } 67 | 68 | async function nodePing(ctx, next) { 69 | let node = Node.getNode(ctx.params.nodeName) 70 | 71 | let res = await NodeApi.ping(node) 72 | if (res.errCode !== 0) { 73 | ctx.throw(500, errdef.ERR_NODE_RESPONSE_ERROR) 74 | } 75 | ctx.body = { 76 | 'errCode': 0, 77 | 'data': res.data 78 | } 79 | } 80 | 81 | async function nodeTasks(ctx, next) { 82 | let node = Node.getNode(ctx.params.nodeName) 83 | let res = await NodeApi.getTasks(node) 84 | if (res.errCode !== 0) { 85 | ctx.throw(500, errdef.ERR_NODE_RESPONSE_ERROR) 86 | } 87 | ctx.body = { 88 | 'errCode': 0, 89 | 'data': res.data 90 | } 91 | } 92 | 93 | module.exports = router 94 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const app = new Koa() 3 | const json = require('koa-json') 4 | const onerror = require('koa-onerror') 5 | const error = require('koa-json-error') 6 | const bodyparser = require('koa-bodyparser')() 7 | const logger = require('koa-logger') 8 | const config = require('./config') 9 | const session = require("koa-session2") 10 | const ratelimit = require('koa-simple-ratelimit') 11 | const SessionStore = require("./utils/session_store.js") 12 | const errdef = require('./utils/errdef') 13 | const Redis = require('ioredis') 14 | const redis = new Redis(config.redis) 15 | 16 | const magnet = require('./controller/capi/magnet') 17 | const torrent = require('./controller/capi/torrent') 18 | const user = require('./controller/capi/user') 19 | const task = require('./controller/capi/task') 20 | const api = require('./controller/api') 21 | const captcha = require('./controller/capi/captcha') 22 | 23 | // trust X-Forwarded 24 | app.proxy = true 25 | //onerror(app) 26 | // json error style 27 | app.use(error(errdef.formatError)) 28 | // json body 29 | app.use(async (ctx, next) => { 30 | if (ctx.path === '/capi/torrent/upload') ctx.disableBodyParser = true 31 | await next() 32 | }) 33 | app.use(bodyparser) 34 | // json response 35 | app.use(json()) 36 | // logger 37 | app.use(logger()) 38 | // session 39 | app.use(session({ 40 | key: 'session', 41 | store: new SessionStore(), 42 | // 24H 43 | maxAge: 1000 * 60 * 60 * 24 44 | })) 45 | // session check 46 | app.use(async (ctx, next) => { 47 | let bypass = false 48 | if (ctx.url.startsWith('/capi/')) { 49 | if (ctx.url.startsWith('/capi/user') || ctx.url.startsWith('/capi/captcha')) { 50 | bypass = true 51 | } else if (ctx.session.userInfo) { 52 | ctx.userId = ctx.session.userInfo.id 53 | bypass = true 54 | } 55 | } 56 | else if (ctx.url.startsWith('/api/')) { 57 | if (ctx.query.token === config.core.api_token) bypass = true 58 | } else { 59 | bypass = true 60 | } 61 | if (!bypass) { 62 | ctx.throw(401, errdef.ERR_MISSING_SESSION) 63 | } else { 64 | await next() 65 | } 66 | }) 67 | 68 | // limit rate by user 69 | app.use(ratelimit({ 70 | db: redis, 71 | duration: 10 * 1000, 72 | max: 6, 73 | id: function (ctx) { 74 | return `userId:${ctx.userId}` || ctx.ip 75 | }, 76 | blacklist: [], 77 | whitelist: [] 78 | })) 79 | 80 | // routes 81 | app.use(torrent.routes(), torrent.allowedMethods()) 82 | app.use(magnet.routes(), magnet.allowedMethods()) 83 | app.use(user.routes(), user.allowedMethods()) 84 | app.use(task.routes(), task.allowedMethods()) 85 | app.use(api.routes(), api.allowedMethods()) 86 | app.use(captcha.routes(), captcha.allowedMethods()) 87 | 88 | module.exports = app.listen(3001) 89 | -------------------------------------------------------------------------------- /cron/task_purge.js: -------------------------------------------------------------------------------- 1 | var requests = require('../utils/requests') 2 | const config = require('../config') 3 | const common = require('../utils/common') 4 | const _ = require('lodash') 5 | 6 | const API_GW = 'http://127.0.0.1:3001' 7 | 8 | async function taskPurge() { 9 | let res = await requests.request({ 10 | method: 'get', 11 | url: `${API_GW}/api/tasks`, 12 | params: { 13 | expire: 'true', 14 | token: config.core.api_token 15 | } 16 | }) 17 | let updated_cnt = 0 18 | 19 | for (let idx in res.data.data) { 20 | let m = res.data.data[idx] 21 | console.log(`will delete ${m.info_hash}`) 22 | await common.sleep(10 * 1000) 23 | let ret = await requests.request({ 24 | method: 'delete', 25 | url: `${API_GW}/api/tasks/${m.info_hash}/delete`, 26 | params: { 27 | token: config.core.api_token 28 | } 29 | }) 30 | let msg = 'success' 31 | if (!ret) { 32 | msg = 'failed' 33 | } else { 34 | updated_cnt += 1 35 | } 36 | console.log(`delete ${m.info_hash} ${msg} ... sleep 60s`) 37 | await common.sleep(60 * 1000) 38 | } 39 | return updated_cnt 40 | } 41 | 42 | async function taskPrugeForce(node) { 43 | // check free disk space by ping 44 | let res = await requests.request({ 45 | method: 'get', 46 | url: `${API_GW}/api/nodes/${node.name}/ping`, 47 | params: { 48 | token: config.core.api_token 49 | } 50 | }) 51 | if (res.data.data.available > config.core.force_purge_space) return 0 52 | // get oldest task 53 | res = await requests.request({ 54 | method: 'get', 55 | url: `${API_GW}/api/tasks`, 56 | params: { 57 | oldest: 'true', 58 | node_name: node.name, 59 | token: config.core.api_token 60 | } 61 | }) 62 | // do delete 63 | if (res.data.data.length == 0) throw new Error('no task found.') 64 | let infoHash = res.data.data[0].info_hash 65 | console.log(`will delete ${infoHash}`) 66 | await common.sleep(10 * 1000) 67 | let ret = await requests.request({ 68 | method: 'delete', 69 | url: `${API_GW}/api/tasks/${infoHash}/delete`, 70 | params: { 71 | token: config.core.api_token 72 | } 73 | }) 74 | if (!ret) return 0 75 | return 1 76 | } 77 | 78 | async function loooooop () { 79 | while (1) { 80 | try { 81 | let cnt = await taskPurge() 82 | // free space on each node 83 | if (config.core.force_purge) { 84 | for (let idx in config.node) { 85 | let node = config.node[idx] 86 | try { 87 | success = await taskPrugeForce(node) 88 | if (success) { 89 | cnt += 1 90 | break 91 | } 92 | } catch (e) { 93 | console.log(e) 94 | } 95 | } 96 | } 97 | console.log(`purge: ${cnt} task purge from rain`) 98 | } catch (e) { 99 | console.log(e) 100 | } finally { 101 | await common.sleep(30000) 102 | } 103 | } 104 | } 105 | 106 | loooooop() 107 | -------------------------------------------------------------------------------- /controller/capi/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const router = require('koa-router')() 3 | const errdef = require('./../../utils/errdef') 4 | const recaptcha = require('./../../utils/recaptcha') 5 | const User = require('./../../models/user.js') 6 | const regEmail = /(gmail.com|hotmail.com|qq.com)$/ 7 | const regEmail2 = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ 8 | const regPassword = /[\S]{6,16}$/ 9 | // const {smtpClient} = require('./../../utils/smtp') 10 | 11 | 12 | router.prefix('/capi/user') 13 | 14 | router.post('/register', register) 15 | router.post('/login', login) 16 | router.post('/logout', logout) 17 | 18 | 19 | async function register(ctx, next) { 20 | const data = ctx.request.body 21 | let vaildMail = false 22 | 23 | // email 24 | if (!regEmail.exec(data.email) || !regEmail2.exec(data.email)) { 25 | ctx.throw(400, errdef.ERR_INVAILD_EMAIL) 26 | } 27 | 28 | // password 29 | if (!regPassword.exec(data.password)) { 30 | ctx.throw(400, errdef.ERR_INVAILD_PASSWORD) 31 | } 32 | 33 | console.log(data.answered == ctx.session.captcha) 34 | console.log(data.answered, ctx.session.captcha) 35 | if (typeof ctx.session.captcha === 'undefined'|| 36 | typeof ctx.session.captcha === 'undefined' || 37 | data.answered !== ctx.session.captcha) { 38 | ctx.throw(400, errdef.ERR_INVAILD_ANSWERED) 39 | } 40 | 41 | // captcha 42 | // if(!recaptcha.checkCaptch(data.captcha)) { 43 | // ctx.throw(400, errdef.ERR_INVAILD_CAPTCHA) 44 | // } 45 | 46 | /* 47 | let success = null 48 | smtpClient.send({ 49 | text: "tedt", 50 | from: "@gmail.com", 51 | to: "@qq.com", 52 | subject: "testing emailjs" 53 | }, function(err, message) { 54 | // todo I need a promisifyall 55 | if (err) { 56 | success = false 57 | console.log(err) 58 | } else { 59 | success = true 60 | } 61 | }.bind(this)) 62 | // wait smtp response 63 | while (true) { 64 | if (success !== null) break 65 | await sleep(1000) 66 | } 67 | if (!success) { 68 | ctx.throw(500, errdef.ERR_SEND_MAIL) 69 | } 70 | */ 71 | 72 | // insert 73 | const result = await User.findOrCreate({ 74 | where: { 75 | email: data.email 76 | }, 77 | defaults: { 78 | password: data.password, 79 | email: data.email, 80 | register_ip: ctx.request.ip, 81 | ip: ctx.request.ip 82 | } 83 | }) 84 | const created = result[1] 85 | if (!created) { 86 | //exist 87 | ctx.throw(400, errdef.ERR_EMAIL_ALREAD_EXIST) 88 | } 89 | 90 | ctx.body = { 91 | errCode: 0, 92 | errMsg: 'register success' 93 | } 94 | } 95 | 96 | async function login(ctx, next) { 97 | let data = ctx.request.body 98 | let result = await User.findOne({ 99 | where: { 100 | email: data.email, 101 | password: data.password 102 | } 103 | }) 104 | if (!result) { 105 | ctx.throw(403, errdef.ERR_INVAILD_EMAIL_OR_PASSWORD) 106 | } 107 | 108 | // server side seesion 109 | ctx.session.userInfo = { 110 | id: result.id, 111 | nick_name: result.nick_name 112 | } 113 | 114 | ctx.body = { 115 | errCode: 0, 116 | errMsg: 'login success' 117 | } 118 | } 119 | 120 | async function logout(ctx, next) { 121 | let data = ctx.request.body 122 | 123 | // server side seesion 124 | ctx.session.userInfo = null 125 | 126 | ctx.body = { 127 | errCode: 0, 128 | errMsg: 'logout success' 129 | } 130 | } 131 | 132 | var sleep = (time) => { 133 | return new Promise((resolve, reject) => { 134 | setTimeout(() => { resolve() }, time) 135 | }) 136 | } 137 | 138 | module.exports = router 139 | -------------------------------------------------------------------------------- /controller/capi/task.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const router = require('koa-router')() 3 | const errdef = require('./../../utils/errdef') 4 | const Task = require('./../../models/task') 5 | const File = require('./../../models/file') 6 | const Node = require('./../../models/node') 7 | const {getSignatureUrl} = require('./../../utils/signature') 8 | const Redis = require('ioredis') 9 | const config = require('./../../config') 10 | const redis = new Redis(config.redis) 11 | const {getTorrentExtraInfo} = require('./../../utils/torrent') 12 | 13 | const regInfoHash = /^[a-fA-F0-9]{40}$/ 14 | 15 | 16 | router.prefix('/capi/tasks') 17 | 18 | router.post('/add', addTask) 19 | router.get('/', listTask) 20 | router.get('/:infoHash/files', taskFiles) 21 | 22 | 23 | async function addTask(ctx, next) { 24 | const data = ctx.request.body 25 | 26 | // infoHash 27 | if (!regInfoHash.exec(data.infoHash)) { 28 | ctx.throw(400, errdef.ERR_INVAILD_INFOHASH) 29 | } 30 | 31 | // get 32 | let tasks = await Task.findAll({ 33 | where: { 34 | user_id: ctx.userId, 35 | progress: { 36 | $lt: 99.5 37 | }, 38 | state: ['CacheQueued', 'Downloading', 'Created'] 39 | } 40 | }) 41 | if (tasks.length >= config.core.torrent_cucurrent_max) { 42 | ctx.throw(400, errdef.ERR_TOO_MANY_TASH) 43 | } 44 | 45 | // check task if added by user 46 | let task = await Task.findOne({ 47 | where: { 48 | user_id: ctx.userId, 49 | info_hash: data.infoHash 50 | } 51 | }) 52 | if (task) { 53 | ctx.throw(400, errdef.ERR_TASK_EXISTS) 54 | } 55 | 56 | // get torrent from redis 57 | let torrentKey = `torrent:bin:${data.infoHash.toLowerCase()}` 58 | let torrent = await redis.getBuffer(torrentKey) 59 | if (!torrent) { 60 | ctx.throw(500, errdef.ERR_TORRENT_BIN_NOT_FOUND) 61 | } else if (torrent.length > config.core.torrent_bin_max_length) { 62 | ctx.throw(500, errdef.ERR_TORRENT_BIN_TOO_LARGE) 63 | } 64 | 65 | // parser torrent and create task 66 | let torrentExtraData = null 67 | try { 68 | torrentExtraData = getTorrentExtraInfo(torrent) 69 | } catch (e) { 70 | // console.log(e) 71 | ctx.throw(500, errdef.ERR_TORRENT_BIN_INVAILD) 72 | } 73 | if (torrentExtraData.torrentFiles.length > config.core.torrent_files_max_count) { 74 | ctx.throw(500, errdef.ERR_TORRENT_FILES_TOO_MUCH) 75 | } else if (torrentExtraData.torrentLength > config.core.torrent_files_max_length) { 76 | ctx.throw(500, errdef.ERR_TORRENT_FILES_LENGTH_TOO_LARGE) 77 | } 78 | 79 | // todo: chooise node 80 | let node = Node.getNode(config.core.download_node) 81 | task = await Task.createTask(ctx.userId, node, torrentExtraData, torrent) 82 | 83 | ctx.body = { 84 | errCode: 0, 85 | errMsg: 'add task success', 86 | data: task.id 87 | } 88 | } 89 | 90 | async function listTask(ctx, next) { 91 | const curPage = ctx.query.cur_page ? Number(ctx.query.cur_page) : 1 92 | let tasks = await Task.getTaskByUserId(ctx.userId, curPage) 93 | ctx.body = { 94 | errCode: 0, 95 | errMsg: 'success', 96 | 'total': tasks.count, 97 | 'data': tasks.rows 98 | } 99 | } 100 | 101 | async function taskFiles(ctx, next) { 102 | let task = await Task.findOne({ 103 | where: { 104 | user_id: ctx.userId, 105 | info_hash: ctx.params.infoHash 106 | } 107 | }) 108 | if (!task) { 109 | ctx.throw(400, errdef.ERR_TASK_NOT_FOUND) 110 | } 111 | 112 | let files = await File.getFiles({ 113 | torrent_info_hash: ctx.params.infoHash 114 | }) 115 | if (!files) { 116 | ctx.throw(404, errdef.ERR_FILE_NOT_FOUND) 117 | } 118 | 119 | let linkFiles = [] 120 | let node = Node.getNode(task.node_name) 121 | files.forEach((file, idx) => { 122 | linkFiles.push({ 123 | links: getSignatureUrl(ctx.userId, task.id, node, file), 124 | name: `${file.path}${file.name}`, 125 | size: file.size 126 | }) 127 | }) 128 | ctx.body = { 129 | 'errCode': 0, 130 | 'data': linkFiles, 131 | 'errMsg': 'success' 132 | } 133 | } 134 | 135 | module.exports = router 136 | -------------------------------------------------------------------------------- /models/task.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var sequelize = require("./sequelize.js") 3 | var Sequelize = require('sequelize') 4 | var config = require('../config') 5 | var File = require('./file') 6 | var Cache = require('./cache') 7 | var NodeApi = require('../api/node_api') 8 | const TASK_PER_PAGE = 12 9 | 10 | var Task = sequelize.define('tasks', { 11 | info_hash: { 12 | type: Sequelize.STRING, 13 | allowNull: false 14 | }, 15 | name: { 16 | type: Sequelize.STRING, 17 | allowNull: false 18 | }, 19 | user_id: { 20 | type: Sequelize.INTEGER, 21 | allowNull: false, 22 | }, 23 | node_name: { 24 | type: Sequelize.STRING, 25 | allowNull: false 26 | }, 27 | progress: { 28 | type: Sequelize.FLOAT, 29 | allowNull: false, 30 | defaultValue: 0 31 | }, 32 | num_peers: { 33 | type: Sequelize.INTEGER, 34 | allowNull: false, 35 | defaultValue: 0 36 | }, 37 | num_seeds: { 38 | type: Sequelize.INTEGER, 39 | allowNull: false, 40 | defaultValue: 0 41 | }, 42 | total_size: { 43 | type: Sequelize.BIGINT, 44 | allowNull: false, 45 | defaultValue: 0 46 | }, 47 | total_done: { 48 | type: Sequelize.BIGINT, 49 | allowNull: false, 50 | defaultValue: 0 51 | }, 52 | download_payload_rate: { 53 | type: Sequelize.INTEGER, 54 | allowNull: false, 55 | defaultValue: 0 56 | }, 57 | eta: { 58 | type: Sequelize.BIGINT, 59 | allowNull: false, 60 | defaultValue: 0 61 | }, 62 | state: { 63 | type: Sequelize.STRING, 64 | allowNull: false 65 | }, 66 | // todo migrate 67 | create_time: { 68 | type: Sequelize.DATE, 69 | allowNull: false, 70 | defaultValue: Sequelize.NOW 71 | } 72 | } 73 | ) 74 | 75 | Task.getTaskByUserId = async (userId, curPage) => { 76 | let filter = { 77 | where: { 78 | user_id: userId 79 | }, 80 | order: [['id', 'desc']] 81 | } 82 | if (curPage) { 83 | filter.offset = (curPage - 1) * TASK_PER_PAGE 84 | filter.limit = TASK_PER_PAGE 85 | } 86 | return await Task.findAndCountAll(filter) 87 | } 88 | 89 | Task.getTask = async (where) => { 90 | return await Task.findAll({where}) 91 | } 92 | 93 | Task.createTask = async (userId, node, torrentExtraData, torrent) => { 94 | let t = await sequelize.transaction() 95 | // insert task 96 | try { 97 | let progress = 0 98 | let state = 'Created' 99 | let nodeName = node.name 100 | let _task = await Task.findOne({where: {info_hash: torrentExtraData.infoHash}}) 101 | if (_task) { 102 | // copy state 103 | progress = _task.progress 104 | state = _task.state 105 | nodeName = _task.node_name 106 | } else { 107 | // get from cache storage 108 | let cache = await Cache.findOne({where: {info_hash: torrentExtraData.infoHash}}) 109 | if (cache) { 110 | progress = 99 111 | state = 'CacheQueued' 112 | } else { 113 | // call node api create task 114 | let res = await NodeApi.createTasks(node, torrent, torrentExtraData.infoHash) 115 | // console.log(res) 116 | if (res.errCode !== 0) { 117 | throw new Error('node create failed') 118 | } 119 | } 120 | } 121 | let taskInfo = { 122 | info_hash: torrentExtraData.infoHash, 123 | name: torrentExtraData.torrentName, 124 | user_id: userId, 125 | total_size: torrentExtraData.torrentLength, 126 | node_name: nodeName, 127 | state, 128 | progress 129 | } 130 | let task = await Task.create(taskInfo, {transaction: t}) 131 | let file = await File.findOne({where: {torrent_info_hash: torrentExtraData.infoHash}}) 132 | if (!file) { 133 | // files info not exists 134 | for (let idx in torrentExtraData.torrentFiles) { 135 | let file = torrentExtraData.torrentFiles[idx] 136 | let fileInfo = { 137 | path: file.path, 138 | name: file.name, 139 | size: file.length, 140 | torrent_info_hash: torrentExtraData.infoHash 141 | } 142 | await File.create(fileInfo, {transaction: t}) 143 | } 144 | } 145 | await t.commit() 146 | return task 147 | } catch (e) { 148 | await t.rollback() 149 | throw e 150 | } 151 | } 152 | 153 | Task.getExpireTask = async () => { 154 | // if task(info_hash) add datetime > $torrent_expire_days 155 | //select Distinct(`info_hash`) , `name`, max(`create_time`) from tasks group by `info_hash` 156 | let tasks = await Task.findAll({ 157 | where: { 158 | create_time: { 159 | $lt: ( d => new Date(d.setDate(d.getDate()-config.core.torrent_expire_days)) )(new Date) 160 | } 161 | }, 162 | attributes: [ 163 | [Sequelize.fn('DISTINCT', Sequelize.col('info_hash')) ,'info_hash'], 164 | ], 165 | group: ['info_hash'] 166 | }) 167 | return tasks 168 | } 169 | 170 | Task.getUnstorageTask = async () => { 171 | let res = await sequelize.query('SELECT DISTINCT A.`info_hash`, A.`node_name`\ 172 | FROM `tasks` A left join `caches` B \ 173 | on A.`info_hash` = B.`info_hash` \ 174 | WHERE A.progress=100 AND B.`info_hash` is null') 175 | if (res.length >= 1) return res[0] // ??? 176 | return [] 177 | } 178 | 179 | Task.sync() 180 | 181 | module.exports = Task 182 | -------------------------------------------------------------------------------- /controller/capi/magnet.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const router = require('koa-router')() 3 | const axios = require('axios') 4 | const Redis = require("ioredis") 5 | const uuidv4 = require('uuid/v4') 6 | const errdef = require('./../../utils/errdef') 7 | const {getTorrentExtraInfo} = require('./../../utils/torrent') 8 | const config = require('./../../config') 9 | const redis = new Redis(config.redis) 10 | const qs = require('qs') 11 | 12 | const regMagnetHash = /([a-fA-F0-9]{40})/ 13 | const regBtcacheKey = /name="key" value="([\s\S]+?)"/ 14 | const regTorrentOrgKey = /Hash Info/ 15 | 16 | router.prefix('/capi/magnet') 17 | 18 | router.get('/torrent/prefetch', magnet2torrentPrefetch) 19 | router.get('/torrent/prefetch/captcha', magnet2torrentPrefetchCaptcha) 20 | router.get('/torrent/fetch', magnet2torrentFetch) 21 | 22 | 23 | // fetch torrent from 3rd site 24 | async function magnet2torrentPrefetch(ctx, next) { 25 | let uuid = uuidv4() 26 | let magnet = ctx.query.magnet 27 | let errMsg = '' 28 | let errCode = 0 29 | let info = null 30 | let match = regMagnetHash.exec(magnet) 31 | if (!match) { 32 | ctx.throw(400, errdef.ERR_MAGNET_FORMAT) 33 | } 34 | let infoHash = match[1] 35 | let session = axios.create({timeout: 5000}) 36 | // get torrent page 37 | let res = await session.get(`http://btcache.me/torrent/${infoHash}`) 38 | match = regBtcacheKey.exec(res.data) 39 | if (match) { 40 | let key = match[1] 41 | // get captcha 42 | res = await axios.get(`http://btcache.me/captcha?t=${Math.ceil(Date.parse(new Date())/10000)}`, {responseType: 'arraybuffer', timeout: 2000}) 43 | let cookie = res.headers['set-cookie'][0] 44 | // console.log("cookie:", cookie) 45 | uuid = uuidv4() 46 | info = { 47 | site: 1, 48 | key, 49 | cookie, 50 | captcha: new Buffer(res.data, 'binary').toString('base64') 51 | } 52 | } 53 | 54 | if (!info) { 55 | res = await session.get(`http://www.torrent.org.cn/home/convert/magnet2torrent.html?hash=${infoHash}`) 56 | match = regTorrentOrgKey.exec(res.data) 57 | if (match) { 58 | let key = infoHash.toUpperCase() 59 | let cookie = res.headers['set-cookie'][0] 60 | // get captcha 61 | res = await session.get('http://www.torrent.org.cn/home/torrent/yanzhengma', 62 | { 63 | responseType: 'arraybuffer', 64 | headers: { 65 | Cookie: cookie 66 | } 67 | }) 68 | 69 | uuid = uuidv4() 70 | info = { 71 | site: 2, 72 | key, 73 | cookie, 74 | captcha: new Buffer(res.data, 'binary').toString('base64') 75 | } 76 | } 77 | } 78 | if (!info) { 79 | ctx.throw(404, errdef.ERR_MAGNET_NOT_FOUND) 80 | } 81 | await redis.set(`magnet:fetch:${uuid}`, JSON.stringify(info), 'EX', 300) 82 | ctx.body = { data: uuid, errMsg, errCode } 83 | } 84 | 85 | async function magnet2torrentPrefetchCaptcha(ctx, next) { 86 | let uuid = ctx.query.uuid 87 | let info = await redis.get(`magnet:fetch:${uuid}`) 88 | info = JSON.parse(info) 89 | let buf = new Buffer(info.captcha, 'base64') 90 | ctx.body = buf 91 | ctx.type = 'image/jpeg' 92 | } 93 | 94 | // fetch torrent from 3rd site 95 | async function magnet2torrentFetch(ctx, next) { 96 | let captcha = ctx.query.captcha 97 | let uuid = ctx.query.uuid 98 | let data = null 99 | let torrentBin = null 100 | let torrentExtraData = null 101 | let info = await redis.get(`magnet:fetch:${uuid}`) 102 | let res = null 103 | info = JSON.parse(info) 104 | switch (info.site) { 105 | case 1: 106 | // get torrent 107 | res = await axios({ 108 | method: 'post', 109 | baseURL: 'http://btcache.me/download', 110 | data: qs.stringify({ 111 | key: info.key, 112 | captcha: captcha 113 | }), 114 | timeout: 10000, 115 | headers: {'Cookie': info.cookie}, 116 | responseType: 'arraybuffer' 117 | }) 118 | torrentBin = res.data 119 | break 120 | case 2: 121 | // get torrent 122 | res = await axios({ 123 | method: 'get', 124 | baseURL: 'http://www.torrent.org.cn/Home/torrent/download.html', 125 | params: { 126 | hash: info.key, 127 | code: captcha 128 | }, 129 | timeout: 10000, 130 | headers: { 131 | Cookie: info.cookie 132 | }, 133 | responseType: 'arraybuffer' 134 | }) 135 | torrentBin = res.data 136 | break 137 | default: 138 | ctx.throw(400, 'site not vaild.') 139 | } 140 | 141 | // parser torrent 142 | try { 143 | torrentExtraData = getTorrentExtraInfo(res.data) 144 | } catch (e) { 145 | // console.log(e) 146 | ctx.throw(500, errdef.ERR_INVAILD_ANSWERED) 147 | } 148 | 149 | // store torrent to redis 150 | let torrentKey = `torrent:bin:${torrentExtraData.infoHash}` 151 | await redis.set(torrentKey, res.data, 'EX', 300) 152 | data = torrentExtraData.infoHash 153 | ctx.body = { data, errMsg: '', errCode: 0 } 154 | } 155 | 156 | 157 | module.exports = router 158 | --------------------------------------------------------------------------------