funkwhale/api/funkwhale_api/users/models.py

403 wiersze
13 KiB
Python
Czysty Zwykły widok Historia

# -*- coding: utf-8 -*-
2018-06-10 08:55:16 +00:00
from __future__ import absolute_import, unicode_literals
import binascii
import datetime
import os
2018-06-19 19:47:43 +00:00
import random
import string
import uuid
from django.conf import settings
2018-06-10 08:39:47 +00:00
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
2018-06-10 08:55:16 +00:00
from django.urls import reverse
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
2018-08-22 18:10:39 +00:00
from django_auth_ldap.backend import populate_user as ldap_populate_user
from oauth2_provider import models as oauth2_models
from oauth2_provider import validators as oauth2_validators
2018-07-13 12:10:39 +00:00
from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
2018-07-13 12:10:39 +00:00
2018-06-10 08:55:16 +00:00
from funkwhale_api.common import fields, preferences
2018-07-13 12:10:39 +00:00
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import validators as common_validators
2018-07-22 10:20:16 +00:00
from funkwhale_api.federation import keys
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
2018-03-01 19:36:29 +00:00
def get_token():
2018-06-09 13:36:16 +00:00
return binascii.b2a_hex(os.urandom(15)).decode("utf-8")
PERMISSIONS_CONFIGURATION = {
"moderation": {
"label": "Moderation",
"help_text": "Block/mute/remove domains, users and content",
"scopes": {
"read:instance:policies",
"write:instance:policies",
"read:instance:accounts",
"write:instance:accounts",
"read:instance:domains",
"write:instance:domains",
"read:instance:reports",
"write:instance:reports",
},
},
2018-06-09 13:36:16 +00:00
"library": {
"label": "Manage library",
"help_text": "Manage library, delete files, tracks, artists, albums...",
"scopes": {
"read:instance:edits",
"write:instance:edits",
"read:instance:libraries",
"write:instance:libraries",
},
},
"settings": {
"label": "Manage instance-level settings",
"help_text": "",
"scopes": {
"read:instance:settings",
"write:instance:settings",
"read:instance:users",
"write:instance:users",
"read:instance:invitations",
"write:instance:invitations",
},
},
}
PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
2018-07-13 12:10:39 +00:00
get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False)
@python_2_unicode_compatible
class User(AbstractUser):
# First Name and Last Name do not cover name patterns
# around the globe.
name = models.CharField(_("Name of User"), blank=True, max_length=255)
# updated on logout or password change, to invalidate JWT
secret_key = models.UUIDField(default=uuid.uuid4, null=True)
2018-03-16 22:30:11 +00:00
privacy_level = fields.get_privacy_field()
# Unfortunately, Subsonic API assumes a MD5/password authentication
# scheme, which is weak in terms of security, and not achievable
# anyway since django use stronger schemes for storing passwords.
# Users that want to use the subsonic API from external client
# should set this token and use it as their password in such clients
2018-06-09 13:36:16 +00:00
subsonic_api_token = models.CharField(blank=True, null=True, max_length=255)
2018-03-01 19:36:29 +00:00
# permissions
permission_moderation = models.BooleanField(
PERMISSIONS_CONFIGURATION["moderation"]["label"],
help_text=PERMISSIONS_CONFIGURATION["moderation"]["help_text"],
2018-06-09 13:36:16 +00:00
default=False,
)
2018-05-18 17:18:40 +00:00
permission_library = models.BooleanField(
2018-06-09 13:36:16 +00:00
PERMISSIONS_CONFIGURATION["library"]["label"],
help_text=PERMISSIONS_CONFIGURATION["library"]["help_text"],
default=False,
)
2018-05-18 17:18:40 +00:00
permission_settings = models.BooleanField(
2018-06-09 13:36:16 +00:00
PERMISSIONS_CONFIGURATION["settings"]["label"],
help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"],
default=False,
)
last_activity = models.DateTimeField(default=None, null=True, blank=True)
2018-06-19 19:47:43 +00:00
invitation = models.ForeignKey(
"Invitation",
related_name="users",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
2018-07-13 12:10:39 +00:00
avatar = VersatileImageField(
upload_to=get_file_path,
null=True,
blank=True,
max_length=150,
validators=[
common_validators.ImageDimensionsValidator(min_width=50, min_height=50),
common_validators.FileValidator(
allowed_extensions=["png", "jpg", "jpeg", "gif"],
max_size=1024 * 1024 * 2,
),
],
)
2018-07-22 10:20:16 +00:00
actor = models.OneToOneField(
"federation.Actor",
related_name="user",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
2018-06-19 19:47:43 +00:00
upload_quota = models.PositiveIntegerField(null=True, blank=True)
def __str__(self):
return self.username
2018-06-19 16:48:43 +00:00
def get_permissions(self, defaults=None):
defaults = defaults or preferences.get("users__default_permissions")
perms = {}
for p in PERMISSIONS:
v = (
2018-06-09 13:36:16 +00:00
self.is_superuser
or getattr(self, "permission_{}".format(p))
or p in defaults
)
perms[p] = v
return perms
2018-06-19 18:11:40 +00:00
@property
def all_permissions(self):
return self.get_permissions()
def has_permissions(self, *perms, **kwargs):
operator = kwargs.pop("operator", "and")
2018-06-09 13:36:16 +00:00
if operator not in ["and", "or"]:
raise ValueError("Invalid operator {}".format(operator))
permissions = self.get_permissions()
2018-06-09 13:36:16 +00:00
checker = all if operator == "and" else any
return checker([permissions[p] for p in perms])
def get_absolute_url(self):
2018-06-09 13:36:16 +00:00
return reverse("users:detail", kwargs={"username": self.username})
def update_secret_key(self):
self.secret_key = uuid.uuid4()
return self.secret_key
def update_subsonic_api_token(self):
self.subsonic_api_token = get_token()
return self.subsonic_api_token
def set_password(self, raw_password):
super().set_password(raw_password)
self.update_secret_key()
if self.subsonic_api_token:
self.update_subsonic_api_token()
def get_activity_url(self):
2018-06-09 13:36:16 +00:00
return settings.FUNKWHALE_URL + "/@{}".format(self.username)
def record_activity(self):
"""
Simply update the last_activity field if current value is too old
than a threshold. This is useful to keep a track of inactive accounts.
"""
current = self.last_activity
delay = 60 * 15 # fifteen minutes
now = timezone.now()
if current is None or current < now - datetime.timedelta(seconds=delay):
self.last_activity = now
self.save(update_fields=["last_activity"])
2018-06-19 19:47:43 +00:00
def create_actor(self):
self.actor = create_actor(self)
self.save(update_fields=["actor"])
return self.actor
def get_upload_quota(self):
return self.upload_quota or preferences.get("users__upload_quota")
def get_quota_status(self):
data = self.actor.get_current_usage()
max_ = self.get_upload_quota()
return {
"max": max_,
"remaining": max(max_ - (data["total"] / 1000 / 1000), 0),
"current": data["total"] / 1000 / 1000,
"skipped": data["skipped"] / 1000 / 1000,
"pending": data["pending"] / 1000 / 1000,
"finished": data["finished"] / 1000 / 1000,
"errored": data["errored"] / 1000 / 1000,
}
def get_channels_groups(self):
2018-09-13 15:18:23 +00:00
groups = ["imports", "inbox"]
return ["user.{}.{}".format(self.pk, g) for g in groups]
def full_username(self):
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
@property
def avatar_path(self):
if not self.avatar:
return None
try:
return self.avatar.path
except NotImplementedError:
# external storage
return self.avatar.name
2018-06-19 19:47:43 +00:00
def generate_code(length=10):
return "".join(
random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length)
2018-06-19 19:47:43 +00:00
)
class InvitationQuerySet(models.QuerySet):
def open(self, include=True):
2018-06-19 19:47:43 +00:00
now = timezone.now()
qs = self.annotate(_users=models.Count("users"))
query = models.Q(_users=0, expiration_date__gt=now)
if include:
return qs.filter(query)
return qs.exclude(query)
2018-06-19 19:47:43 +00:00
class Invitation(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
expiration_date = models.DateTimeField()
owner = models.ForeignKey(
User, related_name="invitations", on_delete=models.CASCADE
)
code = models.CharField(max_length=50, unique=True)
objects = InvitationQuerySet.as_manager()
def save(self, **kwargs):
if not self.code:
self.code = generate_code()
if not self.expiration_date:
self.expiration_date = self.creation_date + datetime.timedelta(
days=settings.USERS_INVITATION_EXPIRATION_DAYS
)
return super().save(**kwargs)
class Application(oauth2_models.AbstractApplication):
scope = models.TextField(blank=True)
@property
def normalized_scopes(self):
from .oauth import permissions
raw_scopes = set(self.scope.split(" ") if self.scope else [])
return permissions.normalize(*raw_scopes)
# oob schemes are not supported yet in oauth toolkit
# (https://github.com/jazzband/django-oauth-toolkit/issues/235)
# so in the meantime, we override their validation to add support
OOB_SCHEMES = ["urn:ietf:wg:oauth:2.0:oob", "urn:ietf:wg:oauth:2.0:oob:auto"]
class CustomRedirectURIValidator(oauth2_validators.RedirectURIValidator):
def __call__(self, value):
if value in OOB_SCHEMES:
return value
return super().__call__(value)
oauth2_models.RedirectURIValidator = CustomRedirectURIValidator
class Grant(oauth2_models.AbstractGrant):
pass
class AccessToken(oauth2_models.AbstractAccessToken):
pass
class RefreshToken(oauth2_models.AbstractRefreshToken):
pass
2019-07-08 13:26:14 +00:00
def get_actor_data(username, **kwargs):
2019-01-30 10:54:43 +00:00
slugified_username = federation_utils.slugify_username(username)
2019-07-08 13:26:14 +00:00
domain = kwargs.get("domain")
if not domain:
domain = federation_models.Domain.objects.get_or_create(
name=settings.FEDERATION_HOSTNAME
)[0]
return {
2019-01-30 10:54:43 +00:00
"preferred_username": slugified_username,
2019-07-08 13:26:14 +00:00
"domain": domain,
2018-07-22 10:20:16 +00:00
"type": "Person",
2019-01-30 10:54:43 +00:00
"name": username,
2018-07-22 10:20:16 +00:00
"manually_approves_followers": False,
"fid": federation_utils.full_url(
2019-01-30 10:54:43 +00:00
reverse(
"federation:actors-detail",
kwargs={"preferred_username": slugified_username},
)
2018-07-22 10:20:16 +00:00
),
"shared_inbox_url": federation_models.get_shared_inbox_url(),
2018-07-22 10:20:16 +00:00
"inbox_url": federation_utils.full_url(
2019-01-30 10:54:43 +00:00
reverse(
"federation:actors-inbox",
kwargs={"preferred_username": slugified_username},
)
2018-07-22 10:20:16 +00:00
),
"outbox_url": federation_utils.full_url(
2019-01-30 10:54:43 +00:00
reverse(
"federation:actors-outbox",
kwargs={"preferred_username": slugified_username},
)
2018-07-22 10:20:16 +00:00
),
2018-09-22 12:29:30 +00:00
"followers_url": federation_utils.full_url(
reverse(
2019-01-30 10:54:43 +00:00
"federation:actors-followers",
kwargs={"preferred_username": slugified_username},
2018-09-22 12:29:30 +00:00
)
),
"following_url": federation_utils.full_url(
reverse(
2019-01-30 10:54:43 +00:00
"federation:actors-following",
kwargs={"preferred_username": slugified_username},
2018-09-22 12:29:30 +00:00
)
),
2018-07-22 10:20:16 +00:00
}
def create_actor(user):
2019-01-30 10:54:43 +00:00
args = get_actor_data(user.username)
private, public = keys.get_key_pair()
2018-07-22 10:20:16 +00:00
args["private_key"] = private.decode("utf-8")
args["public_key"] = public.decode("utf-8")
return federation_models.Actor.objects.create(user=user, **args)
2018-07-22 10:20:16 +00:00
2018-08-22 18:10:39 +00:00
@receiver(ldap_populate_user)
def init_ldap_user(sender, user, ldap_user, **kwargs):
if not user.actor:
user.actor = create_actor(user)
@receiver(models.signals.post_save, sender=User)
def warm_user_avatar(sender, instance, **kwargs):
if not instance.avatar or not settings.CREATE_IMAGE_THUMBNAILS:
return
user_avatar_warmer = VersatileImageFieldWarmer(
instance_or_queryset=instance, rendition_key_set="square", image_attr="avatar"
)
num_created, failed_to_create = user_avatar_warmer.warm()
@receiver(models.signals.pre_delete, sender=User)
def delete_actor(sender, instance, **kwargs):
if not instance.actor:
return
instance.actor.delete()