Skip to content

Commit 7e298d7

Browse files
authored
feat(user): add profile images (#1041)
* 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
1 parent d384521 commit 7e298d7

17 files changed

+1237
-50
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
junit.xml
33
coverage/
44
.seed-executed
5+
tmp_upload/

docker/core-static/nginx.conf

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
worker_processes 4;
2+
pid /run/nginx.pid;
3+
4+
events {
5+
worker_connections 2048;
6+
multi_accept on;
7+
use epoll;
8+
}
9+
10+
http {
11+
server_tokens off;
12+
sendfile off;
13+
tcp_nopush on;
14+
tcp_nodelay on;
15+
keepalive_timeout 15;
16+
types_hash_max_size 2048;
17+
client_max_body_size 20M;
18+
include /etc/nginx/mime.types;
19+
default_type application/octet-stream;
20+
access_log /var/log/nginx/access.log;
21+
error_log /var/log/nginx/error.log;
22+
gzip on;
23+
gzip_disable "msie6";
24+
include /etc/nginx/sites-available/*;
25+
open_file_cache max=100;
26+
charset UTF-8;
27+
28+
# setting real_ip from Docker network
29+
set_real_ip_from 172.18.0.0/16;
30+
real_ip_header X-Forwarded-For;
31+
real_ip_recursive on;
32+
}

docker/core-static/sites/default

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
server {
2+
listen 80;
3+
server_name oms-frontend;
4+
root "/usr/app/media";
5+
6+
charset utf-8;
7+
8+
location /healthcheck {
9+
alias /usr/app/status.json;
10+
add_header "Content-Type" "application/json";
11+
}
12+
13+
location = /favicon.ico { access_log off; log_not_found off; }
14+
location = /robots.txt { access_log off; log_not_found off; }
15+
16+
access_log /dev/stdout;
17+
error_log stderr;
18+
19+
sendfile off;
20+
21+
client_max_body_size 100m;
22+
}

docker/core-static/status.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"success": true
3+
}

docker/docker-compose.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,30 @@ services:
5151
- "traefik.auth.frontend.priority=120"
5252
- "traefik.enable=true"
5353

54+
core-static:
55+
restart: on-failure
56+
image: aegee/nginx-static:latest
57+
volumes:
58+
- core-media:/usr/app/media:ro
59+
- ./${PATH_CORE}/core-static/status.json:/usr/app/status.json:ro
60+
- ./${PATH_CORE}/core-static/nginx.conf:/etc/nginx/nginx.conf:ro
61+
- ./${PATH_CORE}/core-static/sites/default:/etc/nginx/sites-available/default:ro
62+
- shared:/usr/app/shared:ro
63+
expose:
64+
- "80"
65+
healthcheck:
66+
test: ["CMD", "curl", "-f", "http://localhost:80/healthcheck"]
67+
interval: 30s
68+
timeout: 10s
69+
retries: 3
70+
start_period: 40s
71+
labels:
72+
- "traefik.backend=core-static"
73+
- "traefik.port=80"
74+
- "traefik.frontend.rule=PathPrefix:/media/core;PathPrefixStrip:/media/core"
75+
- "traefik.frontend.priority=110"
76+
- "traefik.enable=true"
77+
5478
volumes:
5579
postgres-core:
5680
driver: local

lib/imageserv.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const path = require('path');
2+
const util = require('util');
3+
const fs = require('fs');
4+
const multer = require('multer');
5+
const readChunk = require('read-chunk');
6+
const FileType = require('file-type');
7+
8+
const errors = require('./errors');
9+
const log = require('./logger');
10+
const config = require('../config');
11+
12+
const uploadFolderName = `${config.media_dir}/headimages`;
13+
const allowedExtensions = ['.png', '.jpg', '.jpeg'];
14+
15+
const storage = multer.diskStorage({ // multers disk storage settings
16+
destination(req, file, cb) {
17+
cb(null, uploadFolderName);
18+
},
19+
20+
// Filename is 4 character random string and the current datetime to avoid collisions
21+
filename(req, file, cb) {
22+
const prefix = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 4);
23+
const date = (new Date()).getTime();
24+
const extension = path.extname(file.originalname);
25+
26+
cb(null, `${prefix}-${date}${extension}`);
27+
},
28+
});
29+
const upload = multer({
30+
storage,
31+
fileFilter(req, file, cb) {
32+
const extension = path.extname(file.originalname).toLowerCase();
33+
if (!allowedExtensions.includes(extension)) {
34+
const allowed = allowedExtensions.map((e) => `'${e}'`).join(', ');
35+
return cb(new Error(`Allowed extensions: ${allowed}, but '${extension}' was passed.`));
36+
}
37+
38+
return cb(null, true);
39+
},
40+
}).single('head_image');
41+
42+
const uploadAsync = util.promisify(upload);
43+
44+
exports.uploadImage = async (req, res) => {
45+
const oldimg = req.user.image;
46+
47+
// If upload folder doesn't exists, create it.
48+
if (!fs.existsSync(uploadFolderName)) {
49+
await fs.promises.mkdir(uploadFolderName, { recursive: true });
50+
}
51+
52+
try {
53+
await uploadAsync(req, res);
54+
} catch (err) {
55+
log.error({ err }, 'Could not store image');
56+
return errors.makeValidationError(res, err);
57+
}
58+
59+
// If the head_image field is missing, do nothing.
60+
if (!req.file) {
61+
return errors.makeValidationError(res, 'No head_image is specified.');
62+
}
63+
64+
// If the file's content is malformed, don't save it.
65+
const buffer = readChunk.sync(req.file.path, 0, 4100);
66+
const type = await FileType.fromBuffer(buffer);
67+
68+
const originalExtension = path.extname(req.file.originalname).toLowerCase();
69+
const determinedExtension = (type && type.ext ? `.${type.ext}` : 'unknown');
70+
71+
if (originalExtension !== determinedExtension || !allowedExtensions.includes(determinedExtension)) {
72+
return errors.makeValidationError(res, 'Malformed file content.');
73+
}
74+
75+
await req.user.update({
76+
image: req.file.filename
77+
});
78+
79+
// Remove old file
80+
if (oldimg) {
81+
await fs.promises.unlink(path.join(uploadFolderName, oldimg));
82+
}
83+
84+
return res.json({
85+
success: true,
86+
message: 'File uploaded successfully',
87+
data: req.user.image,
88+
});
89+
};
90+
91+
exports.removeImage = async (req, res) => {
92+
if (!req.user.image) {
93+
return errors.makeValidationError(res, 'No image is specified for the user.');
94+
}
95+
96+
await fs.promises.unlink(path.join(uploadFolderName, req.user.image));
97+
98+
await req.user.update({
99+
image: null
100+
});
101+
102+
return res.json({
103+
success: true,
104+
message: 'File removed successfully',
105+
data: req.user.image
106+
});
107+
};

lib/server.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const log = require('./logger');
99
const config = require('../config');
1010
const Bugsnag = require('./bugsnag');
1111
const cron = require('./cron');
12+
const imageserv = require('./imageserv');
1213

1314
const middlewares = require('../middlewares/generic');
1415
const fetch = require('../middlewares/fetch');
@@ -102,6 +103,8 @@ MemberRouter.post('/listserv', members.subscribeListserv);
102103
MemberRouter.get('/', members.getUser);
103104
MemberRouter.put('/', members.updateUser);
104105
MemberRouter.delete('/', members.deleteUser);
106+
MemberRouter.post('/upload', imageserv.uploadImage);
107+
MemberRouter.delete('/image', imageserv.removeImage);
105108

106109
// Everything related to a specific body. Auth only (except for body details).
107110
BodiesRouter.use(middlewares.maybeAuthorize, fetch.fetchBody);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module.exports = {
2+
up: (queryInterface, Sequelize) => queryInterface.addColumn(
3+
'users',
4+
'image',
5+
{
6+
type: Sequelize.STRING,
7+
allowNull: true
8+
},
9+
),
10+
down: (queryInterface) => queryInterface.removeColumn('users', 'image')
11+
};

models/User.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ const User = sequelize.define('user', {
130130
allowNull: true,
131131
defaultValue: ''
132132
},
133+
image: {
134+
type: Sequelize.STRING,
135+
allowNull: true
136+
},
133137
about_me: {
134138
type: Sequelize.TEXT,
135139
allowNull: true,

0 commit comments

Comments
 (0)