Browse Source

Add user avatars upload

pull/208/head
squeaky otter 2 years ago
parent
commit
897276078d
Signed by: dashie GPG Key ID: C2D57B325840B755
17 changed files with 195 additions and 30 deletions
  1. +1
    -0
      .env.dev
  2. +5
    -1
      CHANGELOG.md
  3. +4
    -0
      api/app.py
  4. +1
    -0
      api/config/config.py
  5. +1
    -0
      api/config/production_secret_sample.py
  6. +46
    -19
      api/controllers/api/v1/accounts.py
  7. +11
    -7
      api/datas_helpers.py
  8. +16
    -0
      api/models.py
  9. +2
    -0
      api/utils/defaults.py
  10. +4
    -0
      deploy/docker.nginx.template
  11. +4
    -0
      deploy/docker/nginx/conf.dev
  12. +1
    -0
      deploy/env.prod.sample
  13. +1
    -0
      docs/installation/configuration.rst
  14. +74
    -1
      front/src/components/user_settings/user_settings.vue
  15. +23
    -1
      front/src/services/api/api.service.js
  16. +1
    -1
      front/src/services/entity_normalizer/entity_normalizer.service.js
  17. +0
    -0
      uploads/avatars/.gitkeep

+ 1
- 0
.env.dev View File

@ -14,6 +14,7 @@ UPLOADS_DEFAULT_DEST=/data/uploads
UPLOADED_SOUNDS_DEST=/data/uploads/sounds
UPLOADED_ARTWORKALBUMS_DEST=/data/uploads/artwork_albums
UPLOADED_ARTWORKSOUNDS_DEST=/data/uploads/artwork_sounds
UPLOADED_AVATARS_DEST=/data/uploads/avatars
AUDIOWAVEFORM_BIN=/usr/local/bin/audiowaveform


+ 5
- 1
CHANGELOG.md View File

@ -14,7 +14,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 005-update-user-quotas
- 006-generate-albums-uuids
- **Breaking:** Commands to run: `flask db-datas 005-update-user-quotas` to precompute the user quotas
- **Breaking:** New config options to set: `UPLOADED_ARTWORKALBUMS_DEST` and `UPLOADED_ARTWORKSOUNDS_DEST`; Update nginx config accordingly
- **Breaking:** New config options to set:
- `UPLOADED_ARTWORKALBUMS_DEST`
- `UPLOADED_ARTWORKSOUNDS_DEST`
- `UPLOADED_AVATARS_DEST`
- Update nginx config accordingly
- User quotas (#179)
- Refactored the cli commands (#179)
- Added a few more users commands (#184)


+ 4
- 0
api/app.py View File

@ -259,6 +259,10 @@ def create_app(config_filename="config.development.Config", app_name=None, regis
artworksounds = UploadSet("artworksounds", Reel2bitsDefaults.artwork_extensions_allowed)
configure_uploads(app, artworksounds)
# User avatars
avatars = UploadSet("avatars", Reel2bitsDefaults.avatar_extensions_allowed)
configure_uploads(app, avatars)
# Total max size upload for the whole app
patch_request_class(app, Reel2bitsDefaults.track_size_limit)


+ 1
- 0
api/config/config.py View File

@ -110,6 +110,7 @@ class BaseConfig(object):
UPLOADED_SOUNDS_DEST = os.getenv("UPLOADED_SOUNDS_DEST", "/home/reel2bits/uploads/sounds")
UPLOADED_ARTWORKALBUMS_DEST = os.getenv("UPLOADED_ARTWORKALBUMS_DEST", "/home/reel2bits/uploads/artwork_albums")
UPLOADED_ARTWORKSOUNDS_DEST = os.getenv("UPLOADED_ARTWORKSOUNDS_DEST", "/home/reel2bits/uploads/artwork_sounds")
UPLOADED_AVATARS_DEST = os.getenv("UPLOADED_AVATARS_DEST", "/home/reel2bits/uploads/avatars")
# Where is audiowaveform located
AUDIOWAVEFORM_BIN = os.getenv("AUDIOWAVEFORM_BIN", "/usr/local/bin/audiowaveform")


+ 1
- 0
api/config/production_secret_sample.py View File

@ -18,6 +18,7 @@ class Config(BaseConfig):
UPLOADED_SOUNDS_DEST = "/Users/dashie/dev/reel2bits/uploads/sounds"
UPLOADED_ARTWORKALBUMS_DEST = "/Users/dashie/dev/reel2bits/uploads/artwork_albums"
UPLOADED_ARTWORKSOUNDS_DEST = "/Users/dashie/dev/reel2bits/uploads/artwork_sounds"
UPLOADED_AVATARS_DEST = "/Users/dashie/dev/reel2bits/uploads/avatars"
# Where is the audiowaveform binary located
AUDIOWAVEFORM_BIN = "/usr/local/bin/audiowaveform"


+ 46
- 19
api/controllers/api/v1/accounts.py View File

@ -18,7 +18,7 @@ from flask_security import confirmable as FSConfirmable
from app_oauth import authorization, require_oauth
from authlib.flask.oauth2 import current_token
from datas_helpers import to_json_track, to_json_account, to_json_relationship
from utils.various import forbidden_username, add_user_log, add_log
from utils.various import forbidden_username, add_user_log, add_log, get_hashed_filename
from tasks import send_update_profile, federate_delete_actor
import re
from sqlalchemy import or_
@ -26,11 +26,15 @@ from flask_mail import Message
from flask import render_template
import smtplib
from app import mail
from utils.defaults import Reel2bitsDefaults
from flask_uploads import UploadSet
import os
bp_api_v1_accounts = Blueprint("bp_api_v1_accounts", __name__)
username_is_legal = re.compile("^[a-zA-Z0-9]+$")
avatars = UploadSet("avatars", Reel2bitsDefaults.avatar_extensions_allowed)
# Parameters needed:
# nickname(==username), email, fullname, password, confirm, agreement, locale(dropped here for now)
@ -395,34 +399,57 @@ def accounts_update_credentials():
- $ref: '#/definitions/Source'
- $ref: '#/definitions/AccountPleroma'
"""
user = current_token.user
r_lang = request.json.get("lang", None)
r_fullname = request.json.get("fullname", None)
r_bio = request.json.get("bio", None)
current_user = current_token.user
r_user = User.query.filter(User.id == user.id).first()
if not r_user:
if not current_user:
# WTF ?
abort(500)
if r_lang:
r_user.locale = r_lang
if r_fullname:
r_user.display_name = r_fullname
if r_bio:
r_user.actor[0].summary = r_bio
return jsonify({"error": "User not found"}), 404
# Update fields like bio, language, etc.
if request.json:
r_lang = request.json.get("lang", None)
r_fullname = request.json.get("fullname", None)
r_bio = request.json.get("bio", None)
if r_lang:
current_user.locale = r_lang
if r_fullname:
current_user.display_name = r_fullname
if r_bio:
current_user.actor[0].summary = r_bio
elif request.files:
# Update things like user background, profile picture, etc.
if "avatar" in request.files:
avatar_uploaded = request.files["avatar"]
avatar_uploaded.seek(0, os.SEEK_END)
avatar_size = avatar_uploaded.tell()
avatar_uploaded.seek(0)
if avatar_size > Reel2bitsDefaults.avatar_size_limit:
return jsonify({"error": "artwork too big, 2MB maximum"}), 413 # Request Entity Too Large
# Delete old avatar if any
if current_user.avatar_filename:
old_avatar = os.path.join(current_app.config["UPLOADED_AVATARS_DEST"], current_user.path_avatar())
if os.path.isfile(old_avatar):
os.unlink(old_avatar)
else:
print(f"Error: cannot delete old avatar: {current_user.id} / {current_user.avatar_filename}")
# Save new avatar
avatar_filename = get_hashed_filename(avatar_uploaded.filename)
avatars.save(avatar_uploaded, folder=current_user.slug, name=avatar_filename)
current_user.avatar_filename = avatar_filename
# commit changes
db.session.commit()
# log action
add_user_log(user.id, user.id, "user", "info", "Edited user profile")
add_user_log(current_user.id, current_user.id, "user", "info", "Edited user profile")
# trigger a profile update
send_update_profile(r_user)
send_update_profile(current_user)
return jsonify(to_json_account(r_user))
return jsonify(to_json_account(current_user))
@bp_api_v1_accounts.route("/api/v1/accounts/<int:user_id>/statuses", methods=["GET"])


+ 11
- 7
api/datas_helpers.py View File

@ -30,6 +30,10 @@ def to_json_relationship(of_user, against_user):
def to_json_account(user, relationship=False):
url_feed = url_for("bp_feeds.tracks", user_id=user.id, _external=False)
if user.path_avatar():
url_avatar = url_for("get_uploads_stuff", thing="avatars", stuff=user.path_avatar(), _external=True)
else:
url_avatar = "/static/userpic_placeholder.svg"
obj = dict(
id=user.id,
@ -45,8 +49,8 @@ def to_json_account(user, relationship=False):
).count(),
note=user.actor[0].summary,
url=user.actor[0].url,
avatar=("" or "/static/userpic_placeholder.svg"),
avatar_static=("" or "/static/userpic_placeholder.svg"),
avatar=url_avatar,
avatar_static=url_avatar,
header="",
header_static="",
emojis=[],
@ -76,10 +80,10 @@ def to_json_account(user, relationship=False):
def to_json_track(track, account):
si = track.sound_infos.first()
url_orig = url_for("get_uploads_stuff", thing="sounds", stuff=track.path_sound(orig=True), _external=False)
url_transcode = url_for("get_uploads_stuff", thing="sounds", stuff=track.path_sound(orig=False), _external=False)
url_orig = url_for("get_uploads_stuff", thing="sounds", stuff=track.path_sound(orig=True), _external=True)
url_transcode = url_for("get_uploads_stuff", thing="sounds", stuff=track.path_sound(orig=False), _external=True)
if track.path_artwork():
url_artwork = url_for("get_uploads_stuff", thing="artwork_sounds", stuff=track.path_artwork(), _external=False)
url_artwork = url_for("get_uploads_stuff", thing="artwork_sounds", stuff=track.path_artwork(), _external=True)
else:
url_artwork = None
@ -152,9 +156,9 @@ def to_json_track(track, account):
def to_json_album(album, account):
url_feed = url_for("bp_feeds.album", user_id=album.user.id, album_id=album.id, _external=False)
url_feed = url_for("bp_feeds.album", user_id=album.user.id, album_id=album.id, _external=True)
if album.path_artwork():
url_artwork = url_for("get_uploads_stuff", thing="artwork_albums", stuff=album.path_artwork(), _external=False)
url_artwork = url_for("get_uploads_stuff", thing="artwork_albums", stuff=album.path_artwork(), _external=True)
else:
url_artwork = None


+ 16
- 0
api/models.py View File

@ -167,6 +167,22 @@ class User(db.Model, UserMixin):
)
return (c[0] or 0) + (c[1] or 0)
def path_avatar(self):
if self.avatar_filename:
return os.path.join(self.slug, self.avatar_filename)
else:
return None
# Delete files file when COMMIT DELETE
def __commit_delete__(self):
print("COMMIT DELETE: Deleting files")
if self.avatar_filename:
fname = os.path.join(current_app.config["UPLOADED_AVATARS_DEST"], self.avatar_filename())
if os.path.isfile(fname):
os.unlink(fname)
else:
print(f"!!! COMMIT DELETE USER cannot delete avatar file {fname}")
event.listen(User.name, "set", User.generate_slug, retval=False)


+ 2
- 0
api/utils/defaults.py View File

@ -61,5 +61,7 @@ class Reel2bitsDefaults(object):
# 2MB
artwork_size_limit = 2000000
avatar_size_limit = artwork_size_limit
artwork_extensions_allowed = tuple("jpg jpe jpeg png gif JPG JPE JPEG PNG GIF".split())
avatar_extensions_allowed = artwork_extensions_allowed

+ 4
- 0
deploy/docker.nginx.template View File

@ -62,4 +62,8 @@ server {
location /_protected/media/artwork_albums {
alias ${UPLOADED_ARTWORKALBUMS_DEST};
}
location /_protected/media/avatars {
alias ${UPLOADED_AVATARS_DEST};
}
}

+ 4
- 0
deploy/docker/nginx/conf.dev View File

@ -106,5 +106,9 @@ http {
location /_protected/media/artwork_albums {
alias ${UPLOADED_ARTWORKALBUMS_DEST};
}
location /_protected/media/avatars {
alias ${UPLOADED_AVATARS_DEST};
}
}
}

+ 1
- 0
deploy/env.prod.sample View File

@ -52,6 +52,7 @@ UPLOADS_DEFAULT_DEST=/data/uploads
UPLOADED_SOUNDS_DEST=/data/uploads/sounds
UPLOADED_ARTWORKALBUMS_DEST=/data/uploads/artwork_albums
UPLOADED_ARTWORKSOUNDS_DEST=/data/uploads/artwork_sounds
UPLOADED_AVATARS_DEST=/data/uploads/avatars
# Set this to where your SPA index is
# relative to the docker path (default: /frontend/index.html)


+ 1
- 0
docs/installation/configuration.rst View File

@ -86,5 +86,6 @@ You can use the following keys to define upload paths:
- UPLOADED_SOUNDS_DEST, default: /home/reel2bits/uploads/sounds
- UPLOADED_ARTWORKALBUMS_DEST, default: /home/reel2bits/uploads/artwork_albums
- UPLOADED_ARTWORKSOUNDS_DEST, default: /home/reel2bits/uploads/artwork_sounds
- UPLOADED_AVATARS_DEST, default: /home/reel2bits/uploads/avatars
Paths of sounds and waveforms should be under the default one.

+ 74
- 1
front/src/components/user_settings/user_settings.vue View File

@ -67,6 +67,45 @@
Save
</translate>
</b-button>
<hr>
<div class="row">
<div class="col-sm-6">
<p v-translate translate-context="Content/UserSettings/Text/Avatar picker" class="visibility-notice">
The recommended minimum size for avatar pictures is 112x112 pixels. JPEG, PNG or GIF only.
</p>
<p v-translate translate-context="Content/UserSettings/Title/Avatar picker">
Current avatar
</p>
<img
:src="currentAvatar"
class="current-avatar"
width="112"
height="112"
>
</div>
<div class="col-sm-6">
<p v-translate translate-context="Content/UserSettings/Title/Avatar picker">
Set new avatar
</p>
<b-button
v-show="pickAvatarBtnVisible"
id="pick-avatar"
>
<translate translate-context="Content/UserSettings/Button/Avatar picker">
Upload an image
</translate>
</b-button>
<image-cropper
trigger="#pick-avatar"
:submit-handler="submitAvatar"
@open="pickAvatarBtnVisible=false"
@close="pickAvatarBtnVisible=true"
/>
</div>
</div>
</b-form>
</b-tab>
@ -164,8 +203,12 @@ ul.nav-tabs {
import { validationMixin } from 'vuelidate'
import { required, maxLength } from 'vuelidate/lib/validators'
import locales from '../../locales.js'
import ImageCropper from '../../components/image_cropper/image_cropper.vue'
export default {
components: {
ImageCropper
},
mixins: [validationMixin],
data: () => ({
user: {
@ -177,7 +220,8 @@ export default {
saveOk: false,
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false
changePasswordError: false,
pickAvatarBtnVisible: true
}),
validations: {
user: {
@ -204,6 +248,13 @@ export default {
accountTab: this.$pgettext('Content/UserSettings/Tabs/Label', 'Account'),
deleteAccountModalTitle: this.$pgettext('Content/UserSettings/Modal/Title', 'Account deletion')
}
},
currentAvatar () {
if (this.currentUser && this.currentUser.profile_image_url) {
return this.currentUser.profile_image_url
} else {
return '/static/userpic_placeholder.svg'
}
}
},
created () {
@ -297,6 +348,28 @@ export default {
variant: 'danger'
})
})
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
updateAvatar(file)
}
})
}
}
}


+ 23
- 1
front/src/services/api/api.service.js View File

@ -690,6 +690,27 @@ const updateArtwork = ({ kind, objId, userId, picture, credentials }) => {
})
}
const updateAvatar = ({ credentials, avatar }) => {
const form = new FormData()
let filename = 'blob.invalid'
if (avatar.type === 'image/jpeg') {
filename = 'blob.jpg'
} else if (avatar.type === 'image/png') {
filename = 'blob.png'
} else if (avatar.type === 'image/gif') {
filename = 'blob.gif'
}
form.append('avatar', avatar, filename)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const apiService = {
verifyCredentials,
register,
@ -722,7 +743,8 @@ const apiService = {
deleteUser,
fetchGenres,
fetchTags,
updateArtwork
updateArtwork,
updateAvatar
}
export default apiService

+ 1
- 1
front/src/services/entity_normalizer/entity_normalizer.service.js View File

@ -141,7 +141,7 @@ export const parseStatus = (data) => {
output.title = data.reel2bits.title
output.account = parseUser(data.account)
output.description = data.content
output.picture_url = (data.reel2bits.picture_url || '/static/artwork_placeholder.svg')
output.picture_url = (data.picture_url || '/static/artwork_placeholder.svg')
output.media_orig = data.reel2bits.media_orig
output.media_transcoded = data.reel2bits.media_transcoded
output.url_feed = data.reel2bits.url_feed


+ 0
- 0
uploads/avatars/.gitkeep View File


Loading…
Cancel
Save