From da8571fb1a5fbacf8bc82964d2e36f5407714161 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Thu, 16 Apr 2020 16:33:32 -0400 Subject: [PATCH 01/20] Added additional info when requests are rejected due to no auth Added two additional auth methods: registering and logging in. They have minimal functionality right now Added auth module which will handle all auth-related requests --- .gitignore | 1 + backend/app.js | 16 +++ backend/authentication/auth.js | 172 +++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 backend/authentication/auth.js diff --git a/.gitignore b/.gitignore index 935896d..1ba6f18 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ backend/appdata/archives/blacklist_audio.txt backend/appdata/archives/blacklist_video.txt backend/appdata/logs/combined.log backend/appdata/logs/error.log +backend/appdata/users.json diff --git a/backend/app.js b/backend/app.js index a8a7c82..aa057be 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,6 +1,7 @@ var async = require('async'); const { uuid } = require('uuidv4'); var fs = require('fs-extra'); +var auth = require('./authentication/auth'); var winston = require('winston'); var path = require('path'); var youtubedl = require('youtube-dl'); @@ -146,6 +147,9 @@ var descriptors = {}; app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); +// use passport +app.use(auth.passport.initialize()); + // objects function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date) { @@ -1253,6 +1257,7 @@ app.use(function(req, res, next) { } else if (req.path.includes('/api/video/') || req.path.includes('/api/audio/')) { next(); } else { + logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`); req.socket.end(); } }); @@ -2303,6 +2308,17 @@ app.get('/api/audio/:id', function(req , res){ }) }); +// user authentication + +app.post('/api/auth/register' + , auth.registerUser); +app.post('/api/auth/login' +// , auth.passport.authenticate('basic',{session:false}) // causes challenge pop-up on 401 + , auth.authenticateViaPassport + , auth.generateJWT + , auth.returnAuthResponse +); + app.use(function(req, res, next) { //if the request is not html then move along var accept = req.accepts('html', 'json', 'xml'); diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js new file mode 100644 index 0000000..7d62196 --- /dev/null +++ b/backend/authentication/auth.js @@ -0,0 +1,172 @@ +const low = require('lowdb') +const FileSync = require('lowdb/adapters/FileSync'); +const adapter = new FileSync('./appdata/users.json'); +const db = low(adapter); +db.defaults( + { + users: [] + } +).write(); + +/************************* + * Authentication module + ************************/ +var bcrypt = require('bcrypt'); +const saltRounds = 10; + +var jwt = require('jsonwebtoken'); +const JWT_EXPIRATION = (60 * 60); // one hour + +const { uuid } = require('uuidv4'); +const SERVER_SECRET = uuid(); + +exports.passport = require('passport'); +var BasicStrategy = require('passport-http').BasicStrategy; + +/*************************************** + * Register user with hashed password + **************************************/ +exports.registerUser = function(req, res) { + console.log('got here'); + var userid = req.body.userid; + var username = req.body.username; + var plaintextPassword = req.body.password; + + bcrypt.hash(plaintextPassword, saltRounds) + .then(function(hash) { + // check if user exists + if (db.get('users').find({uid: userid}).value()) { + // user id is taken! + res.status(409).send('UID is already taken!'); + } else if (db.get('users').find({name: username}).value()) { + // user name is taken! + res.status(409).send('User name is already taken!'); + } else { + // add to db + db.get('users').push({ + name: username, + uid: userid, + passhash: hash + }).write(); + } + }) + .then(function(result) { + res.send('registered'); + }) + .catch(function(err) { + if( err.code == 'ER_DUP_ENTRY' ) { + res.status(409).send('UserId already taken'); + } else { + console.log('failed TO register User'); + + // res.writeHead(500, {'Content-Type':'text/plain'}); + res.end(err); + } + }); +} + +/*************************************** + * Login methods + **************************************/ + +/************************************************* + * This gets called when passport.authenticate() + * gets called. + * + * This checks that the credentials are valid. + * If so, passes the user info to the next middleware. + ************************************************/ +exports.passport.use(new BasicStrategy( + function(userid, plainTextPassword, done) { +// console.log('BasicStrategy: verifying credentials'); + const user = db.get('users').find({uid: userid}).value(); + if (user) { + var hashedPwd = user.passhash; + return bcrypt.compare(plainTextPassword, hashedPwd); + } else { + return false; + } + } +)); + +/************************************************************* + * This is a wrapper for auth.passport.authenticate(). + * We use this to change WWW-Authenticate header so + * the browser doesn't pop-up challenge dialog box by default. + * Browser's will pop-up up dialog when status is 401 and + * "WWW-Authenticate:Basic..." + *************************************************************/ +exports.authenticateViaPassport = function(req, res, next) { + exports.passport.authenticate('basic',{session:false}, + function(err, user, info) { + if(!user){ + res.set('WWW-Authenticate', 'x'+info); // change to xBasic + res.status(401).send('Invalid Authentication'); + } else { + req.user = user; + next(); + } + } + )(req, res, next); +}; + +/********************************** + * Generating/Signing a JWT token + * And attaches the user info into + * the payload to be sent on every + * request. + *********************************/ +exports.generateJWT = function(req, res, next) { + var payload = { + exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION + , user: req.user, +// , role: role + }; + req.token = jwt.sign(payload, SERVER_SECRET); + next(); +} + +exports.returnAuthResponse = function(req, res) { + res.status(200).json({ + user: req.user, + token: req.token + }); +} + +/*************************************** + * Authorization: middleware that checks the + * JWT token for validity before allowing + * the user to access anything. + * + * It also passes the user object to the next + * middleware through res.locals + **************************************/ +exports.ensureAuthenticatedElseError = function(req, res, next) { + var token = getToken(req.query); + if( token ) { + try { + var payload = jwt.verify(token, SERVER_SECRET); + // console.log('payload: ' + JSON.stringify(payload)); + // check if user still exists in database if you'd like + res.locals.user = payload.user; + next(); + } catch(err) { + res.status(401).send('Invalid Authentication'); + } + } else { + res.status(401).send('Missing Authorization header'); + } +} + +function getToken(queryParams) { + if (queryParams && queryParams.jwt) { + var parted = queryParams.jwt.split(' '); + if (parted.length === 2) { + return parted[1]; + } else { + return null; + } + } else { + return null; + } +}; \ No newline at end of file From 1f3572a6307adfa1b4d3eb9ba88d6ca9b6c377be Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Thu, 16 Apr 2020 22:35:34 -0400 Subject: [PATCH 02/20] jwt auth scaffolding logging in now works UI login component created --- backend/app.js | 8 ++- backend/authentication/auth.js | 54 +++++++++++++++++-- src/app/app-routing.module.ts | 12 +++-- src/app/app.module.ts | 4 +- src/app/components/login/login.component.html | 22 ++++++++ src/app/components/login/login.component.scss | 6 +++ .../components/login/login.component.spec.ts | 25 +++++++++ src/app/components/login/login.component.ts | 31 +++++++++++ src/app/posts.services.ts | 36 ++++++++++++- 9 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 src/app/components/login/login.component.html create mode 100644 src/app/components/login/login.component.scss create mode 100644 src/app/components/login/login.component.spec.ts create mode 100644 src/app/components/login/login.component.ts diff --git a/backend/app.js b/backend/app.js index aa057be..1ffd547 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1629,9 +1629,14 @@ app.post('/api/fileStatusMp4', function(req, res) { // gets all download mp3s app.get('/api/getMp3s', function(req, res) { + const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); var mp3s = db.get('files.audio').value(); // getMp3s(); var playlists = db.get('playlists.audio').value(); + if (req.query.jwt && multiUserMode) { + // mp3s = db.get + } + res.send({ mp3s: mp3s, playlists: playlists @@ -2313,8 +2318,7 @@ app.get('/api/audio/:id', function(req , res){ app.post('/api/auth/register' , auth.registerUser); app.post('/api/auth/login' -// , auth.passport.authenticate('basic',{session:false}) // causes challenge pop-up on 401 - , auth.authenticateViaPassport + , auth.passport.authenticate('local', {}) , auth.generateJWT , auth.returnAuthResponse ); diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 7d62196..81392b8 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -8,6 +8,15 @@ db.defaults( } ).write(); +var LocalStrategy = require('passport-local').Strategy; +var JwtStrategy = require('passport-jwt').Strategy, + ExtractJwt = require('passport-jwt').ExtractJwt; +var opts = {} +opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); +opts.secretOrKey = 'secret'; +opts.issuer = 'example.com'; +opts.audience = 'example.com'; + /************************* * Authentication module ************************/ @@ -23,11 +32,18 @@ const SERVER_SECRET = uuid(); exports.passport = require('passport'); var BasicStrategy = require('passport-http').BasicStrategy; +exports.passport.serializeUser(function(user, done) { + done(null, user); +}); + +exports.passport.deserializeUser(function(user, done) { + done(null, user); +}); + /*************************************** * Register user with hashed password **************************************/ exports.registerUser = function(req, res) { - console.log('got here'); var userid = req.body.userid; var username = req.body.username; var plaintextPassword = req.body.password; @@ -76,10 +92,31 @@ exports.registerUser = function(req, res) { * This checks that the credentials are valid. * If so, passes the user info to the next middleware. ************************************************/ -exports.passport.use(new BasicStrategy( +exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { + const user = db.get('users').find({uid: jwt_payload.sub}).value(); + if (user) { + return done(null, user); + } else { + return done(null, false); + // or you could create a new account + } +})); + +exports.passport.use(new LocalStrategy({ + usernameField: 'userid', + passwordField: 'password'}, + function(username, password, done) { + const user = db.get('users').find({name: username}).value(); + if (!user) { return done(null, false); } + if (user) { + return done(null, bcrypt.compareSync(password, user.passhash) ? user : false); + } + } +)); + +/*passport.use(new BasicStrategy( function(userid, plainTextPassword, done) { -// console.log('BasicStrategy: verifying credentials'); - const user = db.get('users').find({uid: userid}).value(); + const user = db.get('users').find({name: userid}).value(); if (user) { var hashedPwd = user.passhash; return bcrypt.compare(plainTextPassword, hashedPwd); @@ -88,6 +125,7 @@ exports.passport.use(new BasicStrategy( } } )); +*/ /************************************************************* * This is a wrapper for auth.passport.authenticate(). @@ -96,6 +134,7 @@ exports.passport.use(new BasicStrategy( * Browser's will pop-up up dialog when status is 401 and * "WWW-Authenticate:Basic..." *************************************************************/ +/* exports.authenticateViaPassport = function(req, res, next) { exports.passport.authenticate('basic',{session:false}, function(err, user, info) { @@ -109,6 +148,7 @@ exports.authenticateViaPassport = function(req, res, next) { } )(req, res, next); }; +*/ /********************************** * Generating/Signing a JWT token @@ -158,6 +198,12 @@ exports.ensureAuthenticatedElseError = function(req, res, next) { } } +// video stuff + +exports.getUserVideos(type) { + +} + function getToken(queryParams) { if (queryParams && queryParams.jwt) { var parted = queryParams.jwt.split(' '); diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c4cce48..7ffbdf3 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -4,11 +4,15 @@ import { MainComponent } from './main/main.component'; import { PlayerComponent } from './player/player.component'; import { SubscriptionsComponent } from './subscriptions/subscriptions.component'; import { SubscriptionComponent } from './subscription/subscription/subscription.component'; +import { PostsService } from './posts.services'; +import { LoginComponent } from './components/login/login.component'; + const routes: Routes = [ - { path: 'home', component: MainComponent }, - { path: 'player', component: PlayerComponent}, - { path: 'subscriptions', component: SubscriptionsComponent }, - { path: 'subscription', component: SubscriptionComponent }, + { path: 'home', component: MainComponent, canActivate: [PostsService] }, + { path: 'player', component: PlayerComponent, canActivate: [PostsService]}, + { path: 'subscriptions', component: SubscriptionsComponent, canActivate: [PostsService] }, + { path: 'subscription', component: SubscriptionComponent, canActivate: [PostsService] }, + { path: 'login', component: LoginComponent }, { path: '', redirectTo: '/home', pathMatch: 'full' }, ]; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6f03650..ac0fc52 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -57,6 +57,7 @@ import { ArgModifierDialogComponent, HighlightPipe } from './dialogs/arg-modifie import { UpdaterComponent } from './updater/updater.component'; import { UpdateProgressDialogComponent } from './dialogs/update-progress-dialog/update-progress-dialog.component'; import { ShareMediaDialogComponent } from './dialogs/share-media-dialog/share-media-dialog.component'; +import { LoginComponent } from './components/login/login.component'; registerLocaleData(es, 'es'); export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps) { @@ -85,7 +86,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible HighlightPipe, UpdaterComponent, UpdateProgressDialogComponent, - ShareMediaDialogComponent + ShareMediaDialogComponent, + LoginComponent ], imports: [ CommonModule, diff --git a/src/app/components/login/login.component.html b/src/app/components/login/login.component.html new file mode 100644 index 0000000..f7e6b93 --- /dev/null +++ b/src/app/components/login/login.component.html @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/src/app/components/login/login.component.scss b/src/app/components/login/login.component.scss new file mode 100644 index 0000000..fe190de --- /dev/null +++ b/src/app/components/login/login.component.scss @@ -0,0 +1,6 @@ +.login-card { + max-width: 600px; + width: 80%; + margin: 0 auto; + margin-top: 20px; +} \ No newline at end of file diff --git a/src/app/components/login/login.component.spec.ts b/src/app/components/login/login.component.spec.ts new file mode 100644 index 0000000..d6d85a8 --- /dev/null +++ b/src/app/components/login/login.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ LoginComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts new file mode 100644 index 0000000..eef1015 --- /dev/null +++ b/src/app/components/login/login.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent implements OnInit { + + usernameInput = ''; + passwordInput = ''; + registrationEnabled = true; + loggingIn = false; + + constructor(private postsService: PostsService) { } + + ngOnInit(): void { + } + + login() { + this.loggingIn = true; + this.postsService.login(this.usernameInput, this.passwordInput).subscribe(res => { + this.loggingIn = false; + console.log(res); + }, err => { + this.loggingIn = false; + }); + } + +} diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 8aa05d1..d55fa39 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -5,12 +5,12 @@ import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch'; import 'rxjs/add/observable/throw'; import { THEMES_CONFIG } from '../themes'; -import { Router } from '@angular/router'; +import { Router, CanActivate } from '@angular/router'; import { DOCUMENT } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; @Injectable() -export class PostsService { +export class PostsService implements CanActivate { path = ''; audioFolder = ''; videoFolder = ''; @@ -24,6 +24,10 @@ export class PostsService { httpOptions = null; debugMode = false; + + isLoggedIn = false; + token = null; + user = null; constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document) { console.log('PostsService Initialized...'); // this.startPath = window.location.href + '/api/'; @@ -41,6 +45,11 @@ export class PostsService { }), }; } + canActivate(route, state): boolean { + console.log(route); + return true; + throw new Error('Method not implemented.'); + } setTheme(theme) { this.theme = this.THEMES_CONFIG[theme]; @@ -233,4 +242,27 @@ export class PostsService { return this.http.get('https://api.github.com/repos/tzahi12345/youtubedl-material/releases'); } + afterLogin(user, token) { + this.isLoggedIn = true; + this.user = user; + this.token = token; + + this.httpOptions = { + params: new HttpParams({ + fromString: `apiKey=${this.auth_token}&jwt=${this.token}` + }), + }; + } + + // user methods + login(username, password) { + const call = this.http.post(this.path + 'auth/login', {userid: username, password: password}, this.httpOptions); + call.subscribe(res => { + if (res['token']) { + this.afterLogin(res['user'], res['token']); + } + }); + return call; + } + } From a78ccefc83388f009de52460a0f17d692c3f6f7c Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Mon, 20 Apr 2020 18:40:21 -0400 Subject: [PATCH 03/20] Updated package.json --- backend/package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/package.json b/backend/package.json index 764f43d..78a2c3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,17 +30,23 @@ "dependencies": { "archiver": "^3.1.1", "async": "^3.1.0", + "bcrypt": "^4.0.1", "compression": "^1.7.4", "config": "^3.2.3", "exe": "^1.0.2", "express": "^4.17.1", "fs-extra": "^9.0.0", + "jsonwebtoken": "^8.5.1", "lowdb": "^1.0.0", "md5": "^2.2.1", "merge-files": "^0.1.2", "node-fetch": "^2.6.0", "node-id3": "^0.1.14", "nodemon": "^2.0.2", + "passport": "^0.4.1", + "passport-http": "^0.3.0", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", "progress": "^2.0.3", "shortid": "^2.2.15", "unzipper": "^0.10.10", From 98f1d003c33f617a4d3e1fb4c886d2db451ba9c3 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Fri, 24 Apr 2020 21:03:00 -0400 Subject: [PATCH 04/20] Fixed bug that prevented migrations from succeeding Added scaffolding required for jwt authentication for certain routes Added logger to auth_api Added necessary routing rules for multi-user mode Registration is now possible --- backend/app.js | 53 ++++++++---- backend/authentication/auth.js | 64 ++++++++++----- src/app/components/login/login.component.html | 25 +++++- src/app/components/login/login.component.ts | 72 +++++++++++++++-- src/app/posts.services.ts | 81 ++++++++++++++++++- 5 files changed, 245 insertions(+), 50 deletions(-) diff --git a/backend/app.js b/backend/app.js index dfad780..0a7fbec 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,7 +1,7 @@ var async = require('async'); const { uuid } = require('uuidv4'); var fs = require('fs-extra'); -var auth = require('./authentication/auth'); +var auth_api = require('./authentication/auth'); var winston = require('winston'); var path = require('path'); var youtubedl = require('youtube-dl'); @@ -63,6 +63,7 @@ const logger = winston.createLogger({ config_api.setLogger(logger); subscriptions_api.setLogger(logger); +auth_api.setLogger(logger); // var GithubContent = require('github-content'); @@ -154,7 +155,7 @@ app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); // use passport -app.use(auth.passport.initialize()); +app.use(auth_api.passport.initialize()); // objects @@ -218,6 +219,7 @@ async function runFilesToDBMigration() { db.set('files_to_db_migration_complete', true).write(); resolve(true); } catch(err) { + logger.error(err); resolve(false); } }); @@ -635,7 +637,7 @@ function getMp3s() { var url = jsonobj.webpage_url; var uploader = jsonobj.uploader; var upload_date = jsonobj.upload_date; - upload_date = `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}`; + upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null; var size = stats.size; @@ -664,7 +666,7 @@ function getMp4s(relative_path = true) { var url = jsonobj.webpage_url; var uploader = jsonobj.uploader; var upload_date = jsonobj.upload_date; - upload_date = `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}`; + upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null; var thumbnail = jsonobj.thumbnail; var duration = jsonobj.duration; @@ -1659,6 +1661,14 @@ app.use(function(req, res, next) { app.use(compression()); +const optionalJwt = function (req, res, next) { + const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); + if (multiUserMode && req.query.jwt) { + return auth_api.passport.authenticate('jwt', { session: false })(req, res, next); + } + return next(); +}; + app.get('/api/config', function(req, res) { let config_file = config_api.getConfigFile(); res.send({ @@ -1781,19 +1791,21 @@ app.post('/api/fileStatusMp4', function(req, res) { }); // gets all download mp3s -app.get('/api/getMp3s', function(req, res) { - const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); +app.get('/api/getMp3s', optionalJwt, function(req, res) { var mp3s = db.get('files.audio').value(); // getMp3s(); var playlists = db.get('playlists.audio').value(); - - if (req.query.jwt && multiUserMode) { + const is_authenticated = req.isAuthenticated(); + if (is_authenticated) { // mp3s = db.get + auth_api.passport.authenticate('jwt') + mp3s = auth_api.getUserVideos() + } else { + res.send({ + mp3s: mp3s, + playlists: playlists + }); } - - res.send({ - mp3s: mp3s, - playlists: playlists - }); + res.end("yes"); }); @@ -2537,11 +2549,18 @@ app.get('/api/audio/:id', function(req , res){ // user authentication app.post('/api/auth/register' - , auth.registerUser); + , auth_api.registerUser); app.post('/api/auth/login' - , auth.passport.authenticate('local', {}) - , auth.generateJWT - , auth.returnAuthResponse + , auth_api.passport.authenticate('local', {}) + , auth_api.passport.authorize('local') + , auth_api.generateJWT + , auth_api.returnAuthResponse +); +app.post('/api/auth/jwtAuth' + , auth_api.passport.authenticate('jwt', { session: false }) + , auth_api.passport.authorize('jwt') + , auth_api.generateJWT + , auth_api.returnAuthResponse ); app.use(function(req, res, next) { diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 81392b8..b46d191 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -11,11 +11,13 @@ db.defaults( var LocalStrategy = require('passport-local').Strategy; var JwtStrategy = require('passport-jwt').Strategy, ExtractJwt = require('passport-jwt').ExtractJwt; -var opts = {} -opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); -opts.secretOrKey = 'secret'; -opts.issuer = 'example.com'; -opts.audience = 'example.com'; + +// other required vars +let logger = null; + +exports.setLogger = function(input_logger) { + logger = input_logger; +} /************************* * Authentication module @@ -27,7 +29,19 @@ var jwt = require('jsonwebtoken'); const JWT_EXPIRATION = (60 * 60); // one hour const { uuid } = require('uuidv4'); -const SERVER_SECRET = uuid(); +let SERVER_SECRET = null; +if (db.get('jwt_secret').value()) { + SERVER_SECRET = db.get('jwt_secret').value(); +} else { + SERVER_SECRET = uuid(); + db.set('jwt_secret', SERVER_SECRET).write(); +} + +var opts = {} +opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); +opts.secretOrKey = SERVER_SECRET; +/*opts.issuer = 'example.com'; +opts.audience = 'example.com';*/ exports.passport = require('passport'); var BasicStrategy = require('passport-http').BasicStrategy; @@ -50,33 +64,42 @@ exports.registerUser = function(req, res) { bcrypt.hash(plaintextPassword, saltRounds) .then(function(hash) { + let new_user = { + name: username, + uid: userid, + passhash: hash, + files: { + audio: [], + video: [] + } + }; // check if user exists if (db.get('users').find({uid: userid}).value()) { // user id is taken! + logger.error('Registration failed: UID is already taken!'); res.status(409).send('UID is already taken!'); } else if (db.get('users').find({name: username}).value()) { // user name is taken! + logger.error('Registration failed: User name is already taken!'); res.status(409).send('User name is already taken!'); } else { // add to db - db.get('users').push({ - name: username, - uid: userid, - passhash: hash - }).write(); + db.get('users').push(new_user).write(); + logger.verbose(`New user created: ${new_user.name}`); + res.send({ + user: new_user + }); } }) .then(function(result) { - res.send('registered'); + }) .catch(function(err) { + logger.error(err); if( err.code == 'ER_DUP_ENTRY' ) { res.status(409).send('UserId already taken'); } else { - console.log('failed TO register User'); - - // res.writeHead(500, {'Content-Type':'text/plain'}); - res.end(err); + res.sendStatus(409); } }); } @@ -93,7 +116,7 @@ exports.registerUser = function(req, res) { * If so, passes the user info to the next middleware. ************************************************/ exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { - const user = db.get('users').find({uid: jwt_payload.sub}).value(); + const user = db.get('users').find({uid: jwt_payload.user.uid}).value(); if (user) { return done(null, user); } else { @@ -107,7 +130,7 @@ exports.passport.use(new LocalStrategy({ passwordField: 'password'}, function(username, password, done) { const user = db.get('users').find({name: username}).value(); - if (!user) { return done(null, false); } + if (!user) { console.log('user not found'); return done(null, false); } if (user) { return done(null, bcrypt.compareSync(password, user.passhash) ? user : false); } @@ -200,8 +223,9 @@ exports.ensureAuthenticatedElseError = function(req, res, next) { // video stuff -exports.getUserVideos(type) { - +exports.getUserVideos = function(uid, type) { + const user = db.get('users').find({uid: uid}).value(); + return user['files'][type]; } function getToken(queryParams) { diff --git a/src/app/components/login/login.component.html b/src/app/components/login/login.component.html index f7e6b93..3833599 100644 --- a/src/app/components/login/login.component.html +++ b/src/app/components/login/login.component.html @@ -1,14 +1,14 @@ \ No newline at end of file diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts index eef1015..0bb8c04 100644 --- a/src/app/components/login/login.component.ts +++ b/src/app/components/login/login.component.ts @@ -1,5 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { PostsService } from 'app/posts.services'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; @Component({ selector: 'app-login', @@ -8,24 +10,82 @@ import { PostsService } from 'app/posts.services'; }) export class LoginComponent implements OnInit { - usernameInput = ''; - passwordInput = ''; - registrationEnabled = true; + selectedTabIndex = 0; + + // login + loginUsernameInput = ''; + loginPasswordInput = ''; loggingIn = false; - constructor(private postsService: PostsService) { } + // registration + registrationEnabled = true; + registrationUsernameInput = ''; + registrationPasswordInput = ''; + registrationPasswordConfirmationInput = ''; + registering = false; + + constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { } ngOnInit(): void { + if (this.postsService.isLoggedIn) { + this.router.navigate(['/home']); + } } login() { this.loggingIn = true; - this.postsService.login(this.usernameInput, this.passwordInput).subscribe(res => { + this.postsService.login(this.loginUsernameInput, this.loginPasswordInput).subscribe(res => { this.loggingIn = false; - console.log(res); }, err => { this.loggingIn = false; }); } + register() { + if (!this.registrationUsernameInput || this.registrationUsernameInput === '') { + this.openSnackBar('User name is required!'); + return; + } + + if (!this.registrationPasswordInput || this.registrationPasswordInput === '') { + this.openSnackBar('Password is required!'); + return; + } + + if (!this.registrationPasswordConfirmationInput || this.registrationPasswordConfirmationInput === '') { + this.openSnackBar('Password confirmation is required!'); + return; + } + + if (this.registrationPasswordInput !== this.registrationPasswordConfirmationInput) { + this.openSnackBar('Password confirmation is incorrect!'); + return; + } + + this.registering = true; + this.postsService.register(this.registrationUsernameInput, this.registrationPasswordInput).subscribe(res => { + this.registering = false; + if (res && res['user']) { + this.openSnackBar(`User ${res['user']['name']} successfully registered.`); + this.loginUsernameInput = res['user']['name']; + this.selectedTabIndex = 0; + } else { + + } + }, err => { + this.registering = false; + if (err && err.error && typeof err.error === 'string') { + this.openSnackBar(err.error); + } else { + console.log(err); + } + }); + } + + public openSnackBar(message: string, action: string = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + } diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index c766ae6..fcbba9b 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -9,6 +9,7 @@ import { Router, CanActivate } from '@angular/router'; import { DOCUMENT } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { v4 as uuid } from 'uuid'; +import { MatSnackBar } from '@angular/material/snack-bar'; @Injectable() export class PostsService implements CanActivate { @@ -25,13 +26,15 @@ export class PostsService implements CanActivate { session_id = null; httpOptions = null; http_params: string = null; + unauthorized = false; debugMode = false; isLoggedIn = false; token = null; user = null; - constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document) { + constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, + public snackBar: MatSnackBar) { console.log('PostsService Initialized...'); // this.startPath = window.location.href + '/api/'; // this.startPathSSL = window.location.href + '/api/'; @@ -49,10 +52,24 @@ export class PostsService implements CanActivate { fromString: this.http_params }), }; + + // login stuff + + if (localStorage.getItem('jwt_token')) { + this.token = localStorage.getItem('jwt_token'); + this.httpOptions = { + params: new HttpParams({ + fromString: `apiKey=${this.auth_token}&jwt=${this.token}` + }), + }; + this.jwtAuth(); + } } - canActivate(route, state): boolean { + canActivate(route, state): Promise { + return new Promise(resolve => { + resolve(true); + }) console.log(route); - return true; throw new Error('Method not implemented.'); } @@ -271,11 +288,17 @@ export class PostsService implements CanActivate { this.user = user; this.token = token; + localStorage.setItem('jwt_token', this.token); + this.httpOptions = { params: new HttpParams({ fromString: `apiKey=${this.auth_token}&jwt=${this.token}` }), }; + + if (this.router.url === '/login') { + this.router.navigate(['/home']); + } } // user methods @@ -289,4 +312,56 @@ export class PostsService implements CanActivate { return call; } + // user methods + jwtAuth() { + const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions); + call.subscribe(res => { + if (res['token']) { + this.afterLogin(res['user'], res['token']); + } + }, err => { + if (err.status === 401) { + this.sendToLogin(); + } + }); + return call; + } + + logout() { + this.user = null; + this.isLoggedIn = false; + localStorage.setItem('jwt_token', null); + } + + // user methods + register(username, password) { + const call = this.http.post(this.path + 'auth/register', {userid: username, + username: username, + password: password}, this.httpOptions); + /*call.subscribe(res => { + console.log(res['user']); + if (res['user']) { + // this.afterRegistration(res['user']); + } + });*/ + return call; + } + + sendToLogin() { + if (this.router.url === '/login') { + return; + } + + this.router.navigate(['/login']); + + // send login notification + this.openSnackBar('You must log in to access this page!'); + } + + public openSnackBar(message: string, action: string = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + } From e6ea2238f83acf451a00323a5dc23709b240fba8 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 17:32:50 -0400 Subject: [PATCH 05/20] Fixed bug where HTTP headers were sent when params should have been sent instead sessionID now gets sent after logging in --- src/app/posts.services.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index fcbba9b..00f02e0 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -59,7 +59,7 @@ export class PostsService implements CanActivate { this.token = localStorage.getItem('jwt_token'); this.httpOptions = { params: new HttpParams({ - fromString: `apiKey=${this.auth_token}&jwt=${this.token}` + fromString: `apiKey=${this.auth_token}&sessionID=${this.session_id}&jwt=${this.token}` }), }; this.jwtAuth(); @@ -170,11 +170,11 @@ export class PostsService implements CanActivate { subscriptionName: subscriptionName, subPlaylist: subPlaylist }, - {responseType: 'blob', headers: this.httpOptions.headers}); + {responseType: 'blob', params: this.httpOptions.params}); } downloadArchive(sub) { - return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', headers: this.httpOptions.headers}); + return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params}); } getFileInfo(fileNames, type, urlMode) { @@ -292,7 +292,7 @@ export class PostsService implements CanActivate { this.httpOptions = { params: new HttpParams({ - fromString: `apiKey=${this.auth_token}&jwt=${this.token}` + fromString: `apiKey=${this.auth_token}&sessionID=${this.session_id}&jwt=${this.token}` }), }; @@ -331,6 +331,18 @@ export class PostsService implements CanActivate { this.user = null; this.isLoggedIn = false; localStorage.setItem('jwt_token', null); + if (this.router.url !== '/login') { + this.router.navigate(['/login']); + } + + // resets http params + this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}` + + this.httpOptions = { + params: new HttpParams({ + fromString: this.http_params + }), + }; } // user methods From a7c810136b92a97d5a95abb845a8de91e099d8e4 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 17:33:29 -0400 Subject: [PATCH 06/20] Added basic user profile component --- src/app/app.component.html | 4 +++ src/app/app.component.ts | 7 +++++ src/app/app.module.ts | 4 ++- .../user-profile-dialog.component.html | 23 +++++++++++++++ .../user-profile-dialog.component.scss | 0 .../user-profile-dialog.component.spec.ts | 25 +++++++++++++++++ .../user-profile-dialog.component.ts | 28 +++++++++++++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html create mode 100644 src/app/dialogs/user-profile-dialog/user-profile-dialog.component.scss create mode 100644 src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts create mode 100644 src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 0e03fe9..5de160c 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -14,6 +14,10 @@
+ +
+ + +
+
warnYou are not logged in.
+ +
+ diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.scss b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts new file mode 100644 index 0000000..364e28b --- /dev/null +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserProfileDialogComponent } from './user-profile-dialog.component'; + +describe('UserProfileDialogComponent', () => { + let component: UserProfileDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UserProfileDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserProfileDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts new file mode 100644 index 0000000..17d8c86 --- /dev/null +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts @@ -0,0 +1,28 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-user-profile-dialog', + templateUrl: './user-profile-dialog.component.html', + styleUrls: ['./user-profile-dialog.component.scss'] +}) +export class UserProfileDialogComponent implements OnInit { + + constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef) { } + + ngOnInit(): void { + } + + loginClicked() { + this.router.navigate(['/login']); + this.dialogRef.close(); + } + + logoutClicked() { + this.postsService.logout(); + this.dialogRef.close(); + } + +} From 912a419bd4e3cd3781bf3b9410680fb7d74bfc45 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 17:34:38 -0400 Subject: [PATCH 07/20] Getting current download refactored to work and display less errors Player component now sends jwt token if logged in --- src/app/main/main.component.ts | 9 +++------ src/app/player/player.component.ts | 5 +++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 708674b..38339d3 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -1137,18 +1137,15 @@ export class MainComponent implements OnInit { } getCurrentDownload() { - this.postsService.getCurrentDownload(this.postsService.session_id, - this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']).subscribe(res => { - const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']; + const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']; + this.postsService.getCurrentDownload(this.postsService.session_id, ui_uid).subscribe(res => { if (res['download']) { - console.log('got new download'); if (ui_uid === res['download']['ui_uid']) { this.current_download = res['download']; this.percentDownloaded = this.current_download.percent_complete; - console.log(this.percentDownloaded); } } else { - console.log('failed to get new download'); + // console.log('failed to get new download'); } }); } diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 83acebb..07f33fa 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -183,6 +183,11 @@ export class PlayerComponent implements OnInit { fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName + '&subPlaylist=' + this.subPlaylist; } + + // adds user token if in multi-user-mode + if (this.postsService.isLoggedIn) { + fullLocation += '?jwt=' + this.postsService.token; + } // if it has a slash (meaning it's in a directory), only get the file name for the label let label = null; const decodedName = decodeURIComponent(fileName); From cb6451ef96f994afdf6f6a0094d974765dfd48be Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 17:37:49 -0400 Subject: [PATCH 08/20] Added new settings: multi user mode and users base path --- backend/appdata/default.json | 4 ++++ backend/appdata/encrypted.json | 4 ++++ backend/config.js | 4 ++++ backend/consts.js | 10 ++++++++++ src/assets/default.json | 4 ++++ 5 files changed, 26 insertions(+) diff --git a/backend/appdata/default.json b/backend/appdata/default.json index a987394..4ed35ed 100644 --- a/backend/appdata/default.json +++ b/backend/appdata/default.json @@ -39,9 +39,13 @@ "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": false, "allow_advanced_download": false } } diff --git a/backend/appdata/encrypted.json b/backend/appdata/encrypted.json index 04de64a..9817beb 100644 --- a/backend/appdata/encrypted.json +++ b/backend/appdata/encrypted.json @@ -39,9 +39,13 @@ "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": false, "allow_advanced_download": false } } diff --git a/backend/config.js b/backend/config.js index a38a703..b3415ee 100644 --- a/backend/config.js +++ b/backend/config.js @@ -178,9 +178,13 @@ DEFAULT_CONFIG = { "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": false, "allow_advanced_download": false } } diff --git a/backend/consts.js b/backend/consts.js index c94aba4..f5b62ad 100644 --- a/backend/consts.js +++ b/backend/consts.js @@ -117,6 +117,12 @@ let CONFIG_ITEMS = { 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_use_youtubedl_archive' }, + // Users + 'ytdl_users_base_path': { + 'key': 'ytdl_users_base_path', + 'path': 'YoutubeDLMaterial.Users.base_path' + }, + // Advanced 'ytdl_use_default_downloading_agent': { 'key': 'ytdl_use_default_downloading_agent', @@ -126,6 +132,10 @@ let CONFIG_ITEMS = { 'key': 'ytdl_custom_downloading_agent', 'path': 'YoutubeDLMaterial.Advanced.custom_downloading_agent' }, + 'ytdl_multi_user_mode': { + 'key': 'ytdl_multi_user_mode', + 'path': 'YoutubeDLMaterial.Advanced.multi_user_mode' + }, 'ytdl_allow_advanced_download': { 'key': 'ytdl_allow_advanced_download', 'path': 'YoutubeDLMaterial.Advanced.allow_advanced_download' diff --git a/src/assets/default.json b/src/assets/default.json index ad4211c..f1ed23a 100644 --- a/src/assets/default.json +++ b/src/assets/default.json @@ -39,9 +39,13 @@ "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": true, "allow_advanced_download": true } } From fa1b291f97ed479bd6a2916dcd65f984b15e715a Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 17:40:28 -0400 Subject: [PATCH 09/20] Added video downloading functionality to multi user mode, as well as playlist management and saving of videos locally. Still missing video deletions and subscriptions Simplified code for downloading videos to client (locally) --- backend/app.js | 285 +++++++++++++++++++++------------ backend/authentication/auth.js | 72 ++++++++- backend/subscriptions.js | 2 + 3 files changed, 256 insertions(+), 103 deletions(-) diff --git a/backend/app.js b/backend/app.js index 0a7fbec..392cfa0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -324,7 +324,9 @@ async function downloadReleaseFiles(tag) { fs.mkdirSync(path.join(__dirname, 'public')); let replace_ignore_list = ['youtubedl-material/appdata/default.json', - 'youtubedl-material/appdata/db.json'] + 'youtubedl-material/appdata/db.json', + 'youtubedl-material/appdata/users.json', + 'youtubedl-material/appdata/*'] logger.info(`Installing update ${tag}...`) // downloads new package.json and adds new public dir files from the downloaded zip @@ -725,10 +727,16 @@ function getFileSizeMp4(name) return filesize; } -function getJSONMp3(name, openReadPerms = false) +function getJSONMp3(name, customPath = null, openReadPerms = false) { var jsonPath = audioFolderPath+name+".info.json"; var alternateJsonPath = audioFolderPath+name+".mp3.info.json"; + if (!customPath) { + jsonPath = audioFolderPath + name + ".info.json"; + } else { + jsonPath = customPath + name + ".info.json"; + alternateJsonPath = customPath + name + ".mp3.info.json"; + } var obj = null; if (fs.existsSync(jsonPath)) { obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); @@ -1003,9 +1011,9 @@ function recFindByExt(base,ext,files,result) return result } -function registerFileDB(full_file_path, type) { - const file_id = full_file_path.substring(0, full_file_path.length-4); - const file_object = generateFileObject(file_id, type); +function registerFileDB(file_path, type, multiUserMode = null) { + const file_id = file_path.substring(0, file_path.length-4); + const file_object = generateFileObject(file_id, type, multiUserMode && multiUserMode.file_path); if (!file_object) { logger.error(`Could not find associated JSON file for ${type} file ${file_id}`); return false; @@ -1017,20 +1025,25 @@ function registerFileDB(full_file_path, type) { path_object = path.parse(file_object['path']); file_object['path'] = path.format(path_object); - // remove existing video if overwriting - db.get(`files.${type}`) + if (multiUserMode) { + auth_api.registerUserFile(multiUserMode.user, file_object, type); + } else { + // remove existing video if overwriting + db.get(`files.${type}`) .remove({ path: file_object['path'] }).write(); - db.get(`files.${type}`) - .push(file_object) - .write(); + db.get(`files.${type}`) + .push(file_object) + .write(); + } + return file_object['uid']; } -function generateFileObject(id, type) { - var jsonobj = (type === 'audio') ? getJSONMp3(id, true) : getJSONMp4(id, null, true); +function generateFileObject(id, type, customPath = null) { + var jsonobj = (type === 'audio') ? getJSONMp3(id, customPath, true) : getJSONMp4(id, customPath, true); if (!jsonobj) { return null; } @@ -1114,6 +1127,20 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) { var is_audio = type === 'audio'; var ext = is_audio ? '.mp3' : '.mp4'; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; + + // prepend with user if needed + let multiUserMode = null; + if (options.user) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const user_path = path.join(usersFileFolder, options.user, type); + fs.ensureDirSync(user_path); + fileFolderPath = user_path + path.sep; + multiUserMode = { + user: options.user, + file_path: fileFolderPath + } + options.customFileFolderPath = fileFolderPath; + } const downloadConfig = await generateArgs(url, type, options); @@ -1195,7 +1222,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) { } // registers file in DB - file_uid = registerFileDB(full_file_path.substring(fileFolderPath.length, full_file_path.length), type); + file_uid = registerFileDB(full_file_path.substring(fileFolderPath.length, full_file_path.length), type, multiUserMode); if (file_name) file_names.push(file_name); } @@ -1230,6 +1257,20 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) { var file_uid = null; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; + // prepend with user if needed + let multiUserMode = null; + if (options.user) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const user_path = path.join(usersFileFolder, options.user, type); + fs.ensureDirSync(user_path); + fileFolderPath = user_path + path.sep; + multiUserMode = { + user: options.user, + file_path: fileFolderPath + } + options.customFileFolderPath = fileFolderPath; + } + const downloadConfig = await generateArgs(url, type, options); // adds download to download helper @@ -1306,7 +1347,7 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) { // registers file in DB const base_file_name = video_info._filename.substring(fileFolderPath.length, video_info._filename.length); - file_uid = registerFileDB(base_file_name, type); + file_uid = registerFileDB(base_file_name, type, multiUserMode); if (options.merged_string) { let current_merged_archive = fs.readFileSync(fileFolderPath + 'merged.txt', 'utf8'); @@ -1344,6 +1385,8 @@ async function generateArgs(url, type, options) { var fileFolderPath = is_audio ? audioFolderPath : videoFolderPath; + if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath; + var customArgs = options.customArgs; var customOutput = options.customOutput; var customQualityConfiguration = options.customQualityConfiguration; @@ -1377,9 +1420,9 @@ async function generateArgs(url, type, options) { } if (customOutput) { - downloadConfig = ['-o', fileFolderPath + customOutput + "", qualityPath, '--write-info-json', '--print-json']; + downloadConfig = ['-o', path.join(fileFolderPath, customOutput), qualityPath, '--write-info-json', '--print-json']; } else { - downloadConfig = ['-o', fileFolderPath + videopath + (is_audio ? '.%(ext)s' : '.mp4'), qualityPath, '--write-info-json', '--print-json']; + downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), qualityPath, '--write-info-json', '--print-json']; } if (is_audio) { @@ -1663,8 +1706,12 @@ app.use(compression()); const optionalJwt = function (req, res, next) { const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); - if (multiUserMode && req.query.jwt) { - return auth_api.passport.authenticate('jwt', { session: false })(req, res, next); + if (multiUserMode) { + if (!req.query.jwt) { + res.sendStatus(401); + return; + } + return auth_api.passport.authenticate('jwt', { session: false })(req, res, next); } return next(); }; @@ -1695,7 +1742,7 @@ app.get('/api/using-encryption', function(req, res) { res.send(usingEncryption); }); -app.post('/api/tomp3', async function(req, res) { +app.post('/api/tomp3', optionalJwt, async function(req, res) { var url = req.body.url; var options = { customArgs: req.body.customArgs, @@ -1704,7 +1751,8 @@ app.post('/api/tomp3', async function(req, res) { customQualityConfiguration: req.body.customQualityConfiguration, youtubeUsername: req.body.youtubeUsername, youtubePassword: req.body.youtubePassword, - ui_uid: req.body.ui_uid + ui_uid: req.body.ui_uid, + user: req.isAuthenticated() ? req.user.uid : null } const is_playlist = url.includes('playlist'); @@ -1721,7 +1769,7 @@ app.post('/api/tomp3', async function(req, res) { res.end("yes"); }); -app.post('/api/tomp4', async function(req, res) { +app.post('/api/tomp4', optionalJwt, async function(req, res) { var url = req.body.url; var options = { customArgs: req.body.customArgs, @@ -1730,7 +1778,8 @@ app.post('/api/tomp4', async function(req, res) { customQualityConfiguration: req.body.customQualityConfiguration, youtubeUsername: req.body.youtubeUsername, youtubePassword: req.body.youtubePassword, - ui_uid: req.body.ui_uid + ui_uid: req.body.ui_uid, + user: req.isAuthenticated() ? req.user.uid : null } const is_playlist = url.includes('playlist'); @@ -1796,48 +1845,59 @@ app.get('/api/getMp3s', optionalJwt, function(req, res) { var playlists = db.get('playlists.audio').value(); const is_authenticated = req.isAuthenticated(); if (is_authenticated) { - // mp3s = db.get + // get user audio files/playlists auth_api.passport.authenticate('jwt') - mp3s = auth_api.getUserVideos() - } else { - res.send({ - mp3s: mp3s, - playlists: playlists - }); + mp3s = auth_api.getUserVideos(req.user.uid, 'audio'); + playlists = auth_api.getUserPlaylists(req.user.uid, 'audio'); } - - res.end("yes"); + + res.send({ + mp3s: mp3s, + playlists: playlists + }); }); // gets all download mp4s -app.get('/api/getMp4s', function(req, res) { +app.get('/api/getMp4s', optionalJwt, function(req, res) { var mp4s = db.get('files.video').value(); // getMp4s(); var playlists = db.get('playlists.video').value(); + const is_authenticated = req.isAuthenticated(); + if (is_authenticated) { + // get user videos/playlists + auth_api.passport.authenticate('jwt') + mp4s = auth_api.getUserVideos(req.user.uid, 'video'); + playlists = auth_api.getUserPlaylists(req.user.uid, 'video'); + } + res.send({ mp4s: mp4s, playlists: playlists }); - res.end("yes"); }); -app.post('/api/getFile', function (req, res) { +app.post('/api/getFile', optionalJwt, function (req, res) { var uid = req.body.uid; var type = req.body.type; var file = null; - if (!type) { - file = db.get('files.audio').find({uid: uid}).value(); - if (!file) { - file = db.get('files.video').find({uid: uid}).value(); - if (file) type = 'video'; - } else { - type = 'audio'; + if (req.isAuthenticated()) { + file = auth_api.getUserVideo(req.user.uid, uid, type); + } else { + if (!type) { + file = db.get('files.audio').find({uid: uid}).value(); + if (!file) { + file = db.get('files.video').find({uid: uid}).value(); + if (file) type = 'video'; + } else { + type = 'audio'; + } } + + if (!file && type) file = db.get(`files.${type}`).find({uid: uid}).value(); } - - if (!file && type) db.get(`files.${type}`).find({uid: uid}).value(); + if (file) { res.send({ @@ -2073,7 +2133,7 @@ app.post('/api/getAllSubscriptions', async (req, res) => { }); }); -app.post('/api/createPlaylist', async (req, res) => { +app.post('/api/createPlaylist', optionalJwt, async (req, res) => { let playlistName = req.body.playlistName; let fileNames = req.body.fileNames; let type = req.body.type; @@ -2087,9 +2147,14 @@ app.post('/api/createPlaylist', async (req, res) => { type: type }; - db.get(`playlists.${type}`) - .push(new_playlist) - .write(); + if (req.isAuthenticated()) { + auth_api.addPlaylist(req.user.uid, new_playlist, type); + } else { + db.get(`playlists.${type}`) + .push(new_playlist) + .write(); + } + res.send({ new_playlist: new_playlist, @@ -2097,24 +2162,29 @@ app.post('/api/createPlaylist', async (req, res) => { }) }); -app.post('/api/getPlaylist', async (req, res) => { +app.post('/api/getPlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlistID; let type = req.body.type; let playlist = null; - if (!type) { - playlist = db.get('playlists.audio').find({id: playlistID}).value(); - if (!playlist) { - playlist = db.get('playlists.video').find({id: playlistID}).value(); - if (playlist) type = 'video'; - } else { - type = 'audio'; + if (req.isAuthenticated()) { + playlist = auth_api.getUserPlaylist(req.user.uid, playlistID, type); + type = playlist.type; + } else { + if (!type) { + playlist = db.get('playlists.audio').find({id: playlistID}).value(); + if (!playlist) { + playlist = db.get('playlists.video').find({id: playlistID}).value(); + if (playlist) type = 'video'; + } else { + type = 'audio'; + } } + + if (!playlist) playlist = db.get(`playlists.${type}`).find({id: playlistID}).value(); } - if (!playlist) playlist = db.get(`playlists.${type}`).find({id: playlistID}).value(); - res.send({ playlist: playlist, type: type, @@ -2122,22 +2192,22 @@ app.post('/api/getPlaylist', async (req, res) => { }); }); -app.post('/api/updatePlaylist', async (req, res) => { +app.post('/api/updatePlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlistID; let fileNames = req.body.fileNames; let type = req.body.type; let success = false; try { - db.get(`playlists.${type}`) - .find({id: playlistID}) - .assign({fileNames: fileNames}) - .write(); - /*logger.info('success!'); - let new_val = db.get(`playlists.${type}`) - .find({id: playlistID}) - .value(); - logger.info(new_val);*/ + if (req.isAuthenticated()) { + auth_api.updatePlaylist(req.user.uid, playlistID, fileNames, type); + } else { + db.get(`playlists.${type}`) + .find({id: playlistID}) + .assign({fileNames: fileNames}) + .write(); + } + success = true; } catch(e) { logger.error(`Failed to find playlist with ID ${playlistID}`); @@ -2148,16 +2218,20 @@ app.post('/api/updatePlaylist', async (req, res) => { }) }); -app.post('/api/deletePlaylist', async (req, res) => { +app.post('/api/deletePlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlistID; let type = req.body.type; let success = null; try { - // removes playlist from playlists - db.get(`playlists.${type}`) - .remove({id: playlistID}) - .write(); + if (req.isAuthenticated()) { + auth_api.removePlaylist(req.user.uid, playlistID, type); + } else { + // removes playlist from playlists + db.get(`playlists.${type}`) + .remove({id: playlistID}) + .write(); + } success = true; } catch(e) { @@ -2221,7 +2295,7 @@ app.post('/api/deleteMp4', async (req, res) => { } }); -app.post('/api/downloadFile', async (req, res) => { +app.post('/api/downloadFile', optionalJwt, async (req, res) => { let fileNames = req.body.fileNames; let zip_mode = req.body.zip_mode; let type = req.body.type; @@ -2232,21 +2306,20 @@ app.post('/api/downloadFile', async (req, res) => { let file = null; if (!zip_mode) { fileNames = decodeURIComponent(fileNames); - if (type === 'audio') { - if (!subscriptionName) { - file = path.join(__dirname, audioFolderPath, fileNames + '.mp3'); - } else { - let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); - file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp3') - } + const is_audio = type === 'audio'; + const fileFolderPath = is_audio ? audioFolderPath : videoFolderPath; + const ext = is_audio ? '.mp3' : '.mp4'; + + let base_path = fileFolderPath; + if (req.isAuthenticated()) { + const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + base_path = path.join(usersFileFolder, req.user.uid, type); + } + if (!subscriptionName) { + file = path.join(__dirname, base_path, fileNames + ext); } else { - // if type is 'subscription' or 'video', it's a video - if (!subscriptionName) { - file = path.join(__dirname, videoFolderPath, fileNames + '.mp4'); - } else { - let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); - file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') - } + let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') } } else { for (let i = 0; i < fileNames.length; i++) { @@ -2369,18 +2442,26 @@ app.post('/api/generateNewAPIKey', function (req, res) { // Streaming API calls -app.get('/api/video/:id', function(req , res){ +app.get('/api/video/:id', optionalJwt, function(req , res){ var head; let optionalParams = url_api.parse(req.url,true).query; let id = decodeURIComponent(req.params.id); - let path = videoFolderPath + id + '.mp4'; - if (optionalParams['subName']) { + let file_path = videoFolderPath + id + '.mp4'; + if (req.isAuthenticated()) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + if (optionalParams['subName']) { + const isPlaylist = optionalParams['subPlaylist']; + file_path = path.join(usersFileFolder, req.user.uid, (isPlaylist === 'true' ? 'playlists/' : 'channels/'), id + '.mp4') + } else { + file_path = path.join(usersFileFolder, req.user.uid, 'video', id + '.mp4'); + } + } else if (optionalParams['subName']) { let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); const isPlaylist = optionalParams['subPlaylist']; basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/'); - path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; + file_path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; } - const stat = fs.statSync(path) + const stat = fs.statSync(file_path) const fileSize = stat.size const range = req.headers.range if (range) { @@ -2390,7 +2471,7 @@ app.get('/api/video/:id', function(req , res){ ? parseInt(parts[1], 10) : fileSize-1 const chunksize = (end-start)+1 - const file = fs.createReadStream(path, {start, end}) + const file = fs.createReadStream(file_path, {start, end}) if (descriptors[id]) descriptors[id].push(file); else descriptors[id] = [file]; file.on('close', function() { @@ -2412,16 +2493,20 @@ app.get('/api/video/:id', function(req , res){ 'Content-Type': 'video/mp4', } res.writeHead(200, head) - fs.createReadStream(path).pipe(res) + fs.createReadStream(file_path).pipe(res) } }); -app.get('/api/audio/:id', function(req , res){ +app.get('/api/audio/:id', optionalJwt, function(req , res){ var head; let id = decodeURIComponent(req.params.id); - let path = "audio/" + id + '.mp3'; - path = path.replace(/\"/g, '\''); - const stat = fs.statSync(path) + let file_path = "audio/" + id + '.mp3'; + if (req.isAuthenticated()) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + file_path = path.join(usersFileFolder, req.user.name, 'audio', id + '.mp3'); + } + file_path = file_path.replace(/\"/g, '\''); + const stat = fs.statSync(file_path) const fileSize = stat.size const range = req.headers.range if (range) { @@ -2431,7 +2516,7 @@ app.get('/api/audio/:id', function(req , res){ ? parseInt(parts[1], 10) : fileSize-1 const chunksize = (end-start)+1 - const file = fs.createReadStream(path, {start, end}); + const file = fs.createReadStream(file_path, {start, end}); if (descriptors[id]) descriptors[id].push(file); else descriptors[id] = [file]; file.on('close', function() { @@ -2453,7 +2538,7 @@ app.get('/api/audio/:id', function(req , res){ 'Content-Type': 'audio/mp3', } res.writeHead(200, head) - fs.createReadStream(path).pipe(res) + fs.createReadStream(file_path).pipe(res) } }); diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index b46d191..2dc0fc6 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -71,7 +71,12 @@ exports.registerUser = function(req, res) { files: { audio: [], video: [] - } + }, + playlists: { + audio: [], + video: [] + }, + created: Date.now() }; // check if user exists if (db.get('users').find({uid: userid}).value()) { @@ -223,11 +228,72 @@ exports.ensureAuthenticatedElseError = function(req, res, next) { // video stuff -exports.getUserVideos = function(uid, type) { - const user = db.get('users').find({uid: uid}).value(); +exports.getUserVideos = function(user_uid, type) { + const user = db.get('users').find({uid: user_uid}).value(); return user['files'][type]; } +exports.getUserVideo = function(user_uid, file_uid, type) { + if (!type) { + file = db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value(); + if (!file) { + file = db.get('users').find({uid: user_uid}).get(`files.video`).find({uid: file_uid}).value(); + if (file) type = 'video'; + } else { + type = 'audio'; + } + } + + if (!file && type) file = db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + return file; +} + +exports.addPlaylist = function(user_uid, new_playlist, type) { + db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write(); + return true; +} + +exports.updatePlaylist = function(user_uid, playlistID, new_filenames, type) { + db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames}); + return true; +} + +exports.removePlaylist = function(user_uid, playlistID, type) { + db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write(); + return true; +} + +exports.getUserPlaylists = function(user_uid, type) { + const user = db.get('users').find({uid: user_uid}).value(); + return user['playlists'][type]; +} + +exports.getUserPlaylist = function(user_uid, playlistID, type) { + let playlist = null; + if (!type) { + playlist = db.get('users').find({uid: user_uid}).get(`playlists.audio`).find({id: playlistID}).value(); + if (!playlist) { + playlist = db.get('users').find({uid: user_uid}).get(`playlists.video`).find({id: playlistID}).value(); + if (playlist) type = 'video'; + } else { + type = 'audio'; + } + } + if (!playlist) playlist = db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).value(); + return playlist; +} + +exports.registerUserFile = function(user_uid, file_object, type) { + db.get('users').find({uid: user_uid}).get(`files.${type}`) + .remove({ + path: file_object['path'] + }).write(); + + db.get('users').find({uid: user_uid}).get(`files.${type}`) + .push(file_object) + .write(); +} + function getToken(queryParams) { if (queryParams && queryParams.jwt) { var parted = queryParams.jwt.split(' '); diff --git a/backend/subscriptions.js b/backend/subscriptions.js index 476292b..ef80ba7 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -9,7 +9,9 @@ var youtubedl = require('youtube-dl'); const config_api = require('./config'); const adapter = new FileSync('./appdata/db.json'); +const users_adapter = new FileSync('./appdata/users.json'); const db = low(adapter) +const db_users = low(users_adapter); const debugMode = process.env.YTDL_MODE === 'debug'; From e790c9fadfaae7c5044ec43424ff43d642b26ba6 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 18:33:23 -0400 Subject: [PATCH 10/20] File descriptors are now stored in the config_api until they find a better home File deletion now works in multi-user mode. Sharing and subscriptions are the last holdouts for porting over to multi-user-mode Fixed bug with archive mode that defaulted to storing the ID in the video archive all the time (rather than audio if it's an mp3) --- backend/app.js | 57 ++++++++++++++++----------- backend/authentication/auth.js | 72 ++++++++++++++++++++++++++++++++++ backend/config.js | 3 +- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/backend/app.js b/backend/app.js index 392cfa0..60ff1a7 100644 --- a/backend/app.js +++ b/backend/app.js @@ -149,7 +149,6 @@ if (writeConfigMode) { } var downloads = {}; -var descriptors = {}; app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); @@ -882,10 +881,10 @@ async function deleteAudioFile(name, blacklistMode = false) { let audioFileExists = fs.existsSync(audioFilePath); - if (descriptors[name]) { + if (config_api.descriptors[name]) { try { - for (let i = 0; i < descriptors[name].length; i++) { - descriptors[name][i].destroy(); + for (let i = 0; i < config_api.descriptors[name].length; i++) { + config_api.descriptors[name][i].destroy(); } } catch(e) { @@ -940,10 +939,10 @@ async function deleteVideoFile(name, customPath = null, blacklistMode = false) { jsonExists = fs.existsSync(jsonPath); videoFileExists = fs.existsSync(videoFilePath); - if (descriptors[name]) { + if (config_api.descriptors[name]) { try { - for (let i = 0; i < descriptors[name].length; i++) { - descriptors[name][i].destroy(); + for (let i = 0; i < config_api.descriptors[name].length; i++) { + config_api.descriptors[name][i].destroy(); } } catch(e) { @@ -1352,7 +1351,7 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) { if (options.merged_string) { let current_merged_archive = fs.readFileSync(fileFolderPath + 'merged.txt', 'utf8'); let diff = current_merged_archive.replace(options.merged_string, ''); - const archive_path = path.join(archivePath, 'archive_video.txt'); + const archive_path = req.isAuthenticated() ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`); fs.appendFileSync(archive_path, diff); } @@ -1440,13 +1439,13 @@ async function generateArgs(url, type, options) { let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); if (useYoutubeDLArchive) { - const archive_path = path.join(archivePath, `archive_${type}.txt`); + const archive_path = options.user ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`); // create archive file if it doesn't exist if (!fs.existsSync(archive_path)) { fs.closeSync(fs.openSync(archive_path, 'w')); } - let blacklist_path = path.join(archivePath, `blacklist_${type}.txt`); + let blacklist_path = options.user ? path.join(fileFolderPath, 'archives', `blacklist_${type}.txt`) : path.join(archivePath, `blacklist_${type}.txt`); // create blacklist file if it doesn't exist if (!fs.existsSync(blacklist_path)) { fs.closeSync(fs.openSync(blacklist_path, 'w')); @@ -2244,12 +2243,19 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => { }); // deletes mp3 file -app.post('/api/deleteMp3', async (req, res) => { +app.post('/api/deleteMp3', optionalJwt, async (req, res) => { // var name = req.body.name; var uid = req.body.uid; + var blacklistMode = req.body.blacklistMode; + + if (req.isAuthenticated()) { + let success = auth_api.deleteUserFile(req.user.uid, uid, 'audio', blacklistMode); + res.send(success); + return; + } + var audio_obj = db.get('files.audio').find({uid: uid}).value(); var name = audio_obj.id; - var blacklistMode = req.body.blacklistMode; var fullpath = audioFolderPath + name + ".mp3"; var wasDeleted = false; if (fs.existsSync(fullpath)) @@ -2270,11 +2276,18 @@ app.post('/api/deleteMp3', async (req, res) => { }); // deletes mp4 file -app.post('/api/deleteMp4', async (req, res) => { +app.post('/api/deleteMp4', optionalJwt, async (req, res) => { var uid = req.body.uid; + var blacklistMode = req.body.blacklistMode; + + if (req.isAuthenticated()) { + let success = auth_api.deleteUserFile(req.user.uid, uid, 'video', blacklistMode); + res.send(success); + return; + } + var video_obj = db.get('files.video').find({uid: uid}).value(); var name = video_obj.id; - var blacklistMode = req.body.blacklistMode; var fullpath = videoFolderPath + name + ".mp4"; var wasDeleted = false; if (fs.existsSync(fullpath)) @@ -2472,11 +2485,11 @@ app.get('/api/video/:id', optionalJwt, function(req , res){ : fileSize-1 const chunksize = (end-start)+1 const file = fs.createReadStream(file_path, {start, end}) - if (descriptors[id]) descriptors[id].push(file); - else descriptors[id] = [file]; + if (config_api.descriptors[id]) config_api.descriptors[id].push(file); + else config_api.descriptors[id] = [file]; file.on('close', function() { - let index = descriptors[id].indexOf(file); - descriptors[id].splice(index, 1); + let index = config_api.descriptors[id].indexOf(file); + config_api.descriptors[id].splice(index, 1); logger.debug('Successfully closed stream and removed file reference.'); }); head = { @@ -2517,11 +2530,11 @@ app.get('/api/audio/:id', optionalJwt, function(req , res){ : fileSize-1 const chunksize = (end-start)+1 const file = fs.createReadStream(file_path, {start, end}); - if (descriptors[id]) descriptors[id].push(file); - else descriptors[id] = [file]; + if (config_api.descriptors[id]) config_api.descriptors[id].push(file); + else config_api.descriptors[id] = [file]; file.on('close', function() { - let index = descriptors[id].indexOf(file); - descriptors[id].splice(index, 1); + let index = config_api.descriptors[id].indexOf(file); + config_api.descriptors[id].splice(index, 1); logger.debug('Successfully closed stream and removed file reference.'); }); head = { diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 2dc0fc6..2a9eceb 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -2,6 +2,10 @@ const low = require('lowdb') const FileSync = require('lowdb/adapters/FileSync'); const adapter = new FileSync('./appdata/users.json'); const db = low(adapter); +const path = require('path'); +const config_api = require('../config'); +var subscriptions_api = require('../subscriptions') +const fs = require('fs-extra'); db.defaults( { users: [] @@ -294,6 +298,74 @@ exports.registerUserFile = function(user_uid, file_object, type) { .write(); } +exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) { + let success = false; + const file_obj = db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + if (file_obj) { + const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const ext = type === 'audio' ? '.mp3' : '.mp4'; + + // close descriptors + if (config_api.descriptors[file_obj.id]) { + try { + for (let i = 0; i < config_api.descriptors[file_obj.id].length; i++) { + config_api.descriptors[file_obj.id][i].destroy(); + } + } catch(e) { + + } + } + + const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext); + db.get('users').find({uid: user_uid}).get(`files.${type}`) + .remove({ + uid: file_uid + }).write(); + if (fs.existsSync(full_path)) { + // remove json and file + const json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + '.info.json'); + const alternate_json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext + '.info.json'); + let youtube_id = null; + if (fs.existsSync(json_path)) { + youtube_id = fs.readJSONSync(json_path).id; + fs.unlinkSync(json_path); + } else if (fs.existsSync(alternate_json_path)) { + youtube_id = fs.readJSONSync(alternate_json_path).id; + fs.unlinkSync(alternate_json_path); + } + + fs.unlinkSync(full_path); + + // do archive stuff + + let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); + if (useYoutubeDLArchive) { + const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`); + + // use subscriptions API to remove video from the archive file, and write it to the blacklist + if (fs.existsSync(archive_path)) { + const line = youtube_id ? subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null; + if (blacklistMode && line) { + let blacklistPath = path.join(usersFileFolder, user_uid, 'archives', `blacklist_${type}.txt`); + // adds newline to the beginning of the line + line = '\n' + line; + fs.appendFileSync(blacklistPath, line); + } + } else { + logger.info('Could not find archive file for audio files. Creating...'); + fs.closeSync(fs.openSync(archive_path, 'w')); + } + } + } + success = true; + } else { + success = false; + console.log('file does not exist!'); + } + + return success; +} + function getToken(queryParams) { if (queryParams && queryParams.jwt) { var parted = queryParams.jwt.split(' '); diff --git a/backend/config.js b/backend/config.js index b3415ee..19ec0ae 100644 --- a/backend/config.js +++ b/backend/config.js @@ -134,7 +134,8 @@ module.exports = { setConfigFile: setConfigFile, configExistsCheck: configExistsCheck, CONFIG_ITEMS: CONFIG_ITEMS, - setLogger: setLogger + setLogger: setLogger, + descriptors: {} } DEFAULT_CONFIG = { From 1ac6683f3346d2f5d247ebd35606a3e8e607a592 Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Sun, 26 Apr 2020 21:37:08 -0400 Subject: [PATCH 11/20] Custom quality configurations now use the old downloading method to avoid errors postsservice now does jwt auth after checking if multi user mode is enabled Minor update to user profile UI Added setting for enabling and disabling multi user mode --- backend/app.js | 6 +-- .../user-profile-dialog.component.html | 12 ++++- src/app/posts.services.ts | 44 ++++++++++++++----- src/app/settings/settings.component.html | 7 ++- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/backend/app.js b/backend/app.js index 60ff1a7..224e05b 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1411,9 +1411,9 @@ async function generateArgs(url, type, options) { downloadConfig = customArgs.split(' '); } else { if (customQualityConfiguration) { - qualityPath = customQualityConfiguration; + qualityPath = `-f ${customQualityConfiguration}`; } else if (selectedHeight && selectedHeight !== '' && !is_audio) { - qualityPath = `-f bestvideo[height=${selectedHeight}]+bestaudio/best[height=${selectedHeight}]`; + qualityPath = `-f '(mp4)[height=${selectedHeight}]'`; } else if (maxBitrate && is_audio) { qualityPath = `--audio-quality ${maxBitrate}` } @@ -1783,7 +1783,7 @@ app.post('/api/tomp4', optionalJwt, async function(req, res) { const is_playlist = url.includes('playlist'); let result_obj = null; - if (is_playlist) + if (is_playlist || options.customQualityConfiguration) result_obj = await downloadFileByURL_exec(url, 'video', options, req.query.sessionID); else result_obj = await downloadFileByURL_normal(url, 'video', options, req.query.sessionID); diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html index 26b621c..fd9be71 100644 --- a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html @@ -11,8 +11,7 @@
Created: {{postsService.user.created ? postsService.user.created : 'N/A'}}
-
- +
@@ -21,3 +20,12 @@ + + +
+
+ + +
+
+
diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 00f02e0..d188006 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -33,6 +33,11 @@ export class PostsService implements CanActivate { isLoggedIn = false; token = null; user = null; + + reload_config = new BehaviorSubject(false); + config_reloaded = new BehaviorSubject(false); + + config = null; constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, public snackBar: MatSnackBar) { console.log('PostsService Initialized...'); @@ -53,17 +58,25 @@ export class PostsService implements CanActivate { }), }; - // login stuff - - if (localStorage.getItem('jwt_token')) { - this.token = localStorage.getItem('jwt_token'); - this.httpOptions = { - params: new HttpParams({ - fromString: `apiKey=${this.auth_token}&sessionID=${this.session_id}&jwt=${this.token}` - }), - }; - this.jwtAuth(); - } + // get config + this.loadNavItems().subscribe(res => { + const result = !this.debugMode ? res['config_file'] : res; + if (result) { + this.config = result['YoutubeDLMaterial']; + if (this.config['Advanced']['multi_user_mode']) { + // login stuff + if (localStorage.getItem('jwt_token')) { + this.token = localStorage.getItem('jwt_token'); + this.httpOptions = { + params: new HttpParams({ + fromString: `apiKey=${this.auth_token}&sessionID=${this.session_id}&jwt=${this.token}` + }), + }; + this.jwtAuth(); + } + } + } + }); } canActivate(route, state): Promise { return new Promise(resolve => { @@ -85,6 +98,15 @@ export class PostsService implements CanActivate { return this.http.get(url + 'geturl'); } + reloadConfig() { + this.loadNavItems().subscribe(res => { + const result = !this.debugMode ? res['config_file'] : res; + if (result) { + this.config = result['YoutubeDLMaterial']; + } + }); + } + getVideoFolder() { return this.http.get(this.startPath + 'videofolder'); } diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 2335182..e2e9102 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -166,7 +166,7 @@ Allow multi-download mode
- Require pin for settings + Require pin for settings
@@ -252,9 +252,12 @@ -
+
Allow advanced download
+
+ Multi-user mode +
From 4b2b278439a832d5e5dd584cd1a88d042ca1268f Mon Sep 17 00:00:00 2001 From: Isaac Grynsztein Date: Mon, 27 Apr 2020 04:31:39 -0400 Subject: [PATCH 12/20] Sharing and video downloads on shared videos now work for multi-user mode --- backend/app.js | 41 +++++++++++++++++-- backend/authentication/auth.js | 20 ++++++++- .../share-media-dialog.component.ts | 5 +++ src/app/player/player.component.ts | 18 ++++++-- src/app/posts.services.ts | 11 +++-- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/backend/app.js b/backend/app.js index 224e05b..d6c6666 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1705,7 +1705,18 @@ app.use(compression()); const optionalJwt = function (req, res, next) { const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); - if (multiUserMode) { + if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') || + req.path.includes('/api/audio') || + req.path.includes('/api/video') || + req.path.includes('/api/downloadFile'))) { + // check if shared video + const using_body = req.body && req.body.uuid; + const uuid = using_body ? req.body.uuid : req.query.uuid; + const uid = using_body ? req.body.uid : req.query.uid; + const type = using_body ? req.body.type : req.query.type; + const is_shared = auth_api.getUserVideo(uuid, uid, type, true); + if (is_shared) return next(); + } else if (multiUserMode) { if (!req.query.jwt) { res.sendStatus(401); return; @@ -1878,11 +1889,14 @@ app.get('/api/getMp4s', optionalJwt, function(req, res) { app.post('/api/getFile', optionalJwt, function (req, res) { var uid = req.body.uid; var type = req.body.type; + var uuid = req.body.uuid; var file = null; if (req.isAuthenticated()) { file = auth_api.getUserVideo(req.user.uid, uid, type); + } else if (uuid) { + file = auth_api.getUserVideo(uuid, uid, type, true); } else { if (!type) { file = db.get('files.audio').find({uid: uid}).value(); @@ -1911,10 +1925,21 @@ app.post('/api/getFile', optionalJwt, function (req, res) { }); // video sharing -app.post('/api/enableSharing', function(req, res) { +app.post('/api/enableSharing', optionalJwt, function(req, res) { var type = req.body.type; var uid = req.body.uid; var is_playlist = req.body.is_playlist; + let success = false; + // multi-user mode + if (req.isAuthenticated()) { + // if multi user mode, use this method instead + success = auth_api.changeSharingMode(req.user.uid, uid, type, is_playlist, true); + console.log(success); + res.send({success: success}); + return; + } + + // single-user mode try { success = true; if (!is_playlist && type !== 'subscription') { @@ -1944,10 +1969,20 @@ app.post('/api/enableSharing', function(req, res) { }); }); -app.post('/api/disableSharing', function(req, res) { +app.post('/api/disableSharing', optionalJwt, function(req, res) { var type = req.body.type; var uid = req.body.uid; var is_playlist = req.body.is_playlist; + + // multi-user mode + if (req.isAuthenticated()) { + // if multi user mode, use this method instead + success = auth_api.changeSharingMode(req.user.uid, uid, type, is_playlist, false); + res.send({success: success}); + return; + } + + // single-user mode try { success = true; if (!is_playlist && type !== 'subscription') { diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 2a9eceb..a274198 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -237,7 +237,7 @@ exports.getUserVideos = function(user_uid, type) { return user['files'][type]; } -exports.getUserVideo = function(user_uid, file_uid, type) { +exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false) { if (!type) { file = db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value(); if (!file) { @@ -249,6 +249,10 @@ exports.getUserVideo = function(user_uid, file_uid, type) { } if (!file && type) file = db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + + // prevent unauthorized users from accessing the file info + if (requireSharing && !file['sharingEnabled']) file = null; + return file; } @@ -366,6 +370,20 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals return success; } +exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enabled) { + let success = false; + const user_db_obj = db.get('users').find({uid: user_uid}); + if (user_db_obj.value()) { + const file_db_obj = is_playlist ? user_db_obj.get(`playlists.${type}`).find({uid: file_uid}) : user_db_obj.get(`files.${type}`).find({uid: file_uid}); + if (file_db_obj.value()) { + success = true; + file_db_obj.assign({sharingEnabled: enabled}).write(); + } + } + + return success; +} + function getToken(queryParams) { if (queryParams && queryParams.jwt) { var parted = queryParams.jwt.split(' '); diff --git a/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts b/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts index 950a6cc..d6771f5 100644 --- a/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts +++ b/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts @@ -13,6 +13,7 @@ export class ShareMediaDialogComponent implements OnInit { type = null; uid = null; + uuid = null; share_url = null; sharing_enabled = null; is_playlist = null; @@ -24,11 +25,15 @@ export class ShareMediaDialogComponent implements OnInit { if (this.data) { this.type = this.data.type; this.uid = this.data.uid; + this.uuid = this.data.uuid; this.sharing_enabled = this.data.sharing_enabled; this.is_playlist = this.data.is_playlist; const arg = (this.is_playlist ? ';id=' : ';uid='); this.share_url = window.location.href.split(';')[0] + arg + this.uid; + if (this.uuid) { + this.share_url += ';uuid=' + this.uuid; + } } } diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 07f33fa..06d3f53 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -39,6 +39,7 @@ export class PlayerComponent implements OnInit { uid = null; // used for non-subscription files (audio, video, playlist) subscriptionName = null; subPlaylist = null; + uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video is_shared = false; @@ -50,6 +51,8 @@ export class PlayerComponent implements OnInit { videoFolderPath = null; subscriptionFolderPath = null; + sharingEnabled = null; + // url-mode params url = null; name = null; @@ -73,6 +76,7 @@ export class PlayerComponent implements OnInit { this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist'); this.url = this.route.snapshot.paramMap.get('url'); this.name = this.route.snapshot.paramMap.get('name'); + this.uuid = this.route.snapshot.paramMap.get('uuid'); // loading config this.postsService.loadNavItems().subscribe(res => { // loads settings @@ -123,12 +127,13 @@ export class PlayerComponent implements OnInit { getFile() { const already_has_filenames = !!this.fileNames; - this.postsService.getFile(this.uid, null).subscribe(res => { + this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => { this.db_file = res['file']; if (!this.db_file) { this.openSnackBar('Failed to get file information from the server.', 'Dismiss'); return; } + this.sharingEnabled = this.db_file.sharingEnabled; if (!this.fileNames) { // means it's a shared video if (!this.id) { @@ -186,7 +191,10 @@ export class PlayerComponent implements OnInit { // adds user token if in multi-user-mode if (this.postsService.isLoggedIn) { - fullLocation += '?jwt=' + this.postsService.token; + fullLocation += `?jwt=${this.postsService.token}`; + if (this.is_shared) { fullLocation += `&uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; } + } else if (this.is_shared) { + fullLocation += `?uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; } // if it has a slash (meaning it's in a directory), only get the file name for the label let label = null; @@ -278,7 +286,8 @@ export class PlayerComponent implements OnInit { const ext = (this.type === 'audio') ? '.mp3' : '.mp4'; const filename = this.playlist[0].title; this.downloading = true; - this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist).subscribe(res => { + this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist, + this.is_shared ? this.db_file['uid'] : null, this.uuid).subscribe(res => { this.downloading = false; const blob: Blob = res; saveAs(blob, filename + ext); @@ -365,7 +374,8 @@ export class PlayerComponent implements OnInit { uid: this.id ? this.id : this.uid, type: this.type, sharing_enabled: this.id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled, - is_playlist: !!this.id + is_playlist: !!this.id, + uuid: this.postsService.isLoggedIn ? this.postsService.user.uid : null }, width: '60vw' }); diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index d188006..0689100 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -179,18 +179,21 @@ export class PostsService implements CanActivate { return this.http.get(this.path + 'getMp4s', this.httpOptions); } - getFile(uid, type) { - return this.http.post(this.path + 'getFile', {uid: uid, type: type}, this.httpOptions); + getFile(uid, type, uuid = null) { + return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions); } - downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null) { + downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null, + uid = null, uuid = null) { return this.http.post(this.path + 'downloadFile', {fileNames: fileName, type: type, zip_mode: Array.isArray(fileName), outputName: outputName, fullPathProvided: fullPathProvided, subscriptionName: subscriptionName, - subPlaylist: subPlaylist + subPlaylist: subPlaylist, + uuid: uuid, + uid: uid }, {responseType: 'blob', params: this.httpOptions.params}); } From ff51a49d1b8cd506bec8afcc2e567fa6765c2c43 Mon Sep 17 00:00:00 2001 From: Tzahi12345 Date: Wed, 29 Apr 2020 00:04:04 -0400 Subject: [PATCH 13/20] Removed unused import --- backend/authentication/auth.js | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index a274198..9607f8b 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -48,7 +48,6 @@ opts.secretOrKey = SERVER_SECRET; opts.audience = 'example.com';*/ exports.passport = require('passport'); -var BasicStrategy = require('passport-http').BasicStrategy; exports.passport.serializeUser(function(user, done) { done(null, user); From a48e12276340fea00efe2693e8a95f56737c1149 Mon Sep 17 00:00:00 2001 From: Adam Verga Date: Wed, 29 Apr 2020 20:15:15 -0400 Subject: [PATCH 14/20] Settings are now more centralized in the frontend --- src/app/app.component.ts | 28 ++--- src/app/main/main.component.ts | 104 ++++++++---------- src/app/posts.services.ts | 5 + src/app/settings/settings.component.ts | 12 +- .../subscription/subscription.component.ts | 11 +- 5 files changed, 73 insertions(+), 87 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 08e81cb..0bf3165 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -62,8 +62,7 @@ export class AppComponent implements OnInit { } }); - this.loadConfig(); - this.postsService.settings_changed.subscribe(changed => { + this.postsService.config_reloaded.subscribe(changed => { if (changed) { this.loadConfig(); } @@ -77,22 +76,17 @@ export class AppComponent implements OnInit { loadConfig() { // loading config - this.postsService.loadNavItems().subscribe(res => { // loads settings - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.topBarTitle = result['YoutubeDLMaterial']['Extra']['title_top']; - this.settingsPinRequired = result['YoutubeDLMaterial']['Extra']['settings_pin_required']; - const themingExists = result['YoutubeDLMaterial']['Themes']; - this.defaultTheme = themingExists ? result['YoutubeDLMaterial']['Themes']['default_theme'] : 'default'; - this.allowThemeChange = themingExists ? result['YoutubeDLMaterial']['Themes']['allow_theme_change'] : true; - this.allowSubscriptions = result['YoutubeDLMaterial']['Subscriptions']['allow_subscriptions']; + this.topBarTitle = this.postsService.config['Extra']['title_top']; + this.settingsPinRequired = this.postsService.config['Extra']['settings_pin_required']; + const themingExists = this.postsService.config['Themes']; + this.defaultTheme = themingExists ? this.postsService.config['Themes']['default_theme'] : 'default'; + this.allowThemeChange = themingExists ? this.postsService.config['Themes']['allow_theme_change'] : true; + this.allowSubscriptions = this.postsService.config['Subscriptions']['allow_subscriptions']; - // sets theme to config default if it doesn't exist - if (!localStorage.getItem('theme')) { - this.setTheme(themingExists ? this.defaultTheme : 'default'); - } - }, error => { - console.log(error); - }); + // sets theme to config default if it doesn't exist + if (!localStorage.getItem('theme')) { + this.setTheme(themingExists ? this.defaultTheme : 'default'); + } } // theme stuff diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 38339d3..b76d6d0 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -231,80 +231,72 @@ export class MainComponent implements OnInit { async loadConfig() { // loading config - this.postsService.loadNavItems().subscribe(res => { // loads settings - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.fileManagerEnabled = result['YoutubeDLMaterial']['Extra']['file_manager_enabled']; - this.downloadOnlyMode = result['YoutubeDLMaterial']['Extra']['download_only_mode']; - this.allowMultiDownloadMode = result['YoutubeDLMaterial']['Extra']['allow_multi_download_mode']; - this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio']; - this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video']; - this.use_youtubedl_archive = result['YoutubeDLMaterial']['Downloader']['use_youtubedl_archive']; - this.globalCustomArgs = result['YoutubeDLMaterial']['Downloader']['custom_args']; - this.youtubeSearchEnabled = result['YoutubeDLMaterial']['API'] && result['YoutubeDLMaterial']['API']['use_youtube_API'] && - result['YoutubeDLMaterial']['API']['youtube_API_key']; - this.youtubeAPIKey = this.youtubeSearchEnabled ? result['YoutubeDLMaterial']['API']['youtube_API_key'] : null; - this.allowQualitySelect = result['YoutubeDLMaterial']['Extra']['allow_quality_select']; - this.allowAdvancedDownload = result['YoutubeDLMaterial']['Advanced']['allow_advanced_download']; - this.useDefaultDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['use_default_downloading_agent']; - this.customDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['custom_downloading_agent']; + this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled']; + this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode']; + this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode']; + this.audioFolderPath = this.postsService.config['Downloader']['path-audio']; + this.videoFolderPath = this.postsService.config['Downloader']['path-video']; + this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive']; + this.globalCustomArgs = this.postsService.config['Downloader']['custom_args']; + this.youtubeSearchEnabled = this.postsService.config['API'] && this.postsService.config['API']['use_youtube_API'] && + this.postsService.config['API']['youtube_API_key']; + this.youtubeAPIKey = this.youtubeSearchEnabled ? this.postsService.config['API']['youtube_API_key'] : null; + this.allowQualitySelect = this.postsService.config['Extra']['allow_quality_select']; + this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download']; + this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent']; + this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent']; - if (this.fileManagerEnabled) { - this.getMp3s(); - this.getMp4s(); + if (this.fileManagerEnabled) { + this.getMp3s(); + this.getMp4s(); + } + + if (this.youtubeSearchEnabled && this.youtubeAPIKey) { + this.youtubeSearch.initializeAPI(this.youtubeAPIKey); + this.attachToInput(); + } + + // set final cache items + if (this.allowAdvancedDownload) { + if (localStorage.getItem('customArgsEnabled') !== null) { + this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true'; } - if (this.youtubeSearchEnabled && this.youtubeAPIKey) { - this.youtubeSearch.initializeAPI(this.youtubeAPIKey); - this.attachToInput(); + if (localStorage.getItem('customOutputEnabled') !== null) { + this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true'; } - // set final cache items - if (this.allowAdvancedDownload) { - if (localStorage.getItem('customArgsEnabled') !== null) { - this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true'; - } - - if (localStorage.getItem('customOutputEnabled') !== null) { - this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true'; - } - - if (localStorage.getItem('youtubeAuthEnabled') !== null) { - this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true'; - } - - // set advanced inputs - const customArgs = localStorage.getItem('customArgs'); - const customOutput = localStorage.getItem('customOutput'); - const youtubeUsername = localStorage.getItem('youtubeUsername'); - - if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }; - if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }; - if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }; + if (localStorage.getItem('youtubeAuthEnabled') !== null) { + this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true'; } - // get downloads routine - setInterval(() => { - if (this.current_download) { - this.getCurrentDownload(); - } - }, 500); + // set advanced inputs + const customArgs = localStorage.getItem('customArgs'); + const customOutput = localStorage.getItem('customOutput'); + const youtubeUsername = localStorage.getItem('youtubeUsername'); - return true; + if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }; + if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }; + if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }; + } - }, error => { - console.log(error); + // get downloads routine + setInterval(() => { + if (this.current_download) { + this.getCurrentDownload(); + } + }, 500); - return false; - }); + return true; } // app initialization. ngOnInit() { this.configLoad(); - this.postsService.settings_changed.subscribe(changed => { + this.postsService.config_reloaded.subscribe(changed => { if (changed) { this.loadConfig(); } diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 0689100..98c16ba 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -77,6 +77,10 @@ export class PostsService implements CanActivate { } } }); + + this.reload_config.subscribe(yes_reload => { + if (yes_reload) { this.reloadConfig(); } + }); } canActivate(route, state): Promise { return new Promise(resolve => { @@ -103,6 +107,7 @@ export class PostsService implements CanActivate { const result = !this.debugMode ? res['config_file'] : res; if (result) { this.config = result['YoutubeDLMaterial']; + this.config_reloaded = true; } }); } diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 57a14b0..b8fc2b9 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -51,14 +51,8 @@ export class SettingsComponent implements OnInit { } getConfig() { - this.loading_config = true; - this.postsService.loadNavItems().subscribe(res => { - this.loading_config = false; - // successfully loaded config - - this.initial_config = !this.postsService.debugMode ? res['config_file']['YoutubeDLMaterial'] : res['YoutubeDLMaterial']; - this.new_config = JSON.parse(JSON.stringify(this.initial_config)); - }); + this.initial_config = this.postsService.config; + this.new_config = JSON.parse(JSON.stringify(this.initial_config)); } settingsSame() { @@ -70,8 +64,8 @@ export class SettingsComponent implements OnInit { this.postsService.setConfig(settingsToSave).subscribe(res => { if (res['success']) { // sets new config as old config - this.postsService.settings_changed.next(true); this.initial_config = JSON.parse(JSON.stringify(this.new_config)); + this.postsService.reload_config.next(true); } }, err => { console.error('Failed to save config!'); diff --git a/src/app/subscription/subscription/subscription.component.ts b/src/app/subscription/subscription/subscription.component.ts index 3c7545f..fce7761 100644 --- a/src/app/subscription/subscription/subscription.component.ts +++ b/src/app/subscription/subscription/subscription.component.ts @@ -50,7 +50,11 @@ export class SubscriptionComponent implements OnInit { this.id = this.route.snapshot.paramMap.get('id'); this.getSubscription(); - this.getConfig(); + this.postsService.config_reloaded.subscribe(changed => { + if (changed) { + this.getConfig(); + } + }); } // set filter property to cached @@ -78,10 +82,7 @@ export class SubscriptionComponent implements OnInit { } getConfig() { - this.postsService.loadNavItems().subscribe(res => { - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.use_youtubedl_archive = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_use_youtubedl_archive']; - }); + this.use_youtubedl_archive = this.postsService.config['Subscriptions']['subscriptions_use_youtubedl_archive']; } goToFile(emit_obj) { From 0fb00bac12b4b3f768becfd6333a3d145f983e1f Mon Sep 17 00:00:00 2001 From: Adam Verga Date: Wed, 29 Apr 2020 20:46:29 -0400 Subject: [PATCH 15/20] Initialization on auth component happens in a separate function, users_db primarily sits in app.js Fixed bug where current download would set to null, but maincomponent still tried to parse it --- backend/app.js | 13 +++- backend/authentication/auth.js | 132 +++++++++++++++++---------------- backend/subscriptions.js | 7 +- src/app/main/main.component.ts | 3 + 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/backend/app.js b/backend/app.js index 055b507..5cf71a0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -32,9 +32,13 @@ var app = express(); // database setup const FileSync = require('lowdb/adapters/FileSync') + const adapter = new FileSync('./appdata/db.json'); const db = low(adapter) +const users_adapter = new FileSync('./appdata/users.json'); +const users_db = low(users_adapter); + // check if debug mode let debugMode = process.env.YTDL_MODE === 'debug'; @@ -62,7 +66,8 @@ const logger = winston.createLogger({ }); config_api.setLogger(logger); -subscriptions_api.initialize(db, logger); +subscriptions_api.initialize(db, users_db, logger); +auth_api.initialize(users_db, logger); // var GithubContent = require('github-content'); @@ -84,6 +89,12 @@ db.defaults( files_to_db_migration_complete: false }).write(); +users_db.defaults( + { + users: [] + } +).write(); + // config values var frontendUrl = null; var backendUrl = null; diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 9607f8b..701b47f 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -1,16 +1,11 @@ -const low = require('lowdb') -const FileSync = require('lowdb/adapters/FileSync'); -const adapter = new FileSync('./appdata/users.json'); -const db = low(adapter); const path = require('path'); const config_api = require('../config'); var subscriptions_api = require('../subscriptions') const fs = require('fs-extra'); -db.defaults( - { - users: [] - } -).write(); +var jwt = require('jsonwebtoken'); +const { uuid } = require('uuidv4'); +var bcrypt = require('bcrypt'); + var LocalStrategy = require('passport-local').Strategy; var JwtStrategy = require('passport-jwt').Strategy, @@ -18,35 +13,56 @@ var JwtStrategy = require('passport-jwt').Strategy, // other required vars let logger = null; +var users_db = null; +let SERVER_SECRET = null; +let JWT_EXPIRATION = null; +let opts = null; +let saltRounds = null; -exports.setLogger = function(input_logger) { +exports.initialize = function(input_users_db, input_logger) { + setLogger(input_logger) + setDB(input_users_db); + + /************************* + * Authentication module + ************************/ + saltRounds = 10; + + JWT_EXPIRATION = (60 * 60); // one hour + + SERVER_SECRET = null; + if (users_db.get('jwt_secret').value()) { + SERVER_SECRET = users_db.get('jwt_secret').value(); + } else { + SERVER_SECRET = uuid(); + users_db.set('jwt_secret', SERVER_SECRET).write(); + } + + opts = {} + opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); + opts.secretOrKey = SERVER_SECRET; + /*opts.issuer = 'example.com'; + opts.audience = 'example.com';*/ + + exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { + const user = users_db.get('users').find({uid: jwt_payload.user.uid}).value(); + if (user) { + return done(null, user); + } else { + return done(null, false); + // or you could create a new account + } + })); +} + +function setLogger(input_logger) { logger = input_logger; } -/************************* - * Authentication module - ************************/ -var bcrypt = require('bcrypt'); -const saltRounds = 10; - -var jwt = require('jsonwebtoken'); -const JWT_EXPIRATION = (60 * 60); // one hour - -const { uuid } = require('uuidv4'); -let SERVER_SECRET = null; -if (db.get('jwt_secret').value()) { - SERVER_SECRET = db.get('jwt_secret').value(); -} else { - SERVER_SECRET = uuid(); - db.set('jwt_secret', SERVER_SECRET).write(); +function setDB(input_users_db) { + users_db = input_users_db; } -var opts = {} -opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); -opts.secretOrKey = SERVER_SECRET; -/*opts.issuer = 'example.com'; -opts.audience = 'example.com';*/ - exports.passport = require('passport'); exports.passport.serializeUser(function(user, done) { @@ -82,17 +98,17 @@ exports.registerUser = function(req, res) { created: Date.now() }; // check if user exists - if (db.get('users').find({uid: userid}).value()) { + if (users_db.get('users').find({uid: userid}).value()) { // user id is taken! logger.error('Registration failed: UID is already taken!'); res.status(409).send('UID is already taken!'); - } else if (db.get('users').find({name: username}).value()) { + } else if (users_db.get('users').find({name: username}).value()) { // user name is taken! logger.error('Registration failed: User name is already taken!'); res.status(409).send('User name is already taken!'); } else { // add to db - db.get('users').push(new_user).write(); + users_db.get('users').push(new_user).write(); logger.verbose(`New user created: ${new_user.name}`); res.send({ user: new_user @@ -123,21 +139,13 @@ exports.registerUser = function(req, res) { * This checks that the credentials are valid. * If so, passes the user info to the next middleware. ************************************************/ -exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { - const user = db.get('users').find({uid: jwt_payload.user.uid}).value(); - if (user) { - return done(null, user); - } else { - return done(null, false); - // or you could create a new account - } -})); + exports.passport.use(new LocalStrategy({ usernameField: 'userid', passwordField: 'password'}, function(username, password, done) { - const user = db.get('users').find({name: username}).value(); + const user = users_db.get('users').find({name: username}).value(); if (!user) { console.log('user not found'); return done(null, false); } if (user) { return done(null, bcrypt.compareSync(password, user.passhash) ? user : false); @@ -147,7 +155,7 @@ exports.passport.use(new LocalStrategy({ /*passport.use(new BasicStrategy( function(userid, plainTextPassword, done) { - const user = db.get('users').find({name: userid}).value(); + const user = users_db.get('users').find({name: userid}).value(); if (user) { var hashedPwd = user.passhash; return bcrypt.compare(plainTextPassword, hashedPwd); @@ -232,22 +240,22 @@ exports.ensureAuthenticatedElseError = function(req, res, next) { // video stuff exports.getUserVideos = function(user_uid, type) { - const user = db.get('users').find({uid: user_uid}).value(); + const user = users_db.get('users').find({uid: user_uid}).value(); return user['files'][type]; } exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false) { if (!type) { - file = db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value(); + file = users_db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value(); if (!file) { - file = db.get('users').find({uid: user_uid}).get(`files.video`).find({uid: file_uid}).value(); + file = users_db.get('users').find({uid: user_uid}).get(`files.video`).find({uid: file_uid}).value(); if (file) type = 'video'; } else { type = 'audio'; } } - if (!file && type) file = db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + if (!file && type) file = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); // prevent unauthorized users from accessing the file info if (requireSharing && !file['sharingEnabled']) file = null; @@ -256,54 +264,54 @@ exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false } exports.addPlaylist = function(user_uid, new_playlist, type) { - db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write(); + users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write(); return true; } exports.updatePlaylist = function(user_uid, playlistID, new_filenames, type) { - db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames}); + users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames}); return true; } exports.removePlaylist = function(user_uid, playlistID, type) { - db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write(); + users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write(); return true; } exports.getUserPlaylists = function(user_uid, type) { - const user = db.get('users').find({uid: user_uid}).value(); + const user = users_db.get('users').find({uid: user_uid}).value(); return user['playlists'][type]; } exports.getUserPlaylist = function(user_uid, playlistID, type) { let playlist = null; if (!type) { - playlist = db.get('users').find({uid: user_uid}).get(`playlists.audio`).find({id: playlistID}).value(); + playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.audio`).find({id: playlistID}).value(); if (!playlist) { - playlist = db.get('users').find({uid: user_uid}).get(`playlists.video`).find({id: playlistID}).value(); + playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.video`).find({id: playlistID}).value(); if (playlist) type = 'video'; } else { type = 'audio'; } } - if (!playlist) playlist = db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).value(); + if (!playlist) playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).value(); return playlist; } exports.registerUserFile = function(user_uid, file_object, type) { - db.get('users').find({uid: user_uid}).get(`files.${type}`) + users_db.get('users').find({uid: user_uid}).get(`files.${type}`) .remove({ path: file_object['path'] }).write(); - db.get('users').find({uid: user_uid}).get(`files.${type}`) + users_db.get('users').find({uid: user_uid}).get(`files.${type}`) .push(file_object) .write(); } exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) { let success = false; - const file_obj = db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + const file_obj = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); if (file_obj) { const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); const ext = type === 'audio' ? '.mp3' : '.mp4'; @@ -320,7 +328,7 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals } const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext); - db.get('users').find({uid: user_uid}).get(`files.${type}`) + users_db.get('users').find({uid: user_uid}).get(`files.${type}`) .remove({ uid: file_uid }).write(); @@ -371,7 +379,7 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enabled) { let success = false; - const user_db_obj = db.get('users').find({uid: user_uid}); + const user_db_obj = users_db.get('users').find({uid: user_uid}); if (user_db_obj.value()) { const file_db_obj = is_playlist ? user_db_obj.get(`playlists.${type}`).find({uid: file_uid}) : user_db_obj.get(`files.${type}`).find({uid: file_uid}); if (file_db_obj.value()) { diff --git a/backend/subscriptions.js b/backend/subscriptions.js index e542714..2ae810f 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -11,11 +11,12 @@ const debugMode = process.env.YTDL_MODE === 'debug'; var logger = null; var db = null; -function setDB(input_db) { db = input_db; } +var users_db = null; +function setDB(input_db, input_users_db) { db = input_db; users_db = input_users_db } function setLogger(input_logger) { logger = input_logger; } -function initialize(input_db, input_logger) { - setDB(input_db); +function initialize(input_db, input_users_db, input_logger) { + setDB(input_db, input_users_db); setLogger(input_logger); } diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index b76d6d0..bc365d0 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -1129,6 +1129,9 @@ export class MainComponent implements OnInit { } getCurrentDownload() { + if (!this.current_download) { + return; + } const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']; this.postsService.getCurrentDownload(this.postsService.session_id, ui_uid).subscribe(res => { if (res['download']) { From 31f581c6420517d80439807a20260a2dedd4e7cc Mon Sep 17 00:00:00 2001 From: Tzahi12345 Date: Thu, 30 Apr 2020 04:54:41 -0400 Subject: [PATCH 16/20] Subscriptions now support multi-user-mode Fixed bug where playlist subscription downloads would fail due to a mislabeled parameter Components that are routes now make sure auth is finished before sending requests to the backend --- backend/app.js | 72 ++++++++---- backend/authentication/auth.js | 1 + backend/subscriptions.js | 108 +++++++++++++----- src/app/main/main.component.ts | 12 +- src/app/player/player.component.ts | 86 +++++++------- src/app/posts.services.ts | 5 +- .../subscription/subscription.component.ts | 4 +- .../subscriptions/subscriptions.component.ts | 15 ++- 8 files changed, 207 insertions(+), 96 deletions(-) diff --git a/backend/app.js b/backend/app.js index 5cf71a0..aaa4550 100644 --- a/backend/app.js +++ b/backend/app.js @@ -588,7 +588,18 @@ function calculateSubcriptionRetrievalDelay(amount) { } function watchSubscriptions() { - let subscriptions = subscriptions_api.getAllSubscriptions(); + let subscriptions = null; + + const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); + if (multiUserMode) { + subscriptions = []; + let users = users_db.get('users').value(); + for (let i = 0; i < users.length; i++) { + if (users[i]['subscriptions']) subscriptions = subscriptions.concat(users[i]['subscriptions']); + } + } else { + subscriptions = subscriptions_api.getAllSubscriptions(); + } if (!subscriptions) return; @@ -600,7 +611,7 @@ function watchSubscriptions() { let sub = subscriptions[i]; logger.verbose('Watching ' + sub.name + ' with delay interval of ' + delay_interval); setTimeout(() => { - subscriptions_api.getVideosForSub(sub); + subscriptions_api.getVideosForSub(sub, sub.user_uid); }, current_delay); current_delay += delay_interval; if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0; @@ -2022,17 +2033,19 @@ app.post('/api/disableSharing', optionalJwt, function(req, res) { }); }); -app.post('/api/subscribe', async (req, res) => { +app.post('/api/subscribe', optionalJwt, async (req, res) => { let name = req.body.name; let url = req.body.url; let timerange = req.body.timerange; let streamingOnly = req.body.streamingOnly; + let user_uid = req.isAuthenticated() ? req.user.uid : null; const new_sub = { name: name, url: url, id: uuid(), - streamingOnly: streamingOnly + streamingOnly: streamingOnly, + user_uid: user_uid }; // adds timerange if it exists, otherwise all videos will be downloaded @@ -2040,7 +2053,7 @@ app.post('/api/subscribe', async (req, res) => { new_sub.timerange = timerange; } - const result_obj = await subscriptions_api.subscribe(new_sub); + const result_obj = await subscriptions_api.subscribe(new_sub, user_uid); if (result_obj.success) { res.send({ @@ -2054,11 +2067,12 @@ app.post('/api/subscribe', async (req, res) => { } }); -app.post('/api/unsubscribe', async (req, res) => { +app.post('/api/unsubscribe', optionalJwt, async (req, res) => { let deleteMode = req.body.deleteMode let sub = req.body.sub; + let user_uid = req.isAuthenticated() ? req.user.uid : null; - let result_obj = subscriptions_api.unsubscribe(sub, deleteMode); + let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid); if (result_obj.success) { res.send({ success: result_obj.success @@ -2071,12 +2085,13 @@ app.post('/api/unsubscribe', async (req, res) => { } }); -app.post('/api/deleteSubscriptionFile', async (req, res) => { +app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => { let deleteForever = req.body.deleteForever; let file = req.body.file; let sub = req.body.sub; + let user_uid = req.isAuthenticated() ? req.user.uid : null; - let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever); + let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever, user_uid); if (success) { res.send({ @@ -2088,11 +2103,12 @@ app.post('/api/deleteSubscriptionFile', async (req, res) => { }); -app.post('/api/getSubscription', async (req, res) => { +app.post('/api/getSubscription', optionalJwt, async (req, res) => { let subID = req.body.id; + let user_uid = req.isAuthenticated() ? req.user.uid : null; // get sub from db - let subscription = subscriptions_api.getSubscription(subID); + let subscription = subscriptions_api.getSubscription(subID, user_uid); if (!subscription) { // failed to get subscription from db, send 400 error @@ -2102,7 +2118,12 @@ app.post('/api/getSubscription', async (req, res) => { // get sub videos if (subscription.name && !subscription.streamingOnly) { - let base_path = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let base_path = null; + if (user_uid) + base_path = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + base_path = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let appended_base_path = path.join(base_path, subscription.isPlaylist ? 'playlists' : 'channels', subscription.name, '/'); let files; try { @@ -2159,18 +2180,22 @@ app.post('/api/getSubscription', async (req, res) => { } }); -app.post('/api/downloadVideosForSubscription', async (req, res) => { +app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => { let subID = req.body.subID; - let sub = subscriptions_api.getSubscription(subID); - subscriptions_api.getVideosForSub(sub); + let user_uid = req.isAuthenticated() ? req.user.uid : null; + + let sub = subscriptions_api.getSubscription(subID, user_uid); + subscriptions_api.getVideosForSub(sub, user_uid); res.send({ success: true }); }); -app.post('/api/getAllSubscriptions', async (req, res) => { +app.post('/api/getAllSubscriptions', optionalJwt, async (req, res) => { + let user_uid = req.isAuthenticated() ? req.user.uid : null; + // get subs from api - let subscriptions = subscriptions_api.getAllSubscriptions(); + let subscriptions = subscriptions_api.getAllSubscriptions(user_uid); res.send({ subscriptions: subscriptions @@ -2360,7 +2385,7 @@ app.post('/api/downloadFile', optionalJwt, async (req, res) => { let outputName = req.body.outputName; let fullPathProvided = req.body.fullPathProvided; let subscriptionName = req.body.subscriptionName; - let subscriptionPlaylist = req.body.subscriptionPlaylist; + let subscriptionPlaylist = req.body.subPlaylist; let file = null; if (!zip_mode) { fileNames = decodeURIComponent(fileNames); @@ -2369,14 +2394,19 @@ app.post('/api/downloadFile', optionalJwt, async (req, res) => { const ext = is_audio ? '.mp3' : '.mp4'; let base_path = fileFolderPath; + let usersFileFolder = null; if (req.isAuthenticated()) { - const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); base_path = path.join(usersFileFolder, req.user.uid, type); } if (!subscriptionName) { file = path.join(__dirname, base_path, fileNames + ext); } else { - let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let basePath = null; + if (usersFileFolder) + basePath = path.join(usersFileFolder, req.user.uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') } } else { @@ -2509,7 +2539,7 @@ app.get('/api/video/:id', optionalJwt, function(req , res){ let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); if (optionalParams['subName']) { const isPlaylist = optionalParams['subPlaylist']; - file_path = path.join(usersFileFolder, req.user.uid, (isPlaylist === 'true' ? 'playlists/' : 'channels/'), id + '.mp4') + file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + '.mp4') } else { file_path = path.join(usersFileFolder, req.user.uid, 'video', id + '.mp4'); } diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 701b47f..5b5d0a6 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -95,6 +95,7 @@ exports.registerUser = function(req, res) { audio: [], video: [] }, + subscriptions: [], created: Date.now() }; // check if user exists diff --git a/backend/subscriptions.js b/backend/subscriptions.js index 2ae810f..cccd224 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -20,7 +20,7 @@ function initialize(input_db, input_users_db, input_logger) { setLogger(input_logger); } -async function subscribe(sub) { +async function subscribe(sub, user_uid = null) { const result_obj = { success: false, error: '' @@ -29,7 +29,14 @@ async function subscribe(sub) { // sub should just have url and name. here we will get isPlaylist and path sub.isPlaylist = sub.url.includes('playlist'); - if (db.get('subscriptions').find({url: sub.url}).value()) { + let url_exists = false; + + if (user_uid) + url_exists = !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({url: sub.url}).value() + else + url_exists = !!db.get('subscriptions').find({url: sub.url}).value(); + + if (url_exists) { logger.info('Sub already exists'); result_obj.error = 'Subcription with URL ' + sub.url + ' already exists!'; resolve(result_obj); @@ -37,19 +44,27 @@ async function subscribe(sub) { } // add sub to db - db.get('subscriptions').push(sub).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').push(sub).write(); + else + db.get('subscriptions').push(sub).write(); let success = await getSubscriptionInfo(sub); result_obj.success = success; result_obj.sub = sub; - getVideosForSub(sub); + getVideosForSub(sub, user_uid); resolve(result_obj); }); } -async function getSubscriptionInfo(sub) { - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); +async function getSubscriptionInfo(sub, user_uid = null) { + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + return new Promise(resolve => { // get videos let downloadConfig = ['--dump-json', '--playlist-end', '1'] @@ -75,16 +90,19 @@ async function getSubscriptionInfo(sub) { if (!output_json) { continue; } - if (!sub.name) { sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader; // if it's now valid, update if (sub.name) { - db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); + else + db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); } } - if (!sub.archive) { + const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); + if (useArchive && !sub.archive) { // must create the archive const archive_dir = path.join(__dirname, basePath, 'archives', sub.name); const archive_path = path.join(archive_dir, 'archive.txt'); @@ -95,7 +113,10 @@ async function getSubscriptionInfo(sub) { // updates subscription sub.archive = archive_dir; - db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); + else + db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); } // TODO: get even more info @@ -108,13 +129,20 @@ async function getSubscriptionInfo(sub) { }); } -async function unsubscribe(sub, deleteMode) { +async function unsubscribe(sub, deleteMode, user_uid = null) { return new Promise(async resolve => { - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); let result_obj = { success: false, error: '' }; let id = sub.id; - db.get('subscriptions').remove({id: id}).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write(); + else + db.get('subscriptions').remove({id: id}).write(); const appendedBasePath = getAppendedBasePath(sub, basePath); if (deleteMode && fs.existsSync(appendedBasePath)) { @@ -132,8 +160,12 @@ async function unsubscribe(sub, deleteMode) { } -async function deleteSubscriptionFile(sub, file, deleteForever) { - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); +async function deleteSubscriptionFile(sub, file, deleteForever, user_uid = null) { + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); const appendedBasePath = getAppendedBasePath(sub, basePath); const name = file; @@ -181,14 +213,27 @@ async function deleteSubscriptionFile(sub, file, deleteForever) { }); } -async function getVideosForSub(sub) { +async function getVideosForSub(sub, user_uid = null) { return new Promise(resolve => { - if (!subExists(sub.id)) { + if (!subExists(sub.id, user_uid)) { resolve(false); return; } - const sub_db = db.get('subscriptions').find({id: sub.id}); - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + + // get sub_db + let sub_db = null; + if (user_uid) + sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}); + else + sub_db = db.get('subscriptions').find({id: sub.id}); + + // get basePath + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); let appendedBasePath = null @@ -263,23 +308,32 @@ async function getVideosForSub(sub) { }); } -function getAllSubscriptions() { - const subscriptions = db.get('subscriptions').value(); - return subscriptions; +function getAllSubscriptions(user_uid = null) { + if (user_uid) + return users_db.get('users').find({uid: user_uid}).get('subscriptions').value(); + else + return db.get('subscriptions').value(); } -function getSubscription(subID) { - return db.get('subscriptions').find({id: subID}).value(); +function getSubscription(subID, user_uid = null) { + if (user_uid) + return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value(); + else + return db.get('subscriptions').find({id: subID}).value(); } -function subExists(subID) { - return !!db.get('subscriptions').find({id: subID}).value(); +function subExists(subID, user_uid = null) { + if (user_uid) + return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value(); + else + return !!db.get('subscriptions').find({id: subID}).value(); } // helper functions function getAppendedBasePath(sub, base_path) { - return base_path + (sub.isPlaylist ? 'playlists/' : 'channels/') + sub.name; + + return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); } // https://stackoverflow.com/a/32197381/8088021 diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index bc365d0..0645c9b 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -196,6 +196,7 @@ export class MainComponent implements OnInit { selectedQuality = ''; formats_loading = false; + config_loaded = false; @ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef; @ViewChildren('audiofilecard') audioFileCards: QueryList; @@ -294,7 +295,16 @@ export class MainComponent implements OnInit { // app initialization. ngOnInit() { - this.configLoad(); + if (this.postsService.config) { + this.configLoad(); + } else { + this.postsService.config_reloaded.subscribe(changed => { + if (changed && !this.config_loaded) { + this.config_loaded = true; + this.configLoad(); + } + }); + } this.postsService.config_reloaded.subscribe(changed => { if (changed) { diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 06d3f53..0377063 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -79,45 +79,15 @@ export class PlayerComponent implements OnInit { this.uuid = this.route.snapshot.paramMap.get('uuid'); // loading config - this.postsService.loadNavItems().subscribe(res => { // loads settings - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.baseStreamPath = this.postsService.path; - this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio']; - this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video']; - this.subscriptionFolderPath = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_base_path']; - this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null; - - if (!this.fileNames) { - this.is_shared = true; - } - - if (this.uid && !this.id) { - this.getFile(); - } else if (this.id) { - this.getPlaylistFiles(); - } - - if (this.url) { - // if a url is given, just stream the URL - this.playlist = []; - const imedia: IMedia = { - title: this.name, - label: this.name, - src: this.url, - type: 'video/mp4' + if (this.postsService.config) { + this.processConfig(); + } else { + this.postsService.config_reloaded.subscribe(changed => { // loads settings + if (changed) { + this.processConfig(); } - this.playlist.push(imedia); - this.currentItem = this.playlist[0]; - this.currentIndex = 0; - this.show_player = true; - } else if (this.type === 'subscription' || this.fileNames) { - this.show_player = true; - this.parseFileNames(); - } - }); - - // this.getFileInfos(); - + }); + } } constructor(private postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, @@ -125,6 +95,42 @@ export class PlayerComponent implements OnInit { } + processConfig() { + this.baseStreamPath = this.postsService.path; + this.audioFolderPath = this.postsService.config['Downloader']['path-audio']; + this.videoFolderPath = this.postsService.config['Downloader']['path-video']; + this.subscriptionFolderPath = this.postsService.config['Subscriptions']['subscriptions_base_path']; + this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null; + + if (!this.fileNames) { + this.is_shared = true; + } + + if (this.uid && !this.id) { + this.getFile(); + } else if (this.id) { + this.getPlaylistFiles(); + } + + if (this.url) { + // if a url is given, just stream the URL + this.playlist = []; + const imedia: IMedia = { + title: this.name, + label: this.name, + src: this.url, + type: 'video/mp4' + } + this.playlist.push(imedia); + this.currentItem = this.playlist[0]; + this.currentIndex = 0; + this.show_player = true; + } else if (this.type === 'subscription' || this.fileNames) { + this.show_player = true; + this.parseFileNames(); + } + } + getFile() { const already_has_filenames = !!this.fileNames; this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => { @@ -191,10 +197,10 @@ export class PlayerComponent implements OnInit { // adds user token if in multi-user-mode if (this.postsService.isLoggedIn) { - fullLocation += `?jwt=${this.postsService.token}`; + fullLocation += (this.subscriptionName ? '&' : '?') + `jwt=${this.postsService.token}`; if (this.is_shared) { fullLocation += `&uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; } } else if (this.is_shared) { - fullLocation += `?uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; + fullLocation += (this.subscriptionName ? '&' : '?') + `uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; } // if it has a slash (meaning it's in a directory), only get the file name for the label let label = null; diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 98c16ba..8fb2a2f 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -74,6 +74,8 @@ export class PostsService implements CanActivate { }; this.jwtAuth(); } + } else { + this.config_reloaded.next(true); } } }); @@ -107,7 +109,7 @@ export class PostsService implements CanActivate { const result = !this.debugMode ? res['config_file'] : res; if (result) { this.config = result['YoutubeDLMaterial']; - this.config_reloaded = true; + this.config_reloaded.next(true); } }); } @@ -348,6 +350,7 @@ export class PostsService implements CanActivate { call.subscribe(res => { if (res['token']) { this.afterLogin(res['user'], res['token']); + this.config_reloaded.next(true); } }, err => { if (err.status === 401) { diff --git a/src/app/subscription/subscription/subscription.component.ts b/src/app/subscription/subscription/subscription.component.ts index fce7761..cd7037c 100644 --- a/src/app/subscription/subscription/subscription.component.ts +++ b/src/app/subscription/subscription/subscription.component.ts @@ -49,10 +49,10 @@ export class SubscriptionComponent implements OnInit { if (this.route.snapshot.paramMap.get('id')) { this.id = this.route.snapshot.paramMap.get('id'); - this.getSubscription(); this.postsService.config_reloaded.subscribe(changed => { if (changed) { this.getConfig(); + this.getSubscription(); } }); } @@ -93,7 +93,7 @@ export class SubscriptionComponent implements OnInit { this.router.navigate(['/player', {name: name, url: url}]); } else { this.router.navigate(['/player', {fileNames: name, type: 'subscription', subscriptionName: this.subscription.name, - subPlaylist: this.subscription.isPlaylist}]); + subPlaylist: this.subscription.isPlaylist, uuid: this.postsService.user ? this.postsService.user.uid : null}]); } } diff --git a/src/app/subscriptions/subscriptions.component.ts b/src/app/subscriptions/subscriptions.component.ts index 9776047..01ab0ea 100644 --- a/src/app/subscriptions/subscriptions.component.ts +++ b/src/app/subscriptions/subscriptions.component.ts @@ -22,16 +22,23 @@ export class SubscriptionsComponent implements OnInit { constructor(private dialog: MatDialog, public postsService: PostsService, private router: Router, private snackBar: MatSnackBar) { } ngOnInit() { - this.getSubscriptions(); + if (this.postsService.config) { + this.getSubscriptions(); + } + this.postsService.config_reloaded.subscribe(changed => { + if (changed) { + this.getSubscriptions(); + } + }); } getSubscriptions() { this.subscriptions_loading = true; this.subscriptions = null; - this.channel_subscriptions = []; - this.playlist_subscriptions = []; this.postsService.getAllSubscriptions().subscribe(res => { - this.subscriptions_loading = false; + this.channel_subscriptions = []; + this.playlist_subscriptions = []; + this.subscriptions_loading = false; this.subscriptions = res['subscriptions']; if (!this.subscriptions) { // set it to an empty array so it can notify the user there are no subscriptions From 81b0ef4a72b2b299e41a1e00d869ab3e328207d6 Mon Sep 17 00:00:00 2001 From: Tzahi12345 Date: Thu, 30 Apr 2020 13:28:58 -0400 Subject: [PATCH 17/20] Refactored initialization process to better facilitate auth if necessary Date in user profile dialog now shows date --- .../user-profile-dialog.component.html | 2 +- src/app/main/main.component.ts | 8 +++----- src/app/player/player.component.ts | 6 +++--- src/app/posts.services.ts | 15 +++++++++++++-- .../subscription/subscription.component.ts | 4 ++-- src/app/subscriptions/subscriptions.component.ts | 6 +++--- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html index fd9be71..c3bb84f 100644 --- a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html @@ -9,7 +9,7 @@ UID: {{postsService.user.uid}}
- Created: {{postsService.user.created ? postsService.user.created : 'N/A'}} + Created: {{postsService.user.created ? (postsService.user.created | date) : 'N/A'}}
diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 0645c9b..36bd9f2 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -196,7 +196,6 @@ export class MainComponent implements OnInit { selectedQuality = ''; formats_loading = false; - config_loaded = false; @ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef; @ViewChildren('audiofilecard') audioFileCards: QueryList; @@ -295,12 +294,11 @@ export class MainComponent implements OnInit { // app initialization. ngOnInit() { - if (this.postsService.config) { + if (this.postsService.initialized) { this.configLoad(); } else { - this.postsService.config_reloaded.subscribe(changed => { - if (changed && !this.config_loaded) { - this.config_loaded = true; + this.postsService.service_initialized.subscribe(init => { + if (init) { this.configLoad(); } }); diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 0377063..84bba1c 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -79,11 +79,11 @@ export class PlayerComponent implements OnInit { this.uuid = this.route.snapshot.paramMap.get('uuid'); // loading config - if (this.postsService.config) { + if (this.postsService.initialized) { this.processConfig(); } else { - this.postsService.config_reloaded.subscribe(changed => { // loads settings - if (changed) { + this.postsService.service_initialized.subscribe(init => { // loads settings + if (init) { this.processConfig(); } }); diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 8fb2a2f..ea47db8 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -36,6 +36,8 @@ export class PostsService implements CanActivate { reload_config = new BehaviorSubject(false); config_reloaded = new BehaviorSubject(false); + service_initialized = new BehaviorSubject(false); + initialized = false; config = null; constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, @@ -75,7 +77,7 @@ export class PostsService implements CanActivate { this.jwtAuth(); } } else { - this.config_reloaded.next(true); + this.setInitialized(); } } }); @@ -328,6 +330,9 @@ export class PostsService implements CanActivate { }), }; + // needed to re-initialize parts of app after login + this.config_reloaded.next(true); + if (this.router.url === '/login') { this.router.navigate(['/home']); } @@ -350,7 +355,7 @@ export class PostsService implements CanActivate { call.subscribe(res => { if (res['token']) { this.afterLogin(res['user'], res['token']); - this.config_reloaded.next(true); + this.setInitialized(); } }, err => { if (err.status === 401) { @@ -403,6 +408,12 @@ export class PostsService implements CanActivate { this.openSnackBar('You must log in to access this page!'); } + setInitialized() { + this.service_initialized.next(true); + this.initialized = true; + this.config_reloaded.next(true); + } + public openSnackBar(message: string, action: string = '') { this.snackBar.open(message, action, { duration: 2000, diff --git a/src/app/subscription/subscription/subscription.component.ts b/src/app/subscription/subscription/subscription.component.ts index cd7037c..2226ba6 100644 --- a/src/app/subscription/subscription/subscription.component.ts +++ b/src/app/subscription/subscription/subscription.component.ts @@ -49,8 +49,8 @@ export class SubscriptionComponent implements OnInit { if (this.route.snapshot.paramMap.get('id')) { this.id = this.route.snapshot.paramMap.get('id'); - this.postsService.config_reloaded.subscribe(changed => { - if (changed) { + this.postsService.service_initialized.subscribe(init => { + if (init) { this.getConfig(); this.getSubscription(); } diff --git a/src/app/subscriptions/subscriptions.component.ts b/src/app/subscriptions/subscriptions.component.ts index 01ab0ea..1723bfd 100644 --- a/src/app/subscriptions/subscriptions.component.ts +++ b/src/app/subscriptions/subscriptions.component.ts @@ -22,11 +22,11 @@ export class SubscriptionsComponent implements OnInit { constructor(private dialog: MatDialog, public postsService: PostsService, private router: Router, private snackBar: MatSnackBar) { } ngOnInit() { - if (this.postsService.config) { + if (this.postsService.initialized) { this.getSubscriptions(); } - this.postsService.config_reloaded.subscribe(changed => { - if (changed) { + this.postsService.service_initialized.subscribe(init => { + if (init) { this.getSubscriptions(); } }); From e5f9694da04cde44faa9361d9a35a4af15b61f5c Mon Sep 17 00:00:00 2001 From: Tzahi12345 Date: Thu, 30 Apr 2020 13:29:47 -0400 Subject: [PATCH 18/20] Fixed bug where downloading individual files failed for channel subscriptions --- backend/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app.js b/backend/app.js index aaa4550..803b674 100644 --- a/backend/app.js +++ b/backend/app.js @@ -2407,7 +2407,7 @@ app.post('/api/downloadFile', optionalJwt, async (req, res) => { basePath = path.join(usersFileFolder, req.user.uid, 'subscriptions'); else basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); - file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') + file = path.join(__dirname, basePath, (subscriptionPlaylist === 'true' ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') } } else { for (let i = 0; i < fileNames.length; i++) { From e7b841c05608eb14fda158407e488c2cf5d71320 Mon Sep 17 00:00:00 2001 From: Tzahi12345 Date: Thu, 30 Apr 2020 16:31:36 -0400 Subject: [PATCH 19/20] Added UI flow for creating default admin account. Dialog will show up after enabling or in the login menu if the admin account isn't present --- backend/app.js | 10 ++++++ backend/authentication/auth.js | 20 ++++++++++- src/app/app.component.ts | 13 ++++++++ src/app/app.module.ts | 4 ++- src/app/components/login/login.component.ts | 7 ++++ .../set-default-admin-dialog.component.html | 19 +++++++++++ .../set-default-admin-dialog.component.scss | 5 +++ ...set-default-admin-dialog.component.spec.ts | 25 ++++++++++++++ .../set-default-admin-dialog.component.ts | 33 +++++++++++++++++++ src/app/posts.services.ts | 25 ++++++++++++++ src/app/settings/settings.component.ts | 4 +++ 11 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html create mode 100644 src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss create mode 100644 src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts create mode 100644 src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts diff --git a/backend/app.js b/backend/app.js index 803b674..0b69026 100644 --- a/backend/app.js +++ b/backend/app.js @@ -2735,6 +2735,16 @@ app.post('/api/auth/jwtAuth' , auth_api.generateJWT , auth_api.returnAuthResponse ); +app.post('/api/auth/changePassword', optionalJwt, async (req, res) => { + let user_uid = req.user.uid; + let password = req.body.password; + let success = await auth_api.changeUserPassword(user_uid, password); + res.send({success: success}); +}); +app.post('/api/auth/adminExists', async (req, res) => { + let exists = auth_api.adminExists(); + res.send({exists: exists}); +}); app.use(function(req, res, next) { //if the request is not html then move along diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 5b5d0a6..b8930a8 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -96,7 +96,8 @@ exports.registerUser = function(req, res) { video: [] }, subscriptions: [], - created: Date.now() + created: Date.now(), + role: userid === 'admin' ? 'admin' : 'user' }; // check if user exists if (users_db.get('users').find({uid: userid}).value()) { @@ -238,6 +239,23 @@ exports.ensureAuthenticatedElseError = function(req, res, next) { } } +// change password +exports.changeUserPassword = async function(user_uid, new_pass) { + return new Promise(resolve => { + bcrypt.hash(new_pass, saltRounds) + .then(function(hash) { + users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write(); + resolve(true); + }).catch(err => { + resolve(false); + }); + }); +} + +exports.adminExists = function() { + return !!users_db.get('users').find({uid: 'admin'}).value(); +} + // video stuff exports.getUserVideos = function(user_uid, type) { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 0bf3165..3fa9207 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -24,6 +24,7 @@ import { SettingsComponent } from './settings/settings.component'; import { CheckOrSetPinDialogComponent } from './dialogs/check-or-set-pin-dialog/check-or-set-pin-dialog.component'; import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component'; import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component'; +import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component'; @Component({ selector: 'app-root', @@ -148,6 +149,18 @@ onSetTheme(theme, old_theme) { } else { // } + this.postsService.open_create_default_admin_dialog.subscribe(open => { + if (open) { + const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent); + dialogRef.afterClosed().subscribe(success => { + if (success) { + if (this.router.url !== '/login') { this.router.navigate(['/login']); } + } else { + console.error('Failed to create default admin account. See logs for details.'); + } + }); + } + }); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0334c6a..4d4a46d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -60,6 +60,7 @@ import { ShareMediaDialogComponent } from './dialogs/share-media-dialog/share-me import { LoginComponent } from './components/login/login.component'; import { DownloadsComponent } from './components/downloads/downloads.component'; import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component'; +import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component'; registerLocaleData(es, 'es'); export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps) { @@ -91,7 +92,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible ShareMediaDialogComponent, LoginComponent, DownloadsComponent, - UserProfileDialogComponent + UserProfileDialogComponent, + SetDefaultAdminDialogComponent ], imports: [ CommonModule, diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts index 0bb8c04..a592302 100644 --- a/src/app/components/login/login.component.ts +++ b/src/app/components/login/login.component.ts @@ -30,6 +30,13 @@ export class LoginComponent implements OnInit { if (this.postsService.isLoggedIn) { this.router.navigate(['/home']); } + this.postsService.service_initialized.subscribe(init => { + if (init) { + if (!this.postsService.config['Advanced']['multi_user_mode']) { + this.router.navigate(['/home']); + } + } + }); } login() { diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html new file mode 100644 index 0000000..1d800b9 --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html @@ -0,0 +1,19 @@ +

Create admin account

+ + +
+

No default admin account detected. This will create and set the password for an admin account with the user name as 'admin'.

+
+
+
+ + + +
+
+
+ + + +
+
\ No newline at end of file diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss new file mode 100644 index 0000000..efe2b9e --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss @@ -0,0 +1,5 @@ +.spinner-div { + position: relative; + left: 10px; + bottom: 5px; +} \ No newline at end of file diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts new file mode 100644 index 0000000..cc37170 --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SetDefaultAdminDialogComponent } from './set-default-admin-dialog.component'; + +describe('SetDefaultAdminDialogComponent', () => { + let component: SetDefaultAdminDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SetDefaultAdminDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SetDefaultAdminDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts new file mode 100644 index 0000000..eec524a --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-set-default-admin-dialog', + templateUrl: './set-default-admin-dialog.component.html', + styleUrls: ['./set-default-admin-dialog.component.scss'] +}) +export class SetDefaultAdminDialogComponent implements OnInit { + creating = false; + input = ''; + constructor(private postsService: PostsService, public dialogRef: MatDialogRef) { } + + ngOnInit(): void { + } + + create() { + this.creating = true; + this.postsService.createAdminAccount(this.input).subscribe(res => { + this.creating = false; + if (res['success']) { + this.dialogRef.close(true); + } else { + this.dialogRef.close(false); + } + }, err => { + console.log(err); + this.dialogRef.close(false); + }); + } + +} diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index ea47db8..42c6168 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -39,6 +39,8 @@ export class PostsService implements CanActivate { service_initialized = new BehaviorSubject(false); initialized = false; + open_create_default_admin_dialog = new BehaviorSubject(false); + config = null; constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, public snackBar: MatSnackBar) { @@ -75,6 +77,8 @@ export class PostsService implements CanActivate { }), }; this.jwtAuth(); + } else { + this.sendToLogin(); } } else { this.setInitialized(); @@ -398,6 +402,7 @@ export class PostsService implements CanActivate { } sendToLogin() { + this.checkAdminCreationStatus(); if (this.router.url === '/login') { return; } @@ -414,6 +419,26 @@ export class PostsService implements CanActivate { this.config_reloaded.next(true); } + adminExists() { + return this.http.post(this.path + 'auth/adminExists', {}, this.httpOptions); + } + + createAdminAccount(password) { + return this.http.post(this.path + 'auth/register', {userid: 'admin', + username: 'admin', + password: password}, this.httpOptions); + } + + checkAdminCreationStatus() { + console.log('checking c stat'); + this.adminExists().subscribe(res => { + if (!res['exists']) { + // must create admin account + this.open_create_default_admin_dialog.next(true); + } + }); + } + public openSnackBar(message: string, action: string = '') { this.snackBar.open(message, action, { duration: 2000, diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index b8fc2b9..c3021a3 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -63,6 +63,10 @@ export class SettingsComponent implements OnInit { const settingsToSave = {'YoutubeDLMaterial': this.new_config}; this.postsService.setConfig(settingsToSave).subscribe(res => { if (res['success']) { + if (!this.initial_config['Advanced']['multi_user_mode'] && this.new_config['Advanced']['multi_user_mode']) { + // multi user mode was enabled, let's check if default admin account exists + this.postsService.checkAdminCreationStatus(); + } // sets new config as old config this.initial_config = JSON.parse(JSON.stringify(this.new_config)); this.postsService.reload_config.next(true); From b685b955df96db7a6e68697b95b8743d09171da5 Mon Sep 17 00:00:00 2001 From: Tzahi12345 Date: Fri, 1 May 2020 03:34:35 -0400 Subject: [PATCH 20/20] Added roles and permissions system, as well as the ability to modify users and their roles Downloads manager now uses device fingerprint as identifier rather than a randomly generated sessionID --- backend/app.js | 97 +++++++- backend/authentication/auth.js | 114 ++++++++- backend/consts.js | 10 + src/app/app.component.html | 6 +- src/app/app.module.ts | 16 +- src/app/components/login/login.component.html | 2 +- src/app/components/login/login.component.ts | 3 + .../manage-role/manage-role.component.html | 19 ++ .../manage-role/manage-role.component.scss | 4 + .../manage-role/manage-role.component.spec.ts | 25 ++ .../manage-role/manage-role.component.ts | 61 +++++ .../manage-user/manage-user.component.html | 31 +++ .../manage-user/manage-user.component.scss | 4 + .../manage-user/manage-user.component.spec.ts | 25 ++ .../manage-user/manage-user.component.ts | 69 ++++++ .../modify-users/modify-users.component.html | 107 +++++++++ .../modify-users/modify-users.component.scss | 5 + .../modify-users.component.spec.ts | 25 ++ .../modify-users/modify-users.component.ts | 219 ++++++++++++++++++ .../add-user-dialog.component.html | 19 ++ .../add-user-dialog.component.scss | 0 .../add-user-dialog.component.spec.ts | 25 ++ .../add-user-dialog.component.ts | 32 +++ .../set-default-admin-dialog.component.html | 2 +- src/app/main/main.component.html | 2 +- src/app/main/main.component.ts | 6 +- src/app/player/player.component.html | 4 +- src/app/player/player.component.ts | 2 +- src/app/posts.services.ts | 71 ++++-- src/app/settings/settings.component.html | 3 + src/app/settings/settings.component.ts | 2 +- 31 files changed, 974 insertions(+), 36 deletions(-) create mode 100644 src/app/components/manage-role/manage-role.component.html create mode 100644 src/app/components/manage-role/manage-role.component.scss create mode 100644 src/app/components/manage-role/manage-role.component.spec.ts create mode 100644 src/app/components/manage-role/manage-role.component.ts create mode 100644 src/app/components/manage-user/manage-user.component.html create mode 100644 src/app/components/manage-user/manage-user.component.scss create mode 100644 src/app/components/manage-user/manage-user.component.spec.ts create mode 100644 src/app/components/manage-user/manage-user.component.ts create mode 100644 src/app/components/modify-users/modify-users.component.html create mode 100644 src/app/components/modify-users/modify-users.component.scss create mode 100644 src/app/components/modify-users/modify-users.component.spec.ts create mode 100644 src/app/components/modify-users/modify-users.component.ts create mode 100644 src/app/dialogs/add-user-dialog/add-user-dialog.component.html create mode 100644 src/app/dialogs/add-user-dialog/add-user-dialog.component.scss create mode 100644 src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts create mode 100644 src/app/dialogs/add-user-dialog/add-user-dialog.component.ts diff --git a/backend/app.js b/backend/app.js index 0b69026..1376471 100644 --- a/backend/app.js +++ b/backend/app.js @@ -91,7 +91,25 @@ db.defaults( users_db.defaults( { - users: [] + users: [], + roles: { + "admin": { + "permissions": [ + 'filemanager', + 'settings', + 'subscriptions', + 'sharing', + 'advanced_download', + 'downloads_manager' + ] + }, "user": { + "permissions": [ + 'filemanager', + 'subscriptions', + 'sharing' + ] + } + } } ).write(); @@ -2737,7 +2755,7 @@ app.post('/api/auth/jwtAuth' ); app.post('/api/auth/changePassword', optionalJwt, async (req, res) => { let user_uid = req.user.uid; - let password = req.body.password; + let password = req.body.new_password; let success = await auth_api.changeUserPassword(user_uid, password); res.send({success: success}); }); @@ -2746,6 +2764,81 @@ app.post('/api/auth/adminExists', async (req, res) => { res.send({exists: exists}); }); +// user management +app.post('/api/getUsers', optionalJwt, async (req, res) => { + let users = users_db.get('users').value(); + res.send({users: users}); +}); +app.post('/api/getRoles', optionalJwt, async (req, res) => { + let roles = users_db.get('roles').value(); + res.send({roles: roles}); +}); + +app.post('/api/changeUser', optionalJwt, async (req, res) => { + let change_obj = req.body.change_object; + try { + const user_db_obj = users_db.get('users').find({uid: change_obj.uid}); + if (change_obj.name) { + user_db_obj.assign({name: change_obj.name}).write(); + } + if (change_obj.role) { + user_db_obj.assign({role: change_obj.role}).write(); + } + res.send({success: true}); + } catch (err) { + logger.error(err); + res.send({success: false}); + } +}); + +app.post('/api/deleteUser', optionalJwt, async (req, res) => { + let uid = req.body.uid; + try { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const user_folder = path.join(__dirname, usersFileFolder, uid); + const user_db_obj = users_db.get('users').find({uid: uid}); + if (user_db_obj.value()) { + // user exists, let's delete + deleteFolderRecursive(user_folder); + users_db.get('users').remove({uid: uid}).write(); + } + res.send({success: true}); + } catch (err) { + logger.error(err); + res.send({success: false}); + } +}); + +app.post('/api/changeUserPermissions', optionalJwt, async (req, res) => { + const user_uid = req.body.user_uid; + const permission = req.body.permission; + const new_value = req.body.new_value; + + if (!permission || !new_value) { + res.sendStatus(400); + return; + } + + const success = auth_api.changeUserPermissions(user_uid, permission, new_value); + + res.send({success: success}); +}); + +app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => { + const role = req.body.role; + const permission = req.body.permission; + const new_value = req.body.new_value; + + if (!permission || !new_value) { + res.sendStatus(400); + return; + } + + const success = auth_api.changeRolePermissions(role, permission, new_value); + + res.send({success: success}); +}); + app.use(function(req, res, next) { //if the request is not html then move along var accept = req.accepts('html', 'json', 'xml'); diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index b8930a8..7ca09fa 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -1,5 +1,6 @@ const path = require('path'); const config_api = require('../config'); +const consts = require('../consts'); var subscriptions_api = require('../subscriptions') const fs = require('fs-extra'); var jwt = require('jsonwebtoken'); @@ -97,7 +98,9 @@ exports.registerUser = function(req, res) { }, subscriptions: [], created: Date.now(), - role: userid === 'admin' ? 'admin' : 'user' + role: userid === 'admin' ? 'admin' : 'user', + permissions: [], + permission_overrides: [] }; // check if user exists if (users_db.get('users').find({uid: userid}).value()) { @@ -200,8 +203,7 @@ exports.authenticateViaPassport = function(req, res, next) { exports.generateJWT = function(req, res, next) { var payload = { exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION - , user: req.user, -// , role: role + , user: req.user }; req.token = jwt.sign(payload, SERVER_SECRET); next(); @@ -210,7 +212,9 @@ exports.generateJWT = function(req, res, next) { exports.returnAuthResponse = function(req, res) { res.status(200).json({ user: req.user, - token: req.token + token: req.token, + permissions: exports.userPermissions(req.user.uid), + available_permissions: consts['AVAILABLE_PERMISSIONS'] }); } @@ -252,6 +256,40 @@ exports.changeUserPassword = async function(user_uid, new_pass) { }); } +// change user permissions +exports.changeUserPermissions = function(user_uid, permission, new_value) { + try { + const user_db_obj = users_db.get('users').find({uid: user_uid}); + user_db_obj.get('permissions').pull(permission).write(); + user_db_obj.get('permission_overrides').pull(permission).write(); + if (new_value === 'yes') { + user_db_obj.get('permissions').push(permission).write(); + user_db_obj.get('permission_overrides').push(permission).write(); + } else if (new_value === 'no') { + user_db_obj.get('permission_overrides').push(permission).write(); + } + return true; + } catch (err) { + logger.error(err); + return false; + } +} + +// change role permissions +exports.changeRolePermissions = function(role, permission, new_value) { + try { + const role_db_obj = users_db.get('roles').get(role); + role_db_obj.get('permissions').pull(permission).write(); + if (new_value === 'yes') { + role_db_obj.get('permissions').push(permission).write(); + } + return true; + } catch (err) { + logger.error(err); + return false; + } +} + exports.adminExists = function() { return !!users_db.get('users').find({uid: 'admin'}).value(); } @@ -410,6 +448,74 @@ exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enab return success; } +exports.userHasPermission = function(user_uid, permission) { + const user_obj = users_db.get('users').find({uid: user_uid}).value(); + const role = user_obj['role']; + if (!role) { + // role doesn't exist + logger.error('Invalid role ' + role); + return false; + } + const role_permissions = (users_db.get('roles').value())['permissions']; + + const user_has_explicit_permission = user_obj['permissions'].includes(permission); + const permission_in_overrides = user_obj['permission_overrides'].includes(permission); + + // check if user has a negative/positive override + if (user_has_explicit_permission && permission_in_overrides) { + // positive override + return true; + } else if (!user_has_explicit_permission && permission_in_overrides) { + // negative override + return false; + } + + // no overrides, let's check if the role has the permission + if (role_permissions.includes(permission)) { + return true; + } else { + logger.verbose(`User ${user_uid} failed to get permission ${permission}`); + return false; + } +} + +exports.userPermissions = function(user_uid) { + let user_permissions = []; + const user_obj = users_db.get('users').find({uid: user_uid}).value(); + const role = user_obj['role']; + if (!role) { + // role doesn't exist + logger.error('Invalid role ' + role); + return null; + } + const role_permissions = users_db.get('roles').get(role).get('permissions').value() + + for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) { + let permission = consts['AVAILABLE_PERMISSIONS'][i]; + + const user_has_explicit_permission = user_obj['permissions'].includes(permission); + const permission_in_overrides = user_obj['permission_overrides'].includes(permission); + + // check if user has a negative/positive override + if (user_has_explicit_permission && permission_in_overrides) { + // positive override + user_permissions.push(permission); + } else if (!user_has_explicit_permission && permission_in_overrides) { + // negative override + continue; + } + + // no overrides, let's check if the role has the permission + if (role_permissions.includes(permission)) { + user_permissions.push(permission); + } else { + continue; + } + } + + return user_permissions; +} + function getToken(queryParams) { if (queryParams && queryParams.jwt) { var parted = queryParams.jwt.split(' '); diff --git a/backend/consts.js b/backend/consts.js index f5b62ad..cb8dc0a 100644 --- a/backend/consts.js +++ b/backend/consts.js @@ -142,7 +142,17 @@ let CONFIG_ITEMS = { }, }; +AVAILABLE_PERMISSIONS = [ + 'filemanager', + 'settings', + 'subscriptions', + 'sharing', + 'advanced_download', + 'downloads_manager' +]; + module.exports = { CONFIG_ITEMS: CONFIG_ITEMS, + AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS, CURRENT_VERSION: 'v3.6' } \ No newline at end of file diff --git a/src/app/app.component.html b/src/app/app.component.html index 5de160c..8f58860 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -23,7 +23,7 @@ Dark - @@ -41,8 +41,8 @@ Home - Subscriptions - Downloads + Subscriptions + Downloads diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4d4a46d..96b91b0 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -25,6 +25,9 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTabsModule } from '@angular/material/tabs'; +import {MatPaginatorModule} from '@angular/material/paginator'; +import {MatSortModule} from '@angular/material/sort'; +import {MatTableModule} from '@angular/material/table'; import {DragDropModule} from '@angular/cdk/drag-drop'; import {ClipboardModule} from '@angular/cdk/clipboard'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; @@ -61,6 +64,10 @@ import { LoginComponent } from './components/login/login.component'; import { DownloadsComponent } from './components/downloads/downloads.component'; import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component'; import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component'; +import { ModifyUsersComponent } from './components/modify-users/modify-users.component'; +import { AddUserDialogComponent } from './dialogs/add-user-dialog/add-user-dialog.component'; +import { ManageUserComponent } from './components/manage-user/manage-user.component'; +import { ManageRoleComponent } from './components/manage-role/manage-role.component'; registerLocaleData(es, 'es'); export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps) { @@ -93,7 +100,11 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible LoginComponent, DownloadsComponent, UserProfileDialogComponent, - SetDefaultAdminDialogComponent + SetDefaultAdminDialogComponent, + ModifyUsersComponent, + AddUserDialogComponent, + ManageUserComponent, + ManageRoleComponent ], imports: [ CommonModule, @@ -127,6 +138,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible MatAutocompleteModule, MatTabsModule, MatTooltipModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, DragDropModule, ClipboardModule, VgCoreModule, diff --git a/src/app/components/login/login.component.html b/src/app/components/login/login.component.html index 3833599..7bc4ac7 100644 --- a/src/app/components/login/login.component.html +++ b/src/app/components/login/login.component.html @@ -8,7 +8,7 @@
- +
diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts index a592302..0665ee0 100644 --- a/src/app/components/login/login.component.ts +++ b/src/app/components/login/login.component.ts @@ -40,6 +40,9 @@ export class LoginComponent implements OnInit { } login() { + if (this.loginPasswordInput === '') { + return; + } this.loggingIn = true; this.postsService.login(this.loginUsernameInput, this.loginPasswordInput).subscribe(res => { this.loggingIn = false; diff --git a/src/app/components/manage-role/manage-role.component.html b/src/app/components/manage-role/manage-role.component.html new file mode 100644 index 0000000..b3e8de9 --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.html @@ -0,0 +1,19 @@ +

Manage role - {{role.name}}

+ + + + +

{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}

+ + + Yes + No + + +
+
+
+ + + + \ No newline at end of file diff --git a/src/app/components/manage-role/manage-role.component.scss b/src/app/components/manage-role/manage-role.component.scss new file mode 100644 index 0000000..167abd6 --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.scss @@ -0,0 +1,4 @@ +.mat-radio-button { + margin-right: 10px; + margin-top: 5px; +} \ No newline at end of file diff --git a/src/app/components/manage-role/manage-role.component.spec.ts b/src/app/components/manage-role/manage-role.component.spec.ts new file mode 100644 index 0000000..2e9579e --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ManageRoleComponent } from './manage-role.component'; + +describe('ManageRoleComponent', () => { + let component: ManageRoleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ManageRoleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ManageRoleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/manage-role/manage-role.component.ts b/src/app/components/manage-role/manage-role.component.ts new file mode 100644 index 0000000..4e05e29 --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-manage-role', + templateUrl: './manage-role.component.html', + styleUrls: ['./manage-role.component.scss'] +}) +export class ManageRoleComponent implements OnInit { + + role = null; + available_permissions = null; + permissions = null; + + permissionToLabel = { + 'filemanager': 'File manager', + 'settings': 'Settings access', + 'subscriptions': 'Subscriptions', + 'sharing': 'Share files', + 'advanced_download': 'Use advanced download mode', + 'downloads_manager': 'Use downloads manager' + } + + constructor(public postsService: PostsService, private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any) { + if (this.data) { + this.role = this.data.role; + this.available_permissions = this.postsService.available_permissions; + this.parsePermissions(); + } + } + + ngOnInit(): void { + } + + parsePermissions() { + this.permissions = {}; + for (let i = 0; i < this.available_permissions.length; i++) { + const permission = this.available_permissions[i]; + if (this.role.permissions.includes(permission)) { + this.permissions[permission] = 'yes'; + } else { + this.permissions[permission] = 'no'; + } + } + } + + changeRolePermissions(change, permission) { + this.postsService.setRolePermission(this.role.name, permission, change.value).subscribe(res => { + if (res['success']) { + + } else { + this.permissions[permission] = this.permissions[permission] === 'yes' ? 'no' : 'yes'; + } + }, err => { + this.permissions[permission] = this.permissions[permission] === 'yes' ? 'no' : 'yes'; + }); + } + +} diff --git a/src/app/components/manage-user/manage-user.component.html b/src/app/components/manage-user/manage-user.component.html new file mode 100644 index 0000000..853cd72 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.html @@ -0,0 +1,31 @@ +

Manage user - {{user.name}}

+ + +

User UID: {{user.uid}}

+ +
+ + + + +
+ +
+ + +

{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}

+ + + Use default + Yes + No + + +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/app/components/manage-user/manage-user.component.scss b/src/app/components/manage-user/manage-user.component.scss new file mode 100644 index 0000000..167abd6 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.scss @@ -0,0 +1,4 @@ +.mat-radio-button { + margin-right: 10px; + margin-top: 5px; +} \ No newline at end of file diff --git a/src/app/components/manage-user/manage-user.component.spec.ts b/src/app/components/manage-user/manage-user.component.spec.ts new file mode 100644 index 0000000..f8fe3a7 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ManageUserComponent } from './manage-user.component'; + +describe('ManageUserComponent', () => { + let component: ManageUserComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ManageUserComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ManageUserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/manage-user/manage-user.component.ts b/src/app/components/manage-user/manage-user.component.ts new file mode 100644 index 0000000..61b38f4 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; + +@Component({ + selector: 'app-manage-user', + templateUrl: './manage-user.component.html', + styleUrls: ['./manage-user.component.scss'] +}) +export class ManageUserComponent implements OnInit { + + user = null; + newPasswordInput = ''; + available_permissions = null; + permissions = null; + + permissionToLabel = { + 'filemanager': 'File manager', + 'settings': 'Settings access', + 'subscriptions': 'Subscriptions', + 'sharing': 'Share files', + 'advanced_download': 'Use advanced download mode', + 'downloads_manager': 'Use downloads manager' + } + + settingNewPassword = false; + + constructor(public postsService: PostsService, @Inject(MAT_DIALOG_DATA) public data: any) { + if (this.data) { + this.user = this.data.user; + this.available_permissions = this.postsService.available_permissions; + this.parsePermissions(); + } + } + + ngOnInit(): void { + } + + parsePermissions() { + this.permissions = {}; + for (let i = 0; i < this.available_permissions.length; i++) { + const permission = this.available_permissions[i]; + if (this.user.permission_overrides.includes(permission)) { + if (this.user.permissions.includes(permission)) { + this.permissions[permission] = 'yes'; + } else { + this.permissions[permission] = 'no'; + } + } else { + this.permissions[permission] = 'default'; + } + } + } + + changeUserPermissions(change, permission) { + this.postsService.setUserPermission(this.user.uid, permission, change.value).subscribe(res => { + // console.log(res); + }); + } + + setNewPassword() { + this.settingNewPassword = true; + this.postsService.changeUserPassword(this.user.uid, this.newPasswordInput).subscribe(res => { + this.newPasswordInput = ''; + this.settingNewPassword = false; + }); + } + +} diff --git a/src/app/components/modify-users/modify-users.component.html b/src/app/components/modify-users/modify-users.component.html new file mode 100644 index 0000000..036ad2b --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.html @@ -0,0 +1,107 @@ +
+
+
+
+
+ + + +
+ +
+ + + + + + User name + + + + + + + + + + + {{row.name}} + + + + + + + Role + + + + + + Admin + User + + + + + + {{row.role}} + + + + + + + Actions + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + +
+ +
+ +
+ + + +
\ No newline at end of file diff --git a/src/app/components/modify-users/modify-users.component.scss b/src/app/components/modify-users/modify-users.component.scss new file mode 100644 index 0000000..558267e --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.scss @@ -0,0 +1,5 @@ +.edit-role { + position: relative; + top: -80px; + left: 35px; +} \ No newline at end of file diff --git a/src/app/components/modify-users/modify-users.component.spec.ts b/src/app/components/modify-users/modify-users.component.spec.ts new file mode 100644 index 0000000..e5e8ef8 --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModifyUsersComponent } from './modify-users.component'; + +describe('ModifyUsersComponent', () => { + let component: ModifyUsersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ModifyUsersComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ModifyUsersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/modify-users/modify-users.component.ts b/src/app/components/modify-users/modify-users.component.ts new file mode 100644 index 0000000..9e54fda --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.ts @@ -0,0 +1,219 @@ +import { Component, OnInit, Input, ViewChild, AfterViewInit } from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { PostsService } from 'app/posts.services'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { AddUserDialogComponent } from 'app/dialogs/add-user-dialog/add-user-dialog.component'; +import { ManageUserComponent } from '../manage-user/manage-user.component'; +import { ManageRoleComponent } from '../manage-role/manage-role.component'; + +@Component({ + selector: 'app-modify-users', + templateUrl: './modify-users.component.html', + styleUrls: ['./modify-users.component.scss'] +}) +export class ModifyUsersComponent implements OnInit, AfterViewInit { + + displayedColumns = ['name', 'role', 'actions']; + dataSource = new MatTableDataSource(); + + deleteDialogContentSubstring = 'Are you sure you want delete user '; + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + // MatPaginator Inputs + length = 100; + @Input() pageSize = 5; + pageSizeOptions: number[] = [5, 10, 25, 100]; + + // MatPaginator Output + pageEvent: PageEvent; + users: any; + editObject = null; + constructedObject = {}; + roles = null; + + + constructor(public postsService: PostsService, public snackBar: MatSnackBar, public dialog: MatDialog, + private dialogRef: MatDialogRef) { } + + ngOnInit() { + this.getArray(); + this.getRoles(); + } + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + /** + * Set the paginator and sort after the view init since this component will + * be able to query its view for the initialized paginator and sort. + */ + afterGetData() { + this.dataSource.sort = this.sort; + } + + setPageSizeOptions(setPageSizeOptionsInput: string) { + this.pageSizeOptions = setPageSizeOptionsInput.split(',').map(str => +str); + } + + applyFilter(filterValue: string) { + filterValue = filterValue.trim(); // Remove whitespace + filterValue = filterValue.toLowerCase(); // Datasource defaults to lowercase matches + this.dataSource.filter = filterValue; + } + + private getArray() { + this.postsService.getUsers().subscribe(res => { + this.users = res['users']; + this.createAndSortData(); + this.afterGetData(); + }); + } + + getRoles() { + this.postsService.getRoles().subscribe(res => { + this.roles = []; + const roles = res['roles']; + const role_names = Object.keys(roles); + for (let i = 0; i < role_names.length; i++) { + const role_name = role_names[i]; + this.roles.push({ + name: role_name, + permissions: roles[role_name]['permissions'] + }); + } + }); + } + + openAddUserDialog() { + const dialogRef = this.dialog.open(AddUserDialogComponent); + dialogRef.afterClosed().subscribe(user => { + if (user && !user.error) { + this.openSnackBar('Successfully added user ' + user.name); + this.getArray(); + } else if (user && user.error) { + this.openSnackBar('Failed to add user'); + } + }); + } + + finishEditing(user_uid) { + let has_finished = false; + if (this.constructedObject && this.constructedObject['name'] && this.constructedObject['role']) { + if (!isEmptyOrSpaces(this.constructedObject['name']) && !isEmptyOrSpaces(this.constructedObject['role'])) { + has_finished = true; + const index_of_object = this.indexOfUser(user_uid); + this.users[index_of_object] = this.constructedObject; + this.constructedObject = {}; + this.editObject = null; + this.setUser(this.users[index_of_object]); + this.createAndSortData(); + } + } + } + + enableEditMode(user_uid) { + if (this.uidInUserList(user_uid) && this.indexOfUser(user_uid) > -1) { + const users_index = this.indexOfUser(user_uid); + this.editObject = this.users[users_index]; + this.constructedObject['name'] = this.users[users_index].name; + this.constructedObject['uid'] = this.users[users_index].uid; + this.constructedObject['role'] = this.users[users_index].role; + } + } + + disableEditMode() { + this.editObject = null; + } + + // checks if user is in users array by name + uidInUserList(user_uid) { + for (let i = 0; i < this.users.length; i++) { + if (this.users[i].uid === user_uid) { + return true; + } + } + return false; + } + + // gets index of user in users array by name + indexOfUser(user_uid) { + for (let i = 0; i < this.users.length; i++) { + if (this.users[i].uid === user_uid) { + return i; + } + } + return -1; + } + + setUser(change_obj) { + this.postsService.changeUser(change_obj).subscribe(res => { + this.getArray(); + }); + } + + manageUser(user_uid) { + const index_of_object = this.indexOfUser(user_uid); + const user_obj = this.users[index_of_object]; + this.dialog.open(ManageUserComponent, { + data: { + user: user_obj + }, + width: '65vw' + }); + } + + removeUser(user_uid) { + this.postsService.deleteUser(user_uid).subscribe(res => { + this.getArray(); + }, err => { + this.getArray(); + }); + } + + createAndSortData() { + // Sorts the data by last finished + this.users.sort((a, b) => b.name > a.name); + + const filteredData = []; + for (let i = 0; i < this.users.length; i++) { + filteredData.push(JSON.parse(JSON.stringify(this.users[i]))); + } + + // Assign the data to the data source for the table to render + this.dataSource.data = filteredData; + } + + openModifyRole(role) { + const dialogRef = this.dialog.open(ManageRoleComponent, { + data: { + role: role + } + }); + + dialogRef.afterClosed().subscribe(success => { + this.getRoles(); + }); + } + + closeDialog() { + this.dialogRef.close(); + } + + public openSnackBar(message: string, action: string = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + +} + +function isEmptyOrSpaces(str){ + return str === null || str.match(/^ *$/) !== null; +} diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.html b/src/app/dialogs/add-user-dialog/add-user-dialog.component.html new file mode 100644 index 0000000..68cb91f --- /dev/null +++ b/src/app/dialogs/add-user-dialog/add-user-dialog.component.html @@ -0,0 +1,19 @@ +

Register a user

+ + +
+ + + +
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.scss b/src/app/dialogs/add-user-dialog/add-user-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts b/src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts new file mode 100644 index 0000000..2eee5ca --- /dev/null +++ b/src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddUserDialogComponent } from './add-user-dialog.component'; + +describe('AddUserDialogComponent', () => { + let component: AddUserDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AddUserDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AddUserDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.ts b/src/app/dialogs/add-user-dialog/add-user-dialog.component.ts new file mode 100644 index 0000000..383103a --- /dev/null +++ b/src/app/dialogs/add-user-dialog/add-user-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-add-user-dialog', + templateUrl: './add-user-dialog.component.html', + styleUrls: ['./add-user-dialog.component.scss'] +}) +export class AddUserDialogComponent implements OnInit { + + usernameInput = ''; + passwordInput = ''; + + constructor(private postsService: PostsService, public dialogRef: MatDialogRef) { } + + ngOnInit(): void { + } + + createUser() { + this.postsService.register(this.usernameInput, this.passwordInput).subscribe(res => { + if (res['user']) { + this.dialogRef.close(res['user']); + } else { + this.dialogRef.close({error: 'Unknown error'}); + } + }, err => { + this.dialogRef.close({error: err}); + }); + } + +} diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html index 1d800b9..4cfbda1 100644 --- a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html @@ -7,7 +7,7 @@
- +
diff --git a/src/app/main/main.component.html b/src/app/main/main.component.html index 46079fa..3d41d80 100644 --- a/src/app/main/main.component.html +++ b/src/app/main/main.component.html @@ -186,7 +186,7 @@ -
+
diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 36bd9f2..aef078f 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -3,7 +3,6 @@ import {PostsService} from '../posts.services'; import {FileCardComponent} from '../file-card/file-card.component'; import { Observable } from 'rxjs/Observable'; import {FormControl, Validators} from '@angular/forms'; -import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { saveAs } from 'file-saver'; @@ -215,7 +214,7 @@ export class MainComponent implements OnInit { simulatedOutput = ''; - constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar, + constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar, private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) { this.audioOnly = false; } @@ -242,7 +241,8 @@ export class MainComponent implements OnInit { this.postsService.config['API']['youtube_API_key']; this.youtubeAPIKey = this.youtubeSearchEnabled ? this.postsService.config['API']['youtube_API_key'] : null; this.allowQualitySelect = this.postsService.config['Extra']['allow_quality_select']; - this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download']; + this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download'] + && (!this.postsService.isLoggedIn || this.postsService.permissions.includes('advanced_download')); this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent']; this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent']; diff --git a/src/app/player/player.component.html b/src/app/player/player.component.html index 6c03c3e..fce9125 100644 --- a/src/app/player/player.component.html +++ b/src/app/player/player.component.html @@ -26,10 +26,10 @@
- +
- +
\ No newline at end of file diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 84bba1c..2cad9c6 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -90,7 +90,7 @@ export class PlayerComponent implements OnInit { } } - constructor(private postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, + constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, public snackBar: MatSnackBar) { } diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 42c6168..a4bb0dc 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -10,6 +10,7 @@ import { DOCUMENT } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { v4 as uuid } from 'uuid'; import { MatSnackBar } from '@angular/material/snack-bar'; +import * as Fingerprint2 from 'fingerprintjs2'; @Injectable() export class PostsService implements CanActivate { @@ -30,9 +31,13 @@ export class PostsService implements CanActivate { debugMode = false; + // must be reset after logout isLoggedIn = false; token = null; user = null; + permissions = null; + + available_permissions = null; reload_config = new BehaviorSubject(false); config_reloaded = new BehaviorSubject(false); @@ -48,13 +53,13 @@ export class PostsService implements CanActivate { // this.startPath = window.location.href + '/api/'; // this.startPathSSL = window.location.href + '/api/'; this.path = this.document.location.origin + '/api/'; - this.session_id = uuid(); + if (isDevMode()) { this.debugMode = true; this.path = 'http://localhost:17442/api/'; } - this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}` + this.http_params = `apiKey=${this.auth_token}` this.httpOptions = { params: new HttpParams({ @@ -62,6 +67,12 @@ export class PostsService implements CanActivate { }), }; + Fingerprint2.get(components => { + // set identity as user id doesn't necessarily exist + this.session_id = Fingerprint2.x64hash128(components.map(function (pair) { return pair.value; }).join(), 31); + this.httpOptions.params = this.httpOptions.params.set('sessionID', this.session_id); + }); + // get config this.loadNavItems().subscribe(res => { const result = !this.debugMode ? res['config_file'] : res; @@ -71,11 +82,8 @@ export class PostsService implements CanActivate { // login stuff if (localStorage.getItem('jwt_token')) { this.token = localStorage.getItem('jwt_token'); - this.httpOptions = { - params: new HttpParams({ - fromString: `apiKey=${this.auth_token}&sessionID=${this.session_id}&jwt=${this.token}` - }), - }; + this.httpOptions.params = this.httpOptions.params.set('jwt', this.token); + this.jwtAuth(); } else { this.sendToLogin(); @@ -321,18 +329,16 @@ export class PostsService implements CanActivate { return this.http.get('https://api.github.com/repos/tzahi12345/youtubedl-material/releases'); } - afterLogin(user, token) { + afterLogin(user, token, permissions, available_permissions) { this.isLoggedIn = true; this.user = user; + this.permissions = permissions; + this.available_permissions = available_permissions; this.token = token; localStorage.setItem('jwt_token', this.token); - this.httpOptions = { - params: new HttpParams({ - fromString: `apiKey=${this.auth_token}&sessionID=${this.session_id}&jwt=${this.token}` - }), - }; + this.httpOptions.params = this.httpOptions.params.set('jwt', this.token); // needed to re-initialize parts of app after login this.config_reloaded.next(true); @@ -347,7 +353,7 @@ export class PostsService implements CanActivate { const call = this.http.post(this.path + 'auth/login', {userid: username, password: password}, this.httpOptions); call.subscribe(res => { if (res['token']) { - this.afterLogin(res['user'], res['token']); + this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']); } }); return call; @@ -358,7 +364,7 @@ export class PostsService implements CanActivate { const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions); call.subscribe(res => { if (res['token']) { - this.afterLogin(res['user'], res['token']); + this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']); this.setInitialized(); } }, err => { @@ -371,6 +377,7 @@ export class PostsService implements CanActivate { logout() { this.user = null; + this.permissions = null; this.isLoggedIn = false; localStorage.setItem('jwt_token', null); if (this.router.url !== '/login') { @@ -430,7 +437,9 @@ export class PostsService implements CanActivate { } checkAdminCreationStatus() { - console.log('checking c stat'); + if (!this.config['Advanced']['multi_user_mode']) { + return; + } this.adminExists().subscribe(res => { if (!res['exists']) { // must create admin account @@ -439,6 +448,36 @@ export class PostsService implements CanActivate { }); } + changeUser(change_obj) { + return this.http.post(this.path + 'changeUser', {change_object: change_obj}, this.httpOptions); + } + + deleteUser(uid) { + return this.http.post(this.path + 'deleteUser', {uid: uid}, this.httpOptions); + } + + changeUserPassword(user_uid, new_password) { + return this.http.post(this.path + 'auth/changePassword', {user_uid: user_uid, new_password: new_password}, this.httpOptions); + } + + getUsers() { + return this.http.post(this.path + 'getUsers', {}, this.httpOptions); + } + + getRoles() { + return this.http.post(this.path + 'getRoles', {}, this.httpOptions); + } + + setUserPermission(user_uid, permission, new_value) { + return this.http.post(this.path + 'changeUserPermissions', {user_uid: user_uid, permission: permission, new_value: new_value}, + this.httpOptions); + } + + setRolePermission(role_name, permission, new_value) { + return this.http.post(this.path + 'changeRolePermissions', {role: role_name, permission: permission, new_value: new_value}, + this.httpOptions); + } + public openSnackBar(message: string, action: string = '') { this.snackBar.open(message, action, { duration: 2000, diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index e2e9102..b2696d5 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -266,6 +266,9 @@
+ + + diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index c3021a3..c31c28a 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -39,7 +39,7 @@ export class SettingsComponent implements OnInit { this._settingsSame = val; } - constructor(private postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer, + constructor(public postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer, private dialog: MatDialog) { } ngOnInit() {