diff --git a/migrations/002-local-auth.sql b/migrations/002-local-auth.sql
new file mode 100644
index 0000000..d9f8016
--- /dev/null
+++ b/migrations/002-local-auth.sql
@@ -0,0 +1,30 @@
+-- Up
+
+CREATE TABLE User (
+ id INTEGER PRIMARY KEY,
+ username TEXT,
+ password TEXT,
+ email TEXT,
+ image TEXT,
+ created TEXT
+);
+
+CREATE TABLE OAuth (
+ id INTEGER PRIMARY KEY,
+ userId INTEGER,
+ remoteId TEXT,
+ created TEXT,
+ CONSTRAINT PE_fk_userId FOREIGN KEY (userId)
+ REFERENCES User (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+ALTER TABLE Track ADD COLUMN userId INTEGER;
+ALTER TABLE Playlist ADD COLUMN userId INTEGER;
+ALTER TABLE PlaylistEntry ADD COLUMN userId INTEGER;
+
+-- Down
+DROP TABLE User;
+DROP TABLE OAuth;
+ALTER TABLE Track DROP COLUMN userId;
+ALTER TABLE Playlist DROP COLUMN userId;
+ALTER TABLE PlaylistEntry DROP COLUMN userId;
diff --git a/package.json b/package.json
index ff10302..7aa66b7 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,15 @@
"private": true,
"dependencies": {
"babel-plugin-transform-runtime": "^6.23.0",
+ "bcrypt": "^3.0.6",
"bluebird": "^3.5.2",
+ "connect-redis": "^3.4.1",
"express": "^4.16.3",
"express-async-errors": "^3.0.0",
+ "express-session": "^1.16.2",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^7.0.0",
+ "oauth-libre": "^0.9.17",
"socket.io": "^2.1.1",
"sqlite": "^3.0.2",
"sqlite3": "^4.0.6"
diff --git a/public/index.html b/public/index.html
index 8ae9392..690c26e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -112,6 +112,11 @@
+
User
+
diff --git a/public/index.js b/public/index.js
index 7b121f2..dd577cc 100644
--- a/public/index.js
+++ b/public/index.js
@@ -6,6 +6,7 @@
var playing = document.getElementById('playing')
var optdrop = document.getElementById('options-drop')
var optmenu = document.getElementById('options')
+ var loggedin = document.getElementById('logged-in')
var menu = document.getElementById('menu')
@@ -48,6 +49,9 @@
sortdir: 'asc'
}
+ // User info
+ var user = {}
+
window.mobilecheck = function() {
var check = false;
(function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera);
@@ -114,6 +118,7 @@
reject(new Error(xmlHttp.status))
}
}
+ xmlHttp.withCredentials = true
xmlHttp.open('GET', url, true)
xmlHttp.send(null)
})
@@ -583,6 +588,16 @@
}
})
+ function checkUser () {
+ httpGet('/user/info').then(function (data) {
+ user = data
+ loggedin.innerHTML = 'Logged in as ' + data.username
+ }, function (e) {
+ window.location.href = '/user/login'
+ })
+ }
+
+ checkUser()
loadOptions()
showTracks(1)
handleHash(window.location.hash)
diff --git a/src/server.js b/src/server.js
index b1ec486..7e51145 100644
--- a/src/server.js
+++ b/src/server.js
@@ -2,9 +2,12 @@ import path from 'path'
import sqlite from 'sqlite'
import Promise from 'bluebird'
import express from 'express'
+import session from 'express-session'
+import redis from 'connect-redis'
import http from 'http'
import https from 'https'
import ffmpeg from 'fluent-ffmpeg'
+import { user, userMiddleware } from './user'
import lfmda from './lastfm'
@@ -35,7 +38,20 @@ const router = express.Router()
const sortfields = ['id', 'track', 'artist', 'title', 'album', 'year', 'file']
const srchcategories = ['title', 'artist', 'album']
-router.get('/tracks', async (req, res) => {
+let SessionStore = redis(session)
+app.use(session({
+ key: values.session_key || 'Session',
+ secret: values.session_secret || 'ch4ng3 m3!',
+ store: new SessionStore(values.redis || { port: 6379 }),
+ resave: false,
+ saveUninitialized: true,
+ cookie: {
+ secure: process.env.NODE_ENV !== 'development',
+ maxAge: 2678400000 // 1 month
+ }
+}))
+
+router.get('/tracks', userMiddleware, async (req, res) => {
let page = parseInt(req.query.page) || 1
if (isNaN(page)) {
page = 1
@@ -70,7 +86,7 @@ router.get('/tracks', async (req, res) => {
})
})
-router.get('/tracks/search', async (req, res) => {
+router.get('/tracks/search', userMiddleware, async (req, res) => {
let query = req.query.q
let streamable = (req.query.streamable === '1')
let qr = ''
@@ -150,7 +166,7 @@ router.get('/tracks/search', async (req, res) => {
})
})
-router.get('/track/:id', async (req, res, next) => {
+router.get('/track/:id', userMiddleware, async (req, res, next) => {
let id = req.params.id
let db = await dbPromise
@@ -165,14 +181,14 @@ router.get('/track/:id', async (req, res, next) => {
res.jsonp(track)
})
-router.get('/playlists', async (req, res, next) => {
+router.get('/playlists', userMiddleware, async (req, res, next) => {
let db = await dbPromise
let playlists = await db.all('SELECT * FROM Playlist')
res.jsonp(playlists)
})
-router.get('/playlist/:id', async (req, res, next) => {
+router.get('/playlist/:id', userMiddleware, async (req, res, next) => {
let id = req.params.id
let db = await dbPromise
let playlist = await db.get('SELECT title FROM Playlist WHERE id = ?', id)
@@ -185,7 +201,7 @@ router.get('/playlist/:id', async (req, res, next) => {
res.jsonp(playlist)
})
-router.get('/serve/by-id/:id', async (req, res, next) => {
+router.get('/serve/by-id/:id', userMiddleware, async (req, res, next) => {
let id = req.params.id
let dl = (req.query.dl === '1')
let db = await dbPromise
@@ -217,6 +233,8 @@ router.use((err, req, res, next) => {
res.status(404).jsonp({error: 404})
})
+app.use('/user', user(dbPromise, values.oauth))
+
app.use('/api', router)
app.use('/file/track', express.static(path.resolve(values.directory)))
app.use('/', express.static(path.join(process.cwd(), 'public')))
diff --git a/src/user.js b/src/user.js
new file mode 100644
index 0000000..d8310fb
--- /dev/null
+++ b/src/user.js
@@ -0,0 +1,152 @@
+import express from 'express'
+import bcrypt from 'bcrypt'
+import { PromiseOAuth2 } from 'oauth-libre'
+import crypto from 'crypto'
+
+const router = express.Router()
+
+async function userInfoPublic (db, id) {
+ let u = await db.get('SELECT id, username, image FROM User WHERE id = ?', id)
+ if (!u) return {}
+ return {
+ id: u.id,
+ username: u.username,
+ image: u.image
+ }
+}
+
+export async function userInfo (db, id) {
+ return db.get('SELECT * FROM User WHERE id = ?', id)
+}
+
+export function userMiddleware (req, res, next) {
+ if (!req.session || !req.session.user) return res.status(401).jsonp({ error: 'Unauthorized' })
+ next()
+}
+
+export function user (dbPromise, oauth) {
+ router.get('/info', userMiddleware, async (req, res) => {
+ res.jsonp(await userInfoPublic(await dbPromise, req.session.user))
+ })
+
+ router.get('/info/:id', userMiddleware, async (req, res) => {
+ if (isNaN(parseInt(req.params.id))) throw new Error('Invalid user ID!')
+ res.jsonp(await userInfoPublic(await dbPromise, parseInt(req.params.id)))
+ })
+
+ if (!oauth) return router
+
+ let oauth2 = new PromiseOAuth2(oauth.clientId, oauth.clientSecret, oauth.baseUrl, oauth.authorizePath, oauth.tokenPath)
+
+ router.get('/login/oauth/_redirect', async (req, res) => {
+ let code = req.query.code
+ let state = req.query.state
+ if (!code || !state) throw new Error('Something went wrong!')
+ if (!req.session.oauthState || req.session.oauthState !== state) throw new Error('Possible request forgery detected! Try again.')
+
+ delete req.session.oauthState
+
+ let tokens
+ try {
+ tokens = await oauth2.getOAuthAccessToken(code, { grant_type: 'authorization_code', redirect_uri: oauth.redirectUri })
+ } catch (e) {
+ throw new Error('No authorization!')
+ }
+
+ let accessToken = tokens[2].access_token
+
+ // Get user information on remote
+ let userInfo
+ try {
+ userInfo = await oauth2.get(oauth.baseUrl + oauth.userPath, accessToken)
+ userInfo = JSON.parse(userInfo)
+ } catch (e) {
+ userInfo = null
+ }
+
+ if (!userInfo) throw new Error('Couldn\'t get user information!')
+
+ // Let's see if there's a link for this user already..
+ let db = await dbPromise
+ let userLocal = await db.get('SELECT * FROM OAuth WHERE remoteId = ?', userInfo.id)
+
+ // User and link both exist
+ if (userLocal) {
+ if (req.session.user) throw new Error('You are already logged in!')
+
+ req.session.user = userLocal.userId
+ return res.redirect('/')
+ }
+
+ // If we're logged in, create a link
+ if (req.session.user) {
+ await db.run('INSERT INTO OAuth (userId,remoteId,created) VALUES (?,?,?)', req.session.user, userInfo.id, new Date())
+ return res.redirect('/')
+ }
+
+ // Create a new user and log in
+ let newU = await db.get('INSERT INTO User (username,email,image,created) VALUES (?,?,?,?)', userInfo.username, userInfo.email, userInfo.image, new Date())
+ await db.run('INSERT INTO OAuth (userId,remoteId,created) VALUES (?,?,?)', newU.id, userInfo.id, new Date())
+ req.session.user = newU.id
+ res.redirect('/')
+ })
+
+ router.get('/login/oauth', async (req, res) => {
+ let state = req.session.oauthState || crypto.randomBytes(10).toString('hex')
+ req.session.oauthState = state
+ return res.redirect(oauth2.getAuthorizeUrl({
+ 'redirect_uri': oauth.redirectUri,
+ 'scope': oauth.scope,
+ 'response_type': 'code',
+ 'state': state
+ }))
+ })
+
+ router.use('/login', async (req, res, next) => {
+ if (req.session && req.session.user) return res.redirect('/')
+ let header = req.get('authorization') || ''
+ let token = header.split(/\s+/).pop() || ''
+ let auth = Buffer.from(token, 'base64').toString()
+ let parts = auth.split(/:/)
+ let username = parts[0]
+ let password = parts[1]
+
+ let message = oauth != null ? 'Enter \'oauth\' to log in remotely.' : 'Log in'
+ req.message = message
+
+ if ((!username || !password) && (username !== 'oauth' && oauth)) {
+ return next()
+ }
+
+ if ((username === 'oauth' || username === 'oa') && oauth) {
+ return res.redirect('/user/login/oauth')
+ }
+
+ let db = await dbPromise
+ let user = await db.get('SELECT * FROM User WHERE username = ?', username)
+ if (!user) return next()
+
+ if (!user.password && oauth) {
+ return res.redirect('/user/login/oauth')
+ }
+
+ // Compare passwords
+ let ures = await bcrypt.compare(password, user.password)
+ if (!ures) return next()
+
+ // Set login success
+ req.message = ''
+ req.session.user = user.id
+ res.redirect('/')
+ }, function (req, res, next) {
+ if (!req.message) return next()
+ res.status(401).set('WWW-Authenticate', 'Basic realm="' + req.message + '", charset="UTF-8"').end()
+ })
+
+ router.use('/logout', function (req, res) {
+ delete req.session.user
+ res.redirect('/')
+ })
+
+ return router
+}