diff --git a/README.md b/README.md index 76accec..60474b3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ # photo_collection -Backend For a Cloud Photo Storage app + +Backend for a cloud photo storage app. + +## Local Development + +```bash +npm install +npm start +``` + +The server listens on `PORT` or `3030`. + +## Configuration + +The MySQL connection can be configured without editing source code: + +```bash +MYSQL_HOST=127.0.0.1 +MYSQL_USER=root +MYSQL_PASSWORD= +MYSQL_DATABASE=photo_collection +``` + +## Tests + +```bash +npm test +``` diff --git a/lib/fileserver.js b/lib/fileserver.js index 42e9b00..857e08c 100644 --- a/lib/fileserver.js +++ b/lib/fileserver.js @@ -1,28 +1,41 @@ exports.printcwd = function (ss='working') { console.log(ss+" "+process.cwd()); }; -exports.addUser = function(db, email, pass){ +exports.addUser = function(db, email, pass, done){ db.query("insert into users (email, password) values (?,?)", [email, pass], function (err, row) { + if(done) return done(err, row); if(err) throw err; // console.log(row); }); }; exports.listCollection = function(db, user_no, resp) { db.query("select * from collections where user_no = ?", user_no, function (err, row) { - if(err) throw err; + if(err) { + resp.writeHead(500, {'Content-Type': 'application/json'}); + resp.end(JSON.stringify({error: 'database_error'})); + return; + } resp.end(JSON.stringify(row)); }); }; exports.listPhoto = function(db, collection_no, resp) { db.query("select * from photographs where collection_number = ?", collection_no, function (err, row) { - if(err) throw err; + if(err) { + resp.writeHead(500, {'Content-Type': 'application/json'}); + resp.end(JSON.stringify({error: 'database_error'})); + return; + } // console.log(row); resp.end(JSON.stringify(row)); }); }; exports.addPhoto = function(db, url, no, resp){ db.query("insert into photographs (photo_url, collection_number) values (?,?)", [url, no], function (err, row) { - if(err) throw err; + if(err) { + resp.writeHead(500, {'Content-Type': 'application/json'}); + resp.end(JSON.stringify({error: 'database_error'})); + return; + } // console.log(row); exports.listPhoto(db, no, resp); @@ -30,8 +43,12 @@ exports.addPhoto = function(db, url, no, resp){ }; exports.login = function(db, param, resp) { db.query("select * from users where email = ? and password = ?", [param.email, param.password], function (err, row) { - if(err) throw err; + if(err) { + resp.writeHead(500, {'Content-Type': 'application/json'}); + resp.end(JSON.stringify({error: 'database_error'})); + return; + } // console.log(row); resp.end(JSON.stringify(row)); }); -}; \ No newline at end of file +}; diff --git a/lib/http.js b/lib/http.js new file mode 100644 index 0000000..d0b06d2 --- /dev/null +++ b/lib/http.js @@ -0,0 +1,31 @@ +var qs = require('querystring'); + +exports.readFormBody = function(req, done) { + var body = ''; + req.on('data', function(chunk) { + body += chunk; + if (body.length > 1024 * 1024) { + req.destroy(); + done(new Error('request body too large')); + } + }); + req.on('error', done); + req.on('end', function() { + try { + done(null, qs.parse(body)); + } catch (err) { + done(err); + } + }); +}; + +exports.sendJson = function(resp, statusCode, payload) { + resp.writeHead(statusCode, { 'Content-Type': 'application/json' }); + resp.end(JSON.stringify(payload)); +}; + +exports.requireFields = function(source, fields) { + return fields.filter(function(field) { + return source[field] == null || String(source[field]).trim() === ''; + }); +}; diff --git a/lib/static-images.js b/lib/static-images.js new file mode 100644 index 0000000..1470c86 --- /dev/null +++ b/lib/static-images.js @@ -0,0 +1,46 @@ +var fs = require('fs'); +var path = require('path'); + +var IMAGE_ROOT = path.resolve(__dirname, '..', 'public', 'images'); +var CONTENT_TYPES = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp' +}; + +function resolvePublicImagePath(requestPath) { + var relativePath = String(requestPath || '').replace(/^\/public\/images\/?/, ''); + var absolutePath = path.resolve(IMAGE_ROOT, relativePath); + if (absolutePath.indexOf(IMAGE_ROOT + path.sep) !== 0 && absolutePath !== IMAGE_ROOT) { + return null; + } + return absolutePath; +} + +function contentTypeFor(filePath) { + return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +exports.resolvePublicImagePath = resolvePublicImagePath; +exports.contentTypeFor = contentTypeFor; + +exports.streamPublicImage = function(requestPath, resp) { + var absolutePath = resolvePublicImagePath(requestPath); + if (!absolutePath) { + resp.writeHead(400, { 'Content-Type': 'application/json' }); + resp.end(JSON.stringify({ error: 'invalid_image_path' })); + return; + } + + fs.stat(absolutePath, function(err, stats) { + if (err || !stats.isFile()) { + resp.writeHead(404, { 'Content-Type': 'application/json' }); + resp.end(JSON.stringify({ error: 'image_not_found' })); + return; + } + resp.writeHead(200, { 'Content-Type': contentTypeFor(absolutePath) }); + fs.createReadStream(absolutePath).pipe(resp); + }); +}; diff --git a/lib/uploads.js b/lib/uploads.js new file mode 100644 index 0000000..5dbd6ae --- /dev/null +++ b/lib/uploads.js @@ -0,0 +1,37 @@ +var path = require('path'); + +var IMAGE_ROOT = path.resolve(__dirname, '..', 'public', 'images'); +var SAFE_EXTENSIONS = { + '.gif': true, + '.jpeg': true, + '.jpg': true, + '.png': true, + '.webp': true +}; + +function sanitizeSegment(value) { + return String(value || '') + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'uncategorized'; +} + +function safeExtension(filename) { + var ext = path.extname(filename || '').toLowerCase(); + return SAFE_EXTENSIONS[ext] ? ext : '.jpg'; +} + +exports.sanitizeSegment = sanitizeSegment; +exports.safeExtension = safeExtension; + +exports.buildUploadTarget = function(collectionNumber, originalName) { + var collectionDir = sanitizeSegment(collectionNumber); + var filename = Date.now() + '-' + Math.random().toString(36).slice(2, 10) + safeExtension(originalName); + var directory = path.join(IMAGE_ROOT, collectionDir); + var absolutePath = path.join(directory, filename); + return { + directory: directory, + absolutePath: absolutePath, + publicPath: 'public/images/' + collectionDir + '/' + filename + }; +}; diff --git a/package.json b/package.json index 353d50a..20806db 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "server.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test", "start": "node server.js" }, "author": "", diff --git a/server.js b/server.js index 0810d1c..ac0c900 100644 --- a/server.js +++ b/server.js @@ -1,186 +1,189 @@ var http = require('http'); var url = require('url'); -var join = require('path').join; var qs = require('querystring'); var fs = require('fs'); var mkdirp = require('mkdirp'); var formidable = require('formidable'); var fileServer = require('./lib/fileserver.js'); var mysql = require('mysql'); +var httpHelpers = require('./lib/http.js'); +var staticImages = require('./lib/static-images.js'); +var uploads = require('./lib/uploads.js'); + +var PORT = Number(process.env.PORT || 3030); var db = mysql.createConnection({ - host: '127.0.0.1', - user: 'root', - password: '', - database: 'photo_collection' + host: process.env.MYSQL_HOST || '127.0.0.1', + user: process.env.MYSQL_USER || 'root', + password: process.env.MYSQL_PASSWORD || '', + database: process.env.MYSQL_DATABASE || 'photo_collection' }); + +function handleDbError(err, resp) { + if (!err) return false; + console.error(err); + httpHelpers.sendJson(resp, 500, { error: 'database_error' }); + return true; +} + db.connect(function(err) { - if(err)throw err; - console.log("connected"); + if (err) throw err; + console.log('connected'); }); + http.createServer(function(req, resp) { - switch (req.method) { - case 'GET': - var new_uri = url.parse(req.url); - if(new_uri.pathname.indexOf('/public/images')== 0){ - // console.log(new_uri); - fs.createReadStream('.'+new_uri.pathname).pipe(resp); - // resp.end("a phto"); - return; + var parsedUrl = url.parse(req.url || ''); + + if (req.method === 'GET') { + if (parsedUrl.pathname.indexOf('/public/images') === 0) { + staticImages.streamPublicImage(parsedUrl.pathname, resp); + return; + } + + if (parsedUrl.pathname === '/api') { + if (parsedUrl.query == null) { + fs.createReadStream('./public/form.html').pipe(resp); + return; } - if(new_uri.pathname == '/api' ){ - if(new_uri.query == null){ - // console.log('Invalid api'); - fs.createReadStream('./public/form.html').pipe(resp); + + var params = qs.parse(parsedUrl.query); + var missing = httpHelpers.requireFields(params, ['email', 'password']); + if (missing.length) { + httpHelpers.sendJson(resp, 400, { + error: 'missing_fields', + fields: missing + }); + return; + } + + db.query('select * from users where email = ?', params.email, function(err, row) { + if (handleDbError(err, resp)) return; + if (row.length === 0) { + fileServer.addUser(db, params.email, params.password, function(addErr) { + if (handleDbError(addErr, resp)) return; + httpHelpers.sendJson(resp, 201, { status: 'created' }); + }); + } else { + resp.setHeader('exist_before', true); + httpHelpers.sendJson(resp, 200, row); + } + }); + return; + } + + httpHelpers.sendJson(resp, 404, { error: 'not_found' }); + return; + } + + if (req.method === 'POST') { + if (parsedUrl.pathname === '/addcollection') { + httpHelpers.readFormBody(req, function(err, parameters) { + if (err) { + httpHelpers.sendJson(resp, 400, { error: 'invalid_form_body' }); return; } - var para = new_uri.query.split('&'); - // console.log(para[0].split('=')); - // console.log(para[1].split('=')); - db.query("select * from users where email = ?", para[0].split('=')[1], function (err, row) { - if(err) throw err; - console.log(row); - if(row.length == 0){ - fileServer.addUser(db, para[0].split('=')[1], para[1].split('=')[1]); - resp.end('New Acccount'); - }else{ - resp.setHeader('exist_before', true); - resp.end(JSON.stringify(row)); + var missing = httpHelpers.requireFields(parameters, ['collection_name', 'user_no']); + if (missing.length) { + httpHelpers.sendJson(resp, 400, { error: 'missing_fields', fields: missing }); + return; + } + db.query( + 'insert into collections (collection_name, user_no) values (?,?)', + [parameters.collection_name, parameters.user_no], + function(insertErr) { + if (handleDbError(insertErr, resp)) return; + fileServer.listCollection(db, parameters.user_no, resp); } - - }); + ); + }); + return; + } + + if (parsedUrl.pathname === '/login') { + httpHelpers.readFormBody(req, function(err, parameters) { + if (err) { + httpHelpers.sendJson(resp, 400, { error: 'invalid_form_body' }); + return; + } + var missing = httpHelpers.requireFields(parameters, ['email', 'password']); + if (missing.length) { + httpHelpers.sendJson(resp, 400, { error: 'missing_fields', fields: missing }); + return; + } + fileServer.login(db, parameters, resp); + }); + return; + } + + if (parsedUrl.pathname === '/listcollection') { + httpHelpers.readFormBody(req, function(err, parameters) { + if (err) { + httpHelpers.sendJson(resp, 400, { error: 'invalid_form_body' }); + return; + } + var missing = httpHelpers.requireFields(parameters, ['user_no']); + if (missing.length) { + httpHelpers.sendJson(resp, 400, { error: 'missing_fields', fields: missing }); + return; + } + fileServer.listCollection(db, parameters.user_no, resp); + }); + return; + } + + if (parsedUrl.pathname.indexOf('/listphoto') === 0) { + var collectionNo = parsedUrl.pathname.split('/')[2]; + if (!collectionNo) { + httpHelpers.sendJson(resp, 400, { error: 'missing_collection_number' }); + return; } - break; - case 'POST': - var body = ''; - var new_post_uri = url.parse(req.url); - if(new_post_uri.pathname == '/addcollection'){ - // console.log(new_post_uri); - body = ''; - req.on('data', function(chunk) { - body += chunk; - }); - req.on('end', function() { - var parameters = qs.parse(body); - // console.log(parameters); - db.query("insert into collections (collection_name, user_no) values (?,?)", [parameters.collection_name, parameters.user_no], function (err, row) { - if(err) throw err; - var jsonresp = fileServer.listCollection(db, parameters.user_no, resp); - // resp.end(JSON.stringify(row)); + fileServer.listPhoto(db, collectionNo, resp); + return; + } + + if (parsedUrl.pathname === '/upload') { + var form = new formidable.IncomingForm(); + form.parse(req, function(err, fields, files) { + if (err) { + httpHelpers.sendJson(resp, 400, { error: 'invalid_upload' }); + return; + } + var missing = httpHelpers.requireFields(fields, ['file_details']); + if (missing.length || !files.uploaded_file) { + httpHelpers.sendJson(resp, 400, { + error: 'missing_fields', + fields: missing.concat(files.uploaded_file ? [] : ['uploaded_file']) }); - }); - return; - } - if(new_post_uri.pathname == '/login'){ - // console.log(new_post_uri); - body = ''; - req.on('data', function(chunk) { - body += chunk; - }); - req.on('end', function() { - var parameters = qs.parse(body); - // console.log(parameters); - fileServer.login(db, parameters, resp); - }); - return; - } - if(new_post_uri.pathname == '/listcollection'){ - // console.log(new_post_uri); - body = ''; - req.on('data', function(chunk) { - body += chunk; - }); - req.on('end', function() { - var parameters = qs.parse(body); - // console.log(parameters); - fileServer.listCollection(db, parameters.user_no, resp); - }); - return; - } - if(new_post_uri.pathname.indexOf('/listphoto')== 0){ - // console.log(new_post_uri.pathname.split("/")[2]); - - fileServer.listPhoto(db, new_post_uri.pathname.split("/")[2], resp); - return; - } - - - if(new_post_uri.pathname == '/upload'){ - body = ''; - // console.log(req.headers['content-type']); - - var form = new formidable.IncomingForm(); - form.parse(req, function (err, fields, files) { - if(err)throw err; - // console.log("Successfully formidable"); - db.query("select * from photographs where collection_number = ?", fields.file_details, function (err, row) { - if(err) throw err; - // console.log(row); - var new_url = "public/images/"+fields.file_details; - mkdirp(new_url, function (err) { - if(err)throw err; - // console.log("Successfully made dir"); - fs.rename(files.uploaded_file.path, new_url+"/"+row.length+".jpg", function (err) { - if (err) throw err; - // console.log("Successfully Uploaded"); - fileServer.addPhoto(db, new_url+"/"+row.length+".jpg", fields.file_details, resp); - }); - // console.log(files.uploaded_file.name); + return; + } + + db.query('select * from photographs where collection_number = ?', fields.file_details, function(queryErr) { + if (handleDbError(queryErr, resp)) return; + var target = uploads.buildUploadTarget(fields.file_details, files.uploaded_file.name); + mkdirp(target.directory, function(mkdirErr) { + if (mkdirErr) { + httpHelpers.sendJson(resp, 500, { error: 'upload_directory_error' }); + return; + } + fs.rename(files.uploaded_file.path, target.absolutePath, function(renameErr) { + if (renameErr) { + httpHelpers.sendJson(resp, 500, { error: 'upload_write_error' }); + return; + } + fileServer.addPhoto(db, target.publicPath, fields.file_details, resp); }); - }); - }); - return; - } - - // form.on('') - // resp.end("success"); - // form.on('progress', function(cur, total) { - // console.log(Math.floor(cur/total * 100)); - // }); - // req.on('data', function(chunk) { - // body += chunk; - // }); - // req.on('end', function () { - // console.log(body); - // resp.end("success"); - // }); - // req.on('end', function(chunk) { - - // // mkdirp('public/images/test', function (err) { - // // if(err)throw err; - // // console.log("Successfully made dir"); - // // }); - // // fs.writeFile(join(__dirname,"public/images/test/32.jpg"), body, function (err) { - // // if(err)throw err; - // // console.log("Successfull"); - // // }); - - // }); - // req.on('end', function() { - // var parameters = qs.parse(body); - // console.log(parameters); - // db.query("select * from users where email = ?", parameters.email, function (err, row) { - // if(err) throw err; - // console.log(row); - // if(row.length == 0){ - // fileServer.addUser(db, parameters.email, parameters.password); - // resp.end('New Acccount'); - // }else{ - // resp.setHeader('exist_before', true); - // resp.end(JSON.stringify(row)); - // } - - // }); - // }); - - break; - - default: - break; + }); + return; + } + + httpHelpers.sendJson(resp, 404, { error: 'not_found' }); + return; } -}).listen(3030 || process.env.PORT, function() { - console.log("Listening to 3030"); + + httpHelpers.sendJson(resp, 405, { error: 'method_not_allowed' }); +}).listen(PORT, function() { + console.log('Listening to ' + PORT); }); -fileServer.printcwd("runing"); +fileServer.printcwd('running'); diff --git a/test/http.test.js b/test/http.test.js new file mode 100644 index 0000000..9900c42 --- /dev/null +++ b/test/http.test.js @@ -0,0 +1,14 @@ +var test = require('node:test'); +var assert = require('node:assert/strict'); +var httpHelpers = require('../lib/http.js'); + +test('requireFields returns blank and missing fields', function() { + assert.deepEqual(httpHelpers.requireFields({ email: 'user@example.com', password: ' ' }, ['email', 'password', 'name']), [ + 'password', + 'name' + ]); +}); + +test('requireFields accepts present non-empty values', function() { + assert.deepEqual(httpHelpers.requireFields({ user_no: '42' }, ['user_no']), []); +}); diff --git a/test/static-images.test.js b/test/static-images.test.js new file mode 100644 index 0000000..d5f4306 --- /dev/null +++ b/test/static-images.test.js @@ -0,0 +1,21 @@ +var test = require('node:test'); +var assert = require('node:assert/strict'); +var path = require('node:path'); +var staticImages = require('../lib/static-images.js'); + +test('resolvePublicImagePath keeps valid requests inside public images', function() { + var resolved = staticImages.resolvePublicImagePath('/public/images/album/photo.jpg'); + assert.equal(path.basename(resolved), 'photo.jpg'); + assert.equal(path.basename(path.dirname(resolved)), 'album'); + assert.equal(path.basename(path.dirname(path.dirname(resolved))), 'images'); +}); + +test('resolvePublicImagePath rejects path traversal attempts', function() { + assert.equal(staticImages.resolvePublicImagePath('/public/images/../../server.js'), null); +}); + +test('contentTypeFor maps common image extensions', function() { + assert.equal(staticImages.contentTypeFor('photo.jpg'), 'image/jpeg'); + assert.equal(staticImages.contentTypeFor('photo.png'), 'image/png'); + assert.equal(staticImages.contentTypeFor('photo.unknown'), 'application/octet-stream'); +}); diff --git a/test/uploads.test.js b/test/uploads.test.js new file mode 100644 index 0000000..5e19945 --- /dev/null +++ b/test/uploads.test.js @@ -0,0 +1,18 @@ +var test = require('node:test'); +var assert = require('node:assert/strict'); +var uploads = require('../lib/uploads.js'); + +test('sanitizeSegment removes path and shell-sensitive characters', function() { + assert.equal(uploads.sanitizeSegment('../album 42!!'), 'album-42'); +}); + +test('safeExtension preserves known image extensions and falls back to jpg', function() { + assert.equal(uploads.safeExtension('selfie.PNG'), '.png'); + assert.equal(uploads.safeExtension('payload.exe'), '.jpg'); +}); + +test('buildUploadTarget returns a public image path for sanitized collection id', function() { + var target = uploads.buildUploadTarget('../album 42', 'selfie.png'); + assert.match(target.publicPath, /^public\/images\/album-42\/\d+-[a-z0-9]+\.png$/); + assert.match(target.absolutePath, /public[\\/]images[\\/]album-42[\\/]\d+-[a-z0-9]+\.png$/); +});