Harden auth, add AI smart-search and post-download organization
Security & correctness hardening: - Gate /__api/token/auth behind auth and self-scope every handler to the caller (was fully unauthenticated — account-takeover hole). - Escape LDAP filter values (injection) and reject empty-password binds. - Enforce per-torrent ownership so private torrents aren't exposed via IDOR. - Assorted cleanup: fix 'use static' typos, drop dead Torrent.migrate + the getTorrentData noUpdate flag, Buffer.alloc, __dirname-relative reads, res.statusCode in the error handler, const-scope pubsub. Login-gated proxy + anti-indexing: - Block all proxying for logged-out users via an auth-token cookie the front end mirrors from its token; serve a local login page instead of hitting TPB. - robots.txt disallow-all + X-Robots-Tag noindex. Torrent category: - Store a normalized category (TV/Movie/Music/Adult/App/Game/Other) mapped from the TPB category id; captured at add time (migration). Smart Search (movies/TV): - New /__api/search: TMDB title confirm -> scrape piratebay.party HTML -> Ollama ranks releases against quality prefs (x265/1080p/~1.5GB/subs, prefer uncut) returning a recommended pick, optional warned 4K, and other editions. Front-end Smart Search box + dialog feeding the existing add flow. Post-download organization -> Emby (public Movie/TV only): - Completion watcher files finished torrents: Ollama parses the release name, TMDB canonicalizes title/year, files main video (+subs) into the library with edition/quality-aware names (movies + TV SxxExx), stops seeding, and triggers an Emby library scan. Low-confidence matches are flagged, not mis-filed; correctable via "Fix match". Adds organizedAt/metadata columns. Shared helpers: controller/tmdb.js, controller/ollama.js. Config blocks for tmdb/ollama/search/emby/library/organize (secrets stay in gitignored secrets.js). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+15
-2
@@ -11,6 +11,12 @@ var userLUR = new LRUCache({
|
||||
maxAge: 60000,
|
||||
});
|
||||
|
||||
const escapeFilterValue = function(value){
|
||||
return String(value).replace(/[\0()*\\]/g, function(char){
|
||||
return '\\' + char.charCodeAt(0).toString(16).padStart(2, '0');
|
||||
});
|
||||
}
|
||||
|
||||
const user_parse = function(data){
|
||||
if(data[conf.userNameAttribute]){
|
||||
data.username = data[conf.userNameAttribute];
|
||||
@@ -89,7 +95,7 @@ User.get = async function(data, key){
|
||||
data.searchKey = data.searchKey || key || conf.userNameAttribute;
|
||||
data.searchValue = data.searchValue || data.uid;
|
||||
|
||||
let filter = `(&${conf.userFilter}(${data.searchKey}=${data.searchValue}))`;
|
||||
let filter = `(&${conf.userFilter}(${escapeFilterValue(data.searchKey)}=${escapeFilterValue(data.searchValue)}))`;
|
||||
if(userLUR.get(filter)) return userLUR.get(filter);
|
||||
|
||||
const client = new Client({
|
||||
@@ -136,7 +142,14 @@ User.exists = async function(data, key){
|
||||
|
||||
User.login = async function(data){
|
||||
try{
|
||||
|
||||
if(!data.password){
|
||||
let error = new Error('LDAPLoginFailed');
|
||||
error.name = 'LDAPLoginFailed';
|
||||
error.message = 'Invalid Credentials, login failed.';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let user = await this.get(data.uid || data[conf.userNameAttribute] || data.username);
|
||||
|
||||
const client = new Client({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('Torrents', 'category', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'Other'
|
||||
});
|
||||
},
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('Torrents', 'category');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('Torrents', 'organizedAt', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('Torrents', 'metadata', {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('Torrents', 'organizedAt');
|
||||
await queryInterface.removeColumn('Torrents', 'metadata');
|
||||
}
|
||||
};
|
||||
+32
-38
@@ -29,6 +29,21 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
|
||||
static trClient = tr_client;
|
||||
|
||||
// Map a raw TPB category id( e.g. 207) to one of our buckets. TPB groups by the
|
||||
// hundreds digit; TV shows are the exception pulled out of the Video group.
|
||||
static categoryFromTPB(id){
|
||||
id = parseInt(id, 10);
|
||||
if([205, 208, 212].includes(id)) return 'TV';
|
||||
return {
|
||||
1: 'Music',
|
||||
2: 'Movie',
|
||||
3: 'App',
|
||||
4: 'Game',
|
||||
5: 'Adult',
|
||||
6: 'Other',
|
||||
}[Math.floor(id / 100)] || 'Other';
|
||||
}
|
||||
|
||||
static async create(data, ...args){
|
||||
try{
|
||||
|
||||
@@ -50,6 +65,7 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
isPrivate: data.isPrivate,
|
||||
name: res.name,
|
||||
added_by: data.added_by,
|
||||
category: this.categoryFromTPB(data.category),
|
||||
status: 0,
|
||||
percentDone: 0,
|
||||
}, args);
|
||||
@@ -59,41 +75,7 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
}
|
||||
}
|
||||
|
||||
static async migrate(hashString, username){
|
||||
try{
|
||||
let exists = await this.findByPk(hashString);
|
||||
|
||||
if(exists){
|
||||
console.log('torrent in DB, skipping')
|
||||
return {}
|
||||
}
|
||||
|
||||
let res = ( await tr_client.get(hashString, [
|
||||
"eta", "percentDone", "status", "rateDownload",
|
||||
"errorString", "hashString", 'name',
|
||||
'downloadDir',
|
||||
'addedDate',
|
||||
'magnetLink',
|
||||
'files', //array of files
|
||||
'filesStats', // array of files with status
|
||||
'isFinished',
|
||||
'isStalled',
|
||||
'peers',
|
||||
'peersConnected', // array of peers,
|
||||
'sizeWhenDone',
|
||||
]) ).torrents[0];
|
||||
|
||||
// console.log('date:', res.addedDate, new Date(res.addedDate*1000), 'res:', res)
|
||||
|
||||
let instance = await this.build({createdAt: new Date(res.addedDate*1000), ...res, added_by: username});
|
||||
await instance.save();
|
||||
return {...res, ...instance.dataValues};
|
||||
}catch(error){
|
||||
console.error('migrate error', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getTorrentData(noUpdate){
|
||||
async getTorrentData(){
|
||||
try{
|
||||
|
||||
if(this.percentDone === 1) return this.dataValues
|
||||
@@ -113,8 +95,7 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
]) ).torrents[0];
|
||||
|
||||
await this.update(res);
|
||||
if(noUpdate) await this.save();
|
||||
|
||||
|
||||
return {...res, ...this.dataValues};
|
||||
}catch(error){
|
||||
if(error.code === 'ECONNREFUSED'){
|
||||
@@ -139,7 +120,7 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
}
|
||||
|
||||
async destroy(){
|
||||
await await this.constructor.trClient.remove(this.hashString, true);
|
||||
await this.constructor.trClient.remove(this.hashString, true);
|
||||
return await super.destroy()
|
||||
}
|
||||
}
|
||||
@@ -162,6 +143,11 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
defaultValue: false,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
category: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'Other',
|
||||
},
|
||||
added_by: {
|
||||
type: DataTypes.STRING,
|
||||
ldapModel: 'User',
|
||||
@@ -188,6 +174,14 @@ module.exports = (sequelize, DataTypes, Model) => {
|
||||
createdAt: {
|
||||
type: DataTypes.DATE
|
||||
},
|
||||
organizedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
},
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'Torrent',
|
||||
|
||||
Reference in New Issue
Block a user