Skip to content

Commit

Permalink
feat(user): add profile images (#1041)
Browse files Browse the repository at this point in the history
* feat(user): add image

Also creates static container for core

* feat(user): add removing of profile image

* fix(image): fix uploading of allowed extensions in caps

* chore(user): add tests for image handling

* chore(image-tests): upload test images

* chore(image-tests): remove mock

* chore(image-tests): fix tests
  • Loading branch information
LeonVreling authored Dec 7, 2024
1 parent d384521 commit 7e298d7
Show file tree
Hide file tree
Showing 17 changed files with 1,237 additions and 50 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
junit.xml
coverage/
.seed-executed
tmp_upload/
32 changes: 32 additions & 0 deletions docker/core-static/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
worker_processes 4;
pid /run/nginx.pid;

events {
worker_connections 2048;
multi_accept on;
use epoll;
}

http {
server_tokens off;
sendfile off;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 15;
types_hash_max_size 2048;
client_max_body_size 20M;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
include /etc/nginx/sites-available/*;
open_file_cache max=100;
charset UTF-8;

# setting real_ip from Docker network
set_real_ip_from 172.18.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
}
22 changes: 22 additions & 0 deletions docker/core-static/sites/default
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
server {
listen 80;
server_name oms-frontend;
root "/usr/app/media";

charset utf-8;

location /healthcheck {
alias /usr/app/status.json;
add_header "Content-Type" "application/json";
}

location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }

access_log /dev/stdout;
error_log stderr;

sendfile off;

client_max_body_size 100m;
}
3 changes: 3 additions & 0 deletions docker/core-static/status.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"success": true
}
24 changes: 24 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ services:
- "traefik.auth.frontend.priority=120"
- "traefik.enable=true"

core-static:
restart: on-failure
image: aegee/nginx-static:latest
volumes:
- core-media:/usr/app/media:ro
- ./${PATH_CORE}/core-static/status.json:/usr/app/status.json:ro
- ./${PATH_CORE}/core-static/nginx.conf:/etc/nginx/nginx.conf:ro
- ./${PATH_CORE}/core-static/sites/default:/etc/nginx/sites-available/default:ro
- shared:/usr/app/shared:ro
expose:
- "80"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/healthcheck"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.backend=core-static"
- "traefik.port=80"
- "traefik.frontend.rule=PathPrefix:/media/core;PathPrefixStrip:/media/core"
- "traefik.frontend.priority=110"
- "traefik.enable=true"

volumes:
postgres-core:
driver: local
Expand Down
107 changes: 107 additions & 0 deletions lib/imageserv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const path = require('path');
const util = require('util');
const fs = require('fs');
const multer = require('multer');
const readChunk = require('read-chunk');
const FileType = require('file-type');

const errors = require('./errors');
const log = require('./logger');
const config = require('../config');

const uploadFolderName = `${config.media_dir}/headimages`;
const allowedExtensions = ['.png', '.jpg', '.jpeg'];

const storage = multer.diskStorage({ // multers disk storage settings
destination(req, file, cb) {
cb(null, uploadFolderName);
},

// Filename is 4 character random string and the current datetime to avoid collisions
filename(req, file, cb) {
const prefix = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 4);
const date = (new Date()).getTime();
const extension = path.extname(file.originalname);

cb(null, `${prefix}-${date}${extension}`);
},
});
const upload = multer({
storage,
fileFilter(req, file, cb) {
const extension = path.extname(file.originalname).toLowerCase();
if (!allowedExtensions.includes(extension)) {
const allowed = allowedExtensions.map((e) => `'${e}'`).join(', ');
return cb(new Error(`Allowed extensions: ${allowed}, but '${extension}' was passed.`));
}

return cb(null, true);
},
}).single('head_image');

const uploadAsync = util.promisify(upload);

exports.uploadImage = async (req, res) => {
const oldimg = req.user.image;

// If upload folder doesn't exists, create it.
if (!fs.existsSync(uploadFolderName)) {
await fs.promises.mkdir(uploadFolderName, { recursive: true });
}

try {
await uploadAsync(req, res);
} catch (err) {
log.error({ err }, 'Could not store image');
return errors.makeValidationError(res, err);
}

// If the head_image field is missing, do nothing.
if (!req.file) {
return errors.makeValidationError(res, 'No head_image is specified.');
}

// If the file's content is malformed, don't save it.
const buffer = readChunk.sync(req.file.path, 0, 4100);
const type = await FileType.fromBuffer(buffer);

const originalExtension = path.extname(req.file.originalname).toLowerCase();
const determinedExtension = (type && type.ext ? `.${type.ext}` : 'unknown');

if (originalExtension !== determinedExtension || !allowedExtensions.includes(determinedExtension)) {
return errors.makeValidationError(res, 'Malformed file content.');
}

await req.user.update({
image: req.file.filename
});

// Remove old file
if (oldimg) {
await fs.promises.unlink(path.join(uploadFolderName, oldimg));
}

return res.json({
success: true,
message: 'File uploaded successfully',
data: req.user.image,
});
};

exports.removeImage = async (req, res) => {
if (!req.user.image) {
return errors.makeValidationError(res, 'No image is specified for the user.');
}

await fs.promises.unlink(path.join(uploadFolderName, req.user.image));

await req.user.update({
image: null
});

return res.json({
success: true,
message: 'File removed successfully',
data: req.user.image
});
};
3 changes: 3 additions & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const log = require('./logger');
const config = require('../config');
const Bugsnag = require('./bugsnag');
const cron = require('./cron');
const imageserv = require('./imageserv');

const middlewares = require('../middlewares/generic');
const fetch = require('../middlewares/fetch');
Expand Down Expand Up @@ -102,6 +103,8 @@ MemberRouter.post('/listserv', members.subscribeListserv);
MemberRouter.get('/', members.getUser);
MemberRouter.put('/', members.updateUser);
MemberRouter.delete('/', members.deleteUser);
MemberRouter.post('/upload', imageserv.uploadImage);
MemberRouter.delete('/image', imageserv.removeImage);

// Everything related to a specific body. Auth only (except for body details).
BodiesRouter.use(middlewares.maybeAuthorize, fetch.fetchBody);
Expand Down
11 changes: 11 additions & 0 deletions migrations/20241207102442-add-user-image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
up: (queryInterface, Sequelize) => queryInterface.addColumn(
'users',
'image',
{
type: Sequelize.STRING,
allowNull: true
},
),
down: (queryInterface) => queryInterface.removeColumn('users', 'image')
};
4 changes: 4 additions & 0 deletions models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ const User = sequelize.define('user', {
allowNull: true,
defaultValue: ''
},
image: {
type: Sequelize.STRING,
allowNull: true
},
about_me: {
type: Sequelize.TEXT,
allowNull: true,
Expand Down
Loading

0 comments on commit 7e298d7

Please sign in to comment.