diff --git a/.gitignore b/.gitignore index d82e823..1b1010e 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,9 @@ fabric.properties .idea/caches/build_file_checksums.ser test* + +# Uploaded files +tmp.uploads/* +!tmp.uploads/.gitkeep +uploads/* +!uploads/.gitkeep diff --git a/TODO.text b/TODO.text index 889a994..a6c866b 100644 --- a/TODO.text +++ b/TODO.text @@ -1,8 +1,4 @@ - Tagline bug - cannot save with empty text -- Profile photos - - Allow uploading/changing - - Default photo - - Expose photo endpoint for public services - App setup wizard - SAML IAM handling - LDAP IAM handling diff --git a/app/assets/app/dash-components.js b/app/assets/app/dash-components.js index b670a57..f18d85e 100644 --- a/app/assets/app/dash-components.js +++ b/app/assets/app/dash-components.js @@ -3,6 +3,7 @@ import NavBarComponent from './dash/NavBar.component.js' import MessageContainerComponent from './dash/message/MessageContainer.component.js' import EditProfileComponent from './dash/profile/EditProfile.component.js' import AppPasswordFormComponent from './dash/profile/form/AppPassword.component.js' +import ProfilePhotoUploaderComponent from './dash/profile/form/ProfilePhotoUploader.component.js' import ListingComponent from './cobalt/Listing.component.js' import FormComponent from './cobalt/Form.component.js' @@ -13,6 +14,7 @@ const dash_components = { MessageContainerComponent, EditProfileComponent, AppPasswordFormComponent, + ProfilePhotoUploaderComponent, ListingComponent, FormComponent, diff --git a/app/assets/app/dash/profile/EditProfile.component.js b/app/assets/app/dash/profile/EditProfile.component.js index feeffbf..f93fe88 100644 --- a/app/assets/app/dash/profile/EditProfile.component.js +++ b/app/assets/app/dash/profile/EditProfile.component.js @@ -13,7 +13,10 @@ const template = `
- Profile Image + Profile Image +
+ +

{{ profile_first }} {{ profile_last }}

@@ -140,6 +143,11 @@ const template = ` ref="app_password_modal" @modal-success="load_app_passwords" > +
` @@ -187,6 +195,17 @@ export default class EditProfileComponent extends Component { } } + on_profile_change_click() { + this.$refs.profile_photo_uploader.show() + } + + async on_profile_photo_upload() { + this.$refs.profile_photo_uploader.close() + let src = this.$refs.photo.src + if ( src.indexOf('?') > 0 ) src = src.split('?')[0] + this.$refs.photo.src = `${src}?i=${(new Date).getTime()}` + } + valid_email() { return this.$refs.email_input.validity.valid } diff --git a/app/assets/app/dash/profile/form/ProfilePhotoUploader.component.js b/app/assets/app/dash/profile/form/ProfilePhotoUploader.component.js new file mode 100644 index 0000000..24d4898 --- /dev/null +++ b/app/assets/app/dash/profile/form/ProfilePhotoUploader.component.js @@ -0,0 +1,60 @@ +import { Component } from '../../../../lib/vues6/vues6.js' + +const template = ` + +` + +export default class ProfilePhotoUploaderComponent extends Component { + static get selector() { return 'coreid-profile-photo-uploader' } + static get template() { return template } + static get params() { return [] } + + ready = false + + show() { + this.ready = true + this.$nextTick(() => { + $(this.$refs.modal).modal() + }) + } + + close() { + $(this.$refs.modal).modal('hide') + } + + async do_upload() { + if ( this.$refs.input.files.length < 1 ) return + const data = new FormData() + data.append('photo', this.$refs.input.files[0]) + try { + await axios.post(`/api/v1/profile/me/photo`, data, { // TODO support passed-in user_id + headers: { + 'Content-Type': 'multipart/form-data', + } + }) + this.$emit('upload') + } catch (e) { + console.error(e) + } + } +} diff --git a/app/assets/humans.txt b/app/assets/humans.txt new file mode 100644 index 0000000..e32af51 --- /dev/null +++ b/app/assets/humans.txt @@ -0,0 +1 @@ +Stock profile photo thanks to: https://www.flaticon.com/authors/vitaly-gorbachev diff --git a/app/assets/less/dashboard.less b/app/assets/less/dashboard.less index 55b7ee5..16daa29 100644 --- a/app/assets/less/dashboard.less +++ b/app/assets/less/dashboard.less @@ -61,3 +61,24 @@ body { .pad-top { padding-top: 30px; } + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + flex-direction: row; + display: flex; + align-items: center; + justify-content: center; + background: rgba(20, 20, 20, 0.4); + opacity: 0; + + transition: all 0.1s linear; + + &:hover { + opacity: 1; + } +} diff --git a/app/assets/people.png b/app/assets/people.png new file mode 100644 index 0000000..179d043 Binary files /dev/null and b/app/assets/people.png differ diff --git a/app/assets/profile.jpg b/app/assets/profile.jpg deleted file mode 100644 index b48af6d..0000000 Binary files a/app/assets/profile.jpg and /dev/null differ diff --git a/app/controllers/api/v1/Profile.controller.js b/app/controllers/api/v1/Profile.controller.js index f2ad0a5..39923e6 100644 --- a/app/controllers/api/v1/Profile.controller.js +++ b/app/controllers/api/v1/Profile.controller.js @@ -1,9 +1,10 @@ const { Controller } = require('libflitter') const Validator = require('email-validator') +const path = require('path') class ProfileController extends Controller { static get services() { - return [...super.services, 'models'] + return [...super.services, 'models', 'utility'] } async fetch(req, res, next) { @@ -72,6 +73,45 @@ class ProfileController extends Controller { return res.api() } + async update_photo(req, res, next) { + const User = this.models.get('auth:User') + let user + if ( req.params.user_id === 'me' ) user = req.user + else user = await User.findById(req.params.user_id) + + if ( !user ) + return res.status(404) + .message('No user found with the specified ID.') + .api() + + if ( !req?.uploads?.photo ) + return res.status(400) + .message('Missing required field: file') + .api() + + user.photo_file_id = req.uploads.photo.id + await user.save() + return res.api() + } + + async get_photo(req, res, next) { + const User = this.models.get('auth:User') + let user + if ( req.params.user_id === 'me' ) user = req.user + else user = await User.findById(req.params.user_id) + + if ( !user ) + return res.status(404) + .message('No user found with the specified ID.') + .api() + + const photo = await user.photo() + if ( photo ) return photo.send(res) + + // The user does not have a profile. Send the default. + return res.sendFile(this.utility.path('app/assets/people.png')) + } + } module.exports = exports = ProfileController diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js index 2284f10..0a051b7 100644 --- a/app/models/auth/User.model.js +++ b/app/models/auth/User.model.js @@ -34,9 +34,15 @@ class User extends AuthUser { mfa_enabled: {type: Boolean, default: false}, mfa_enable_date: Date, create_date: {type: Date, default: () => new Date}, + photo_file_id: String, }} } + async photo() { + const File = this.models.get('upload::File') + return File.findById(this.photo_file_id) + } + has_authorized(client) { return this.app_authorizations.some(x => x.client_id === client.id) } diff --git a/app/routing/middleware/upload/UploadFile.middleware.js b/app/routing/middleware/upload/UploadFile.middleware.js new file mode 100644 index 0000000..53fe9fb --- /dev/null +++ b/app/routing/middleware/upload/UploadFile.middleware.js @@ -0,0 +1,12 @@ +const Middleware = require('flitter-upload/middleware/UploadFile') + +/* + * Middleware to upload the files included in the request + * to the default file store backend. Stores instances of + * the "upload::File" model in "request.uploads". + */ +class UploadFile extends Middleware { + +} + +module.exports = exports = UploadFile diff --git a/app/routing/routers/api/v1/profile.routes.js b/app/routing/routers/api/v1/profile.routes.js index d6630a5..30eb0b5 100644 --- a/app/routing/routers/api/v1/profile.routes.js +++ b/app/routing/routers/api/v1/profile.routes.js @@ -10,6 +10,18 @@ const profile_routes = { ['middleware::api:Permission', { check: 'v1:profile:get' }], 'controller::api:v1:Profile.fetch', ], + '/:user_id/photo': [ + ['middleware::api:Permission', { check: 'v1:profile:photo:get' }], + 'controller::api:v1:Profile.get_photo', + ], + }, + + post: { + '/:user_id/photo': [ + ['middleware::api:Permission', { check: 'v1:profile:photo:update' }], + ['middleware::upload:UploadFile', { tag: 'v1:profile:photo' }], + 'controller::api:v1:Profile.update_photo', + ], }, patch: { diff --git a/config/server.config.js b/config/server.config.js index 261299a..e00919d 100644 --- a/config/server.config.js +++ b/config/server.config.js @@ -35,12 +35,14 @@ const server_config = { }, uploads: { + enable: true, + allowed_path: /./, /* * Used by flitter-upload. Path for uploaded files. * Should be relative to the application root. */ - destination: './uploads' + destination: './tmp.uploads', }, ssl: { diff --git a/config/upload.config.js b/config/upload.config.js new file mode 100644 index 0000000..0b1a3cf --- /dev/null +++ b/config/upload.config.js @@ -0,0 +1,33 @@ +/* + * flitter-upload configuration + * --------------------------------------------------------------- + * Specifies the configuration for various uploader aspects. Mainly, + * contains the configuration for the different file upload backends. + */ +const upload_config = { + /* + * The name of the upload backend to use by default. + */ + default_store: 'flitter', + + /* + * Stores available to the uploader. + */ + stores: { + + /* + * Example of the basic, filesystem-backed uploader. + * The name of the store is arbitrary. Here, it's called 'flitter'. + */ + flitter: { + // This is a filesystem backed 'FlitterStore' + type: 'FlitterStore', + + // Destination for uploaded files. Will be relative to the root + // path of the application. + destination: './uploads', + }, + }, +} + +module.exports = exports = upload_config diff --git a/package.json b/package.json index 1644c6e..cc9246c 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "flitter-flap": "^0.5.2", "flitter-forms": "^0.8.1", "flitter-less": "^0.5.3", - "flitter-upload": "^0.8.0", + "flitter-upload": "^0.8.1", "is-absolute-url": "^3.0.3", "ldapjs": "^1.0.2", - "libflitter": "^0.51.0", + "libflitter": "^0.52.0", "moment": "^2.24.0", "mongodb": "^3.5.6", "qrcode": "^1.4.4", diff --git a/tmp.uploads/.gitkeep b/tmp.uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/yarn.lock b/yarn.lock index eb75bc2..b2c7e5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1871,10 +1871,10 @@ flitter-orm@^0.3.1: sinon "^9.0.0" uuid "^3.4.0" -flitter-upload@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/flitter-upload/-/flitter-upload-0.8.0.tgz#653086f5daa7400305dddb0b0993cebed3e2506f" - integrity sha512-yRhQy/pI7WbXvwcG4zp7eXotj4nkJl/vfitO4L0MAIXGMroduLFOFNwcSYB3xuWGI68i+wJzBvzVmtX46x4Gww== +flitter-upload@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/flitter-upload/-/flitter-upload-0.8.1.tgz#c69c2b16f8f5227532fe28373de4c1f8094fcdf6" + integrity sha512-ASLIAibIriSOes3zODcD40+Ly5mGATvAVf41bOZDTRjnTT6ZG8PTbV3EaU+WerPtIO0cj8ccPiusR8X/nPkKeA== dependencies: es6-promisify "^6.0.1" mkdirp "^1.0.3" @@ -2743,10 +2743,10 @@ leven@^1.0.2: resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3" integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM= -libflitter@^0.51.0: - version "0.51.0" - resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.51.0.tgz#dbe92f1a7bf7fad4edd4fa05de8c1fa3140975fa" - integrity sha512-O/HNbpyXzZaop2Np8J5ETRWWZ1Vv7eBd5RzYCVIfQS5DqCn+UsQX0sR1HfE3d1TcH0ype3n7KjcRBva3MgjkgQ== +libflitter@^0.52.0: + version "0.52.0" + resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.52.0.tgz#c9279d5cff93fd6f24b89f46c2add2f0c3e3b0f1" + integrity sha512-+YR+rww1i7NREfXPJwoxc0yhpmNbMYozgcAed6yfkxK59JS9DBM7cUWwV+dxG0UlFilOxZBGXdj/oKiIs+huvQ== dependencies: colors "^1.3.3" connect-mongodb-session "^2.2.0"