発散解像度 -divergence resolution-
Signed-off-by: Shin'ya Minazuki <shinyoukai@laidback.moe>
This commit is contained in:
parent
1ff613dee6
commit
01bb65f8da
457 changed files with 929 additions and 602 deletions
0
api/funkwhale_api/users/__init__.py
Normal file
0
api/funkwhale_api/users/__init__.py
Normal file
34
api/funkwhale_api/users/adapters.py
Normal file
34
api/funkwhale_api/users/adapters.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from django.conf import settings
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
|
||||
def get_email_context():
|
||||
context = {}
|
||||
context["funkwhale_url"] = settings.FUNQUAIL_URL
|
||||
manager = global_preferences_registry.manager()
|
||||
context["funkwhale_site_name"] = (
|
||||
manager["instance__name"] or settings.FUNQUAIL_HOSTNAME
|
||||
)
|
||||
context["funkwhale_site_domain"] = settings.FUNQUAIL_HOSTNAME
|
||||
return context
|
||||
|
||||
|
||||
class FunQuailAccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request):
|
||||
manager = global_preferences_registry.manager()
|
||||
return manager["users__registration_enabled"]
|
||||
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
context.update(get_email_context())
|
||||
return super().send_mail(template_prefix, email, context)
|
||||
|
||||
def get_login_redirect_url(self, request):
|
||||
return "noop"
|
||||
|
||||
def get_signup_redirect_url(self, request):
|
||||
return "noop"
|
||||
|
||||
def add_message(self, *args, **kwargs):
|
||||
# disable message sending
|
||||
return
|
||||
108
api/funkwhale_api/users/admin.py
Normal file
108
api/funkwhale_api/users/admin.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
from django import forms
|
||||
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
|
||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from funkwhale_api.common import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class MyUserChangeForm(UserChangeForm):
|
||||
class Meta(UserChangeForm.Meta):
|
||||
model = models.User
|
||||
|
||||
|
||||
class MyUserCreationForm(UserCreationForm):
|
||||
error_message = UserCreationForm.error_messages.update(
|
||||
{"duplicate_username": "This username has already been taken."}
|
||||
)
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = models.User
|
||||
|
||||
def clean_username(self):
|
||||
username = self.cleaned_data["username"]
|
||||
try:
|
||||
models.User.objects.get(username=username)
|
||||
except models.User.DoesNotExist:
|
||||
return username
|
||||
raise forms.ValidationError(self.error_messages["duplicate_username"])
|
||||
|
||||
|
||||
def disable(modeladmin, request, queryset):
|
||||
queryset.exclude(pk=request.user.pk).update(is_active=False)
|
||||
|
||||
|
||||
disable.short_description = "Disable login"
|
||||
|
||||
|
||||
def enable(modeladmin, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
|
||||
|
||||
enable.short_description = "Enable login"
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(AuthUserAdmin):
|
||||
form = MyUserChangeForm
|
||||
add_form = MyUserCreationForm
|
||||
list_display = [
|
||||
"username",
|
||||
"email",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
]
|
||||
list_filter = [
|
||||
"is_superuser",
|
||||
"is_staff",
|
||||
"privacy_level",
|
||||
"permission_settings",
|
||||
"permission_library",
|
||||
"permission_moderation",
|
||||
]
|
||||
actions = [disable, enable]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "password", "privacy_level")}),
|
||||
(
|
||||
_("Personal info"),
|
||||
{"fields": ("first_name", "last_name", "email", "avatar")},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"permission_library",
|
||||
"permission_settings",
|
||||
"permission_moderation",
|
||||
"upload_quota",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||
(
|
||||
_("Other"),
|
||||
{
|
||||
"fields": (
|
||||
"instance_support_message_display_date",
|
||||
"funkwhale_support_message_display_date",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Useless fields"), {"fields": ("user_permissions", "groups")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
class InvitationAdmin(admin.ModelAdmin):
|
||||
list_select_related = True
|
||||
list_display = ["owner", "code", "creation_date", "expiration_date"]
|
||||
search_fields = ["owner__username", "code"]
|
||||
readonly_fields = ["expiration_date", "code"]
|
||||
13
api/funkwhale_api/users/api_urls.py
Normal file
13
api/funkwhale_api/users/api_urls.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from django.conf.urls import url
|
||||
|
||||
from funkwhale_api.common import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.OptionalSlashRouter()
|
||||
router.register(r"users", views.UserViewSet, "users")
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^users/login/?$", views.login, name="login"),
|
||||
url(r"^users/logout/?$", views.logout, name="logout"),
|
||||
] + router.urls
|
||||
54
api/funkwhale_api/users/auth_backends.py
Normal file
54
api/funkwhale_api/users/auth_backends.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from allauth.account import auth_backends
|
||||
from django.contrib.auth import backends, get_user_model
|
||||
|
||||
from funkwhale_api.common import authentication
|
||||
|
||||
|
||||
# ugly but allauth doesn't offer an easy way to override the querysets
|
||||
# used to retrieve users, so we monkey patch
|
||||
def decorate_for_auth(func):
|
||||
def inner(*args, **kwargs):
|
||||
qs = func(*args, **kwargs)
|
||||
try:
|
||||
return qs.for_auth()
|
||||
except AttributeError:
|
||||
return (
|
||||
get_user_model()
|
||||
.objects.all()
|
||||
.for_auth()
|
||||
.filter(pk__in=[u.pk for u in qs])
|
||||
)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
auth_backends.filter_users_by_email = decorate_for_auth(
|
||||
auth_backends.filter_users_by_email
|
||||
)
|
||||
auth_backends.filter_users_by_username = decorate_for_auth(
|
||||
auth_backends.filter_users_by_username
|
||||
)
|
||||
|
||||
|
||||
class ModelBackend(backends.ModelBackend):
|
||||
def get_user(self, user_id):
|
||||
"""
|
||||
Select related to avoid two additional queries
|
||||
"""
|
||||
try:
|
||||
user = get_user_model().objects.all().for_auth().get(pk=user_id)
|
||||
except get_user_model().DoesNotExist:
|
||||
return None
|
||||
|
||||
return user if self.user_can_authenticate(user) else None
|
||||
|
||||
def user_can_authenticate(self, user):
|
||||
can_authenticate = super().user_can_authenticate(user)
|
||||
if user.should_verify_email():
|
||||
raise authentication.UnverifiedEmail(user)
|
||||
|
||||
return can_authenticate
|
||||
|
||||
|
||||
class AllAuthBackend(auth_backends.AuthenticationBackend, ModelBackend):
|
||||
pass
|
||||
73
api/funkwhale_api/users/authentication.py
Normal file
73
api/funkwhale_api/users/authentication.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from . import models
|
||||
from .oauth import scopes as available_scopes
|
||||
|
||||
|
||||
def generate_scoped_token(user_id, user_secret, scopes):
|
||||
if set(scopes) & set(available_scopes.SCOPES_BY_ID) != set(scopes):
|
||||
raise ValueError(f"{scopes} contains invalid scopes")
|
||||
|
||||
return signing.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"user_secret": str(user_secret),
|
||||
"scopes": list(sorted(scopes)),
|
||||
},
|
||||
salt="scoped_tokens",
|
||||
)
|
||||
|
||||
|
||||
def authenticate_scoped_token(token):
|
||||
try:
|
||||
payload = signing.loads(
|
||||
token,
|
||||
salt="scoped_tokens",
|
||||
max_age=settings.SCOPED_TOKENS_MAX_AGE,
|
||||
)
|
||||
except signing.BadSignature:
|
||||
raise exceptions.AuthenticationFailed("Invalid token signature")
|
||||
|
||||
try:
|
||||
user_id = int(payload["user_id"])
|
||||
user_secret = str(payload["user_secret"])
|
||||
scopes = list(payload["scopes"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
raise exceptions.AuthenticationFailed("Invalid scoped token payload")
|
||||
|
||||
try:
|
||||
user = (
|
||||
models.User.objects.all()
|
||||
.for_auth()
|
||||
.get(pk=user_id, secret_key=user_secret, is_active=True)
|
||||
)
|
||||
except (models.User.DoesNotExist, ValidationError):
|
||||
raise exceptions.AuthenticationFailed("Invalid user")
|
||||
|
||||
return user, scopes
|
||||
|
||||
|
||||
class ScopedTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""
|
||||
Used when signed token returned by generate_scoped_token are provided via
|
||||
token= in GET requests. Mostly for <audio src=""> urls, since it's not possible
|
||||
to override headers sent by the browser when loading media.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
data = request.GET
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
user, scopes = authenticate_scoped_token(token)
|
||||
except exceptions.AuthenticationFailed:
|
||||
raise exceptions.AuthenticationFailed("Invalid token")
|
||||
|
||||
setattr(request, "scopes", scopes)
|
||||
setattr(request, "actor", user.actor)
|
||||
return user, None
|
||||
40
api/funkwhale_api/users/dynamic_preferences_registry.py
Normal file
40
api/funkwhale_api/users/dynamic_preferences_registry.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.common import preferences as common_preferences
|
||||
|
||||
from . import models
|
||||
|
||||
users = types.Section("users")
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class RegistrationEnabled(types.BooleanPreference):
|
||||
show_in_api = True
|
||||
section = users
|
||||
name = "registration_enabled"
|
||||
default = False
|
||||
verbose_name = "Open registrations to new users"
|
||||
help_text = "When enabled, new users will be able to register on this instance."
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class DefaultPermissions(common_preferences.StringListPreference):
|
||||
show_in_api = True
|
||||
section = users
|
||||
name = "default_permissions"
|
||||
default = []
|
||||
verbose_name = "Default permissions"
|
||||
help_text = "A list of default preferences to give to all registered users."
|
||||
choices = [(k, c["label"]) for k, c in models.PERMISSIONS_CONFIGURATION.items()]
|
||||
field_kwargs = {"choices": choices, "required": False}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class UploadQuota(types.IntPreference):
|
||||
show_in_api = True
|
||||
section = users
|
||||
name = "upload_quota"
|
||||
default = 1000
|
||||
verbose_name = "Upload quota"
|
||||
help_text = "Default upload quota applied to each users, in MB. This can be overridden on a per-user basis."
|
||||
183
api/funkwhale_api/users/factories.py
Normal file
183
api/funkwhale_api/users/factories.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import sys
|
||||
|
||||
import factory
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.factories import ManyToManyFromList, NoUpdateOnCreate, registry
|
||||
|
||||
from . import models
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
else:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
@registry.register
|
||||
class GroupFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
name = factory.Sequence(lambda n: f"group-{n}")
|
||||
|
||||
class Meta:
|
||||
model = "auth.Group"
|
||||
|
||||
@factory.post_generation
|
||||
def perms(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
# Simple build, do nothing.
|
||||
return
|
||||
|
||||
if extracted:
|
||||
perms = [
|
||||
Permission.objects.get(
|
||||
content_type__app_label=p.split(".")[0], codename=p.split(".")[1]
|
||||
)
|
||||
for p in extracted
|
||||
]
|
||||
# A list of permissions were passed in, use them
|
||||
self.permissions.add(*perms)
|
||||
|
||||
|
||||
@registry.register
|
||||
class EmailAddressFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
verified = False
|
||||
primary = True
|
||||
|
||||
class Meta:
|
||||
model = "account.EmailAddress"
|
||||
|
||||
|
||||
@registry.register
|
||||
class EmailConfirmationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
email_address = factory.SubFactory(EmailAddressFactory)
|
||||
|
||||
class Meta:
|
||||
model = "account.EmailConfirmation"
|
||||
|
||||
|
||||
@registry.register
|
||||
class InvitationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
owner = factory.LazyFunction(lambda: UserFactory())
|
||||
|
||||
class Meta:
|
||||
model = "users.Invitation"
|
||||
|
||||
class Params:
|
||||
expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now))
|
||||
with_invited_user = factory.Trait(
|
||||
invited_user=factory.SubFactory("funkwhale_api.users.factories.UserFactory")
|
||||
)
|
||||
|
||||
|
||||
class PasswordSetter(factory.PostGenerationMethodCall):
|
||||
def call(self, instance, step, context):
|
||||
if context.value_provided and context.value is None:
|
||||
# disable setting the password, it's set by hand outside of the factory
|
||||
return
|
||||
|
||||
return super().call(instance, step, context)
|
||||
|
||||
|
||||
@registry.register
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
username = factory.Faker("user_name")
|
||||
email = factory.Faker("email")
|
||||
password = password = PasswordSetter("set_password", "test")
|
||||
subsonic_api_token = None
|
||||
groups = ManyToManyFromList("groups")
|
||||
avatar = factory.django.ImageField()
|
||||
|
||||
class Meta:
|
||||
model = "users.User"
|
||||
django_get_or_create = ("username",)
|
||||
|
||||
class Params:
|
||||
invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
|
||||
|
||||
@factory.post_generation
|
||||
def perms(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
# Simple build, do nothing.
|
||||
return
|
||||
|
||||
if extracted:
|
||||
perms = [
|
||||
Permission.objects.get(
|
||||
content_type__app_label=p.split(".")[0], codename=p.split(".")[1]
|
||||
)
|
||||
for p in extracted
|
||||
]
|
||||
# A list of permissions were passed in, use them
|
||||
self.user_permissions.add(*perms)
|
||||
|
||||
@factory.post_generation
|
||||
def with_actor(self, create, extracted, **kwargs):
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.actor = models.create_actor(self)
|
||||
self.save(update_fields=["actor"])
|
||||
return self.actor
|
||||
|
||||
@factory.post_generation
|
||||
def verified_email(self, create, extracted, **kwargs):
|
||||
if not create or extracted is None:
|
||||
return
|
||||
return EmailConfirmationFactory(
|
||||
email_address__verified=extracted,
|
||||
email_address__email=self.email,
|
||||
email_address__user=self,
|
||||
)
|
||||
|
||||
|
||||
@registry.register(name="users.SuperUser")
|
||||
class SuperUserFactory(UserFactory):
|
||||
is_staff = True
|
||||
is_superuser = True
|
||||
|
||||
|
||||
@registry.register
|
||||
class ApplicationFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.Faker("name")
|
||||
redirect_uris = factory.Faker("url")
|
||||
token = factory.Faker("uuid4")
|
||||
client_type = models.Application.CLIENT_CONFIDENTIAL
|
||||
authorization_grant_type = models.Application.GRANT_AUTHORIZATION_CODE
|
||||
scope = "read"
|
||||
|
||||
class Meta:
|
||||
model = "users.Application"
|
||||
|
||||
|
||||
@registry.register
|
||||
class GrantFactory(factory.django.DjangoModelFactory):
|
||||
application = factory.SubFactory(ApplicationFactory)
|
||||
scope = factory.SelfAttribute(".application.scope")
|
||||
redirect_uri = factory.SelfAttribute(".application.redirect_uris")
|
||||
user = factory.SubFactory(UserFactory)
|
||||
expires = factory.Faker("future_datetime", end_date="+15m")
|
||||
code = factory.Faker("uuid4")
|
||||
|
||||
class Meta:
|
||||
model = "users.Grant"
|
||||
|
||||
|
||||
@registry.register
|
||||
class AccessTokenFactory(factory.django.DjangoModelFactory):
|
||||
application = factory.SubFactory(ApplicationFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
expires = factory.Faker("future_datetime", tzinfo=ZoneInfo("UTC"))
|
||||
token = factory.Faker("uuid4")
|
||||
scope = "read"
|
||||
|
||||
class Meta:
|
||||
model = "users.AccessToken"
|
||||
|
||||
|
||||
@registry.register
|
||||
class RefreshTokenFactory(factory.django.DjangoModelFactory):
|
||||
application = factory.SubFactory(ApplicationFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
token = factory.Faker("uuid4")
|
||||
|
||||
class Meta:
|
||||
model = "users.RefreshToken"
|
||||
9
api/funkwhale_api/users/middleware.py
Normal file
9
api/funkwhale_api/users/middleware.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class RecordActivityMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if hasattr(request, "user") and request.user.is_authenticated:
|
||||
request.user.record_activity()
|
||||
return response
|
||||
137
api/funkwhale_api/users/migrations/0001_initial.py
Normal file
137
api/funkwhale_api/users/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django.utils.timezone
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("auth", "0006_require_contenttypes_0002")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
null=True, verbose_name="last login", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
default=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
max_length=30,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
"^[\\w.@+-]+$",
|
||||
"Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.",
|
||||
"invalid",
|
||||
)
|
||||
],
|
||||
verbose_name="username",
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
max_length=30, verbose_name="first name", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
max_length=30, verbose_name="last name", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
max_length=254, verbose_name="email address", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
default=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
default=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
verbose_name="date joined", default=django.utils.timezone.now
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
related_name="user_set",
|
||||
blank=True,
|
||||
verbose_name="groups",
|
||||
to="auth.Group",
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_query_name="user",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
related_name="user_set",
|
||||
blank=True,
|
||||
verbose_name="user permissions",
|
||||
to="auth.Permission",
|
||||
help_text="Specific permissions for this user.",
|
||||
related_query_name="user",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
max_length=255, verbose_name="Name of User", blank=True
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"abstract": False,
|
||||
"verbose_name_plural": "users",
|
||||
},
|
||||
managers=[("objects", django.contrib.auth.models.UserManager())],
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-12-14 22:05
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name="user",
|
||||
managers=[("objects", django.contrib.auth.models.UserManager())],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="username",
|
||||
field=models.CharField(
|
||||
error_messages={"unique": "A user with that username already exists."},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 2.0 on 2017-12-26 13:57
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0002_auto_20171214_2205")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="secret_key",
|
||||
field=models.UUIDField(default=uuid.uuid4, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="last_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 2.0.2 on 2018-03-01 19:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0003_auto_20171226_1357")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("me", "Only me"),
|
||||
("followers", "Me and my followers"),
|
||||
("instance", "Everyone on my instance, and my followers"),
|
||||
("everyone", "Everyone, including people on other instances"),
|
||||
],
|
||||
default="instance",
|
||||
max_length=30,
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 2.0.3 on 2018-05-08 09:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0004_user_privacy_level")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="subsonic_api_token",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 2.0.4 on 2018-05-17 23:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0005_user_subsonic_api_token")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="permission_federation",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="permission_library",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="permission_settings",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 2.0.4 on 2018-05-24 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0006_auto_20180517_2324")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="permission_upload",
|
||||
field=models.BooleanField(
|
||||
default=False, verbose_name="Upload new content to the library"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="permission_federation",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Follow other instances, accept/deny library follow requests...",
|
||||
verbose_name="Manage library federation",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="permission_library",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Manage library", verbose_name="Manage library"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="permission_settings",
|
||||
field=models.BooleanField(
|
||||
default=False, verbose_name="Manage instance-level settings"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 2.0.6 on 2018-06-17 15:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0007_auto_20180524_2009")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="last_activity",
|
||||
field=models.DateTimeField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="permission_library",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Manage library, delete files, tracks, artists, albums...",
|
||||
verbose_name="Manage library",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Generated by Django 2.0.6 on 2018-06-19 20:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0008_auto_20180617_1531")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Invitation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("expiration_date", models.DateTimeField()),
|
||||
("code", models.CharField(max_length=50, unique=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="invitation",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="users",
|
||||
to="users.Invitation",
|
||||
),
|
||||
),
|
||||
]
|
||||
28
api/funkwhale_api/users/migrations/0010_user_avatar.py
Normal file
28
api/funkwhale_api/users/migrations/0010_user_avatar.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.0.6 on 2018-07-10 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.common.utils
|
||||
import funkwhale_api.common.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0009_auto_20180619_2024")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="avatar",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
max_length=150,
|
||||
null=True,
|
||||
upload_to=funkwhale_api.common.utils.ChunkedPath("users/avatars"),
|
||||
validators=[
|
||||
funkwhale_api.common.validators.ImageDimensionsValidator(
|
||||
max_height=400, max_width=400, min_height=50, min_width=50
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 2.0.7 on 2018-07-21 13:17
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import funkwhale_api.common.utils
|
||||
import funkwhale_api.common.validators
|
||||
import versatileimagefield.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("federation", "0006_auto_20180521_1702"),
|
||||
("users", "0010_user_avatar"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="actor",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="avatar",
|
||||
field=versatileimagefield.fields.VersatileImageField(
|
||||
blank=True,
|
||||
max_length=150,
|
||||
null=True,
|
||||
upload_to=funkwhale_api.common.utils.ChunkedPath(
|
||||
"users/avatars", preserve_file_name=False
|
||||
),
|
||||
validators=[
|
||||
funkwhale_api.common.validators.ImageDimensionsValidator(
|
||||
min_height=50, min_width=50
|
||||
),
|
||||
funkwhale_api.common.validators.FileValidator(
|
||||
allowed_extensions=["png", "jpg", "jpeg", "gif"],
|
||||
max_size=2097152,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
16
api/funkwhale_api/users/migrations/0012_user_upload_quota.py
Normal file
16
api/funkwhale_api/users/migrations/0012_user_upload_quota.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 2.0.7 on 2018-08-01 16:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0011_auto_20180721_1317")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="upload_quota",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 2.0.9 on 2018-12-06 10:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0012_user_upload_quota'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='permission_federation',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='permission_upload',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='permission_moderation',
|
||||
field=models.BooleanField(default=False, help_text='Block/mute/remove domains, users and content', verbose_name='Moderation'),
|
||||
),
|
||||
]
|
||||
195
api/funkwhale_api/users/migrations/0014_oauth.py
Normal file
195
api/funkwhale_api/users/migrations/0014_oauth.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# Generated by Django 2.0.9 on 2018-12-06 10:08
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
import oauth2_provider.generators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0013_auto_20181206_1008"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccessToken",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("expires", models.DateTimeField()),
|
||||
("scope", models.TextField(blank=True)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
("token", models.CharField(max_length=255, unique=True)),
|
||||
],
|
||||
options={"abstract": False},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Application",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_id,
|
||||
max_length=100,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"redirect_uris",
|
||||
models.TextField(
|
||||
blank=True, help_text="Allowed URIs list, space separated"
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("confidential", "Confidential"),
|
||||
("public", "Public"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_grant_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("authorization-code", "Authorization code"),
|
||||
("implicit", "Implicit"),
|
||||
("password", "Resource owner password-based"),
|
||||
("client-credentials", "Client credentials"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_secret",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_secret,
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, max_length=255)),
|
||||
("skip_authorization", models.BooleanField(default=False)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="users_application",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"abstract": False},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Grant",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("code", models.CharField(max_length=255, unique=True)),
|
||||
("expires", models.DateTimeField()),
|
||||
("redirect_uri", models.CharField(max_length=255)),
|
||||
("scope", models.TextField(blank=True)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"application",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="users.Application",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="users_grant",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"abstract": False},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RefreshToken",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("token", models.CharField(max_length=255)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
("revoked", models.DateTimeField(null=True)),
|
||||
(
|
||||
"access_token",
|
||||
models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="refresh_token",
|
||||
to="users.AccessToken",
|
||||
),
|
||||
),
|
||||
(
|
||||
"application",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="users.Application",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="users_refreshtoken",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"abstract": False},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accesstoken",
|
||||
name="application",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="users.Application",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accesstoken",
|
||||
name="source_refresh_token",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="refreshed_access_token",
|
||||
to="users.RefreshToken",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accesstoken",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="users_accesstoken",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="refreshtoken", unique_together={("token", "revoked")}
|
||||
),
|
||||
]
|
||||
18
api/funkwhale_api/users/migrations/0015_application_scope.py
Normal file
18
api/funkwhale_api/users/migrations/0015_application_scope.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.1.7 on 2019-03-18 09:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0014_oauth'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='scope',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Generated by Django 2.2.4 on 2019-09-20 08:57
|
||||
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
import funkwhale_api.users.models
|
||||
|
||||
|
||||
def set_display_date(apps, schema_editor):
|
||||
"""
|
||||
Set display date for instance/funkwhale support message on existing users
|
||||
"""
|
||||
User = apps.get_model("users", "User")
|
||||
now = django.utils.timezone.now()
|
||||
instance_support_message_display_date = now + datetime.timedelta(days=settings.INSTANCE_SUPPORT_MESSAGE_DELAY)
|
||||
funkwhale_support_message_display_date = now + datetime.timedelta(days=settings.FUNQUAIL_SUPPORT_MESSAGE_DELAY)
|
||||
|
||||
User.objects.update(instance_support_message_display_date=instance_support_message_display_date)
|
||||
User.objects.update(funkwhale_support_message_display_date=funkwhale_support_message_display_date)
|
||||
|
||||
|
||||
def rewind(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0015_application_scope'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='funkwhale_support_message_display_date',
|
||||
field=models.DateTimeField(blank=True, null=True, default=funkwhale_api.users.models.get_default_funkwhale_support_message_display_date),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='instance_support_message_display_date',
|
||||
field=models.DateTimeField(blank=True, null=True, default=funkwhale_api.users.models.get_default_instance_support_message_display_date),
|
||||
),
|
||||
migrations.RunPython(set_display_date, rewind),
|
||||
]
|
||||
52
api/funkwhale_api/users/migrations/0017_actor_avatar.py
Normal file
52
api/funkwhale_api/users/migrations/0017_actor_avatar.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_attachments(apps, schema_editor):
|
||||
Actor = apps.get_model("federation", "Actor")
|
||||
User = apps.get_model("users", "User")
|
||||
Attachment = apps.get_model("common", "Attachment")
|
||||
|
||||
obj_attachment_mapping = {}
|
||||
def get_mimetype(path):
|
||||
if path.lower().endswith('.png'):
|
||||
return "image/png"
|
||||
return "image/jpeg"
|
||||
qs = User.objects.filter(actor__attachment_icon=None).exclude(avatar="").exclude(avatar=None).exclude(actor=None).select_related('actor')
|
||||
total = qs.count()
|
||||
print('Creating attachments for {} user avatars, this may take a while…'.format(total))
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
for i, user in enumerate(qs):
|
||||
size = None
|
||||
if isinstance(user.avatar.storage._wrapped, FileSystemStorage):
|
||||
try:
|
||||
size = user.avatar.size
|
||||
except FileNotFoundError:
|
||||
# can occur when file isn't found on disk or S3
|
||||
print(" Warning: avatar file wasn't found in storage: {}".format(e.__class__))
|
||||
obj_attachment_mapping[user.actor] = Attachment(
|
||||
file=user.avatar,
|
||||
size=size,
|
||||
mimetype=get_mimetype(user.avatar.name),
|
||||
)
|
||||
print('Commiting changes…')
|
||||
Attachment.objects.bulk_create(obj_attachment_mapping.values(), batch_size=2000)
|
||||
# map each attachment to the corresponding obj
|
||||
# and bulk save
|
||||
for obj, attachment in obj_attachment_mapping.items():
|
||||
obj.attachment_icon = attachment
|
||||
|
||||
Actor.objects.bulk_update(obj_attachment_mapping.keys(), fields=['attachment_icon'], batch_size=2000)
|
||||
|
||||
|
||||
def rewind(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("users", "0016_auto_20190920_0857"), ("federation", "0024_actor_attachment_icon")]
|
||||
|
||||
operations = [migrations.RunPython(create_attachments, rewind)]
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 3.0.8 on 2020-07-05 08:29
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
import funkwhale_api.users.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0017_actor_avatar'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', funkwhale_api.users.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='settings',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True, blank=True, max_length=50000),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.0.8 on 2020-07-18 07:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0018_auto_20200705_0829'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='grant',
|
||||
name='code_challenge',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='grant',
|
||||
name='code_challenge_method',
|
||||
field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10),
|
||||
),
|
||||
]
|
||||
18
api/funkwhale_api/users/migrations/0020_application_token.py
Normal file
18
api/funkwhale_api/users/migrations/0020_application_token.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.0.8 on 2020-08-19 08:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0019_auto_20200718_0741'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-03 18:10
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0020_application_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='algorithm',
|
||||
field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='grant',
|
||||
name='claims',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='grant',
|
||||
name='nonce',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='application',
|
||||
name='authorization_grant_type',
|
||||
field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='grant',
|
||||
name='redirect_uri',
|
||||
field=models.TextField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='first_name',
|
||||
field=models.CharField(blank=True, max_length=150, verbose_name='first name'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IdToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')),
|
||||
('expires', models.DateTimeField()),
|
||||
('scope', models.TextField(blank=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users_idtoken', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accesstoken',
|
||||
name='id_token',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.13 on 2022-06-27 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0021_auto_20210703_1810'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='settings',
|
||||
field=models.JSONField(blank=True, default=None, max_length=50000, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.2.16 on 2022-11-19 18:19
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
def update_invitation_table(apps, schema_editor):
|
||||
User = apps.get_model("users", "User")
|
||||
Invitation = apps.get_model("users", "Invitation")
|
||||
for user in User.objects.all():
|
||||
if user.invitation:
|
||||
Invitation.objects.filter(id=user.invitation.id).update(invited_user_id=user.id)
|
||||
|
||||
|
||||
def rewind(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0021_auto_20210703_1810'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invitation',
|
||||
name='invited_user',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_invitations', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitation',
|
||||
name='owner',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.RunPython(update_invitation_table, rewind),
|
||||
]
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 3.2.16 on 2022-11-25 19:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0022_alter_user_settings'),
|
||||
('users', '0022_auto_20221119_1819'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
0
api/funkwhale_api/users/migrations/__init__.py
Normal file
0
api/funkwhale_api/users/migrations/__init__.py
Normal file
464
api/funkwhale_api/users/models.py
Normal file
464
api/funkwhale_api/users/models.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import datetime
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as BaseUserManager
|
||||
from django.db import models, transaction
|
||||
from django.db.models import JSONField
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
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
|
||||
from versatileimagefield.fields import VersatileImageField
|
||||
|
||||
from funkwhale_api.common import fields, preferences
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.common import validators as common_validators
|
||||
from funkwhale_api.federation import keys
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
||||
|
||||
def get_token(length=5):
|
||||
wordlist_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "wordlist.txt"
|
||||
)
|
||||
with open(wordlist_path) as f:
|
||||
words = f.readlines()
|
||||
phrase = "".join(random.choice(words) for i in range(length))
|
||||
return phrase.replace("\n", "-").rstrip("-")
|
||||
|
||||
|
||||
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",
|
||||
"read:instance:requests",
|
||||
"write:instance:requests",
|
||||
"read:instance:notes",
|
||||
"write:instance:notes",
|
||||
},
|
||||
},
|
||||
"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())
|
||||
|
||||
|
||||
get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False)
|
||||
|
||||
|
||||
def get_default_instance_support_message_display_date():
|
||||
return timezone.now() + datetime.timedelta(
|
||||
days=settings.INSTANCE_SUPPORT_MESSAGE_DELAY
|
||||
)
|
||||
|
||||
|
||||
def get_default_funkwhale_support_message_display_date():
|
||||
return timezone.now() + datetime.timedelta(
|
||||
days=settings.FUNQUAIL_SUPPORT_MESSAGE_DELAY
|
||||
)
|
||||
|
||||
|
||||
class UserQuerySet(models.QuerySet):
|
||||
def for_auth(self):
|
||||
"""Optimization to avoid additional queries during authentication"""
|
||||
qs = self.select_related("actor__domain")
|
||||
return qs.prefetch_related("plugins", "emailaddress_set")
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
def get_queryset(self):
|
||||
return UserQuerySet(self.model, using=self._db)
|
||||
|
||||
def get_by_natural_key(self, key):
|
||||
obj = BaseUserManager.get_by_natural_key(self.all().for_auth(), key)
|
||||
return obj
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
subsonic_api_token = models.CharField(blank=True, null=True, max_length=255)
|
||||
|
||||
# permissions
|
||||
permission_moderation = models.BooleanField(
|
||||
PERMISSIONS_CONFIGURATION["moderation"]["label"],
|
||||
help_text=PERMISSIONS_CONFIGURATION["moderation"]["help_text"],
|
||||
default=False,
|
||||
)
|
||||
permission_library = models.BooleanField(
|
||||
PERMISSIONS_CONFIGURATION["library"]["label"],
|
||||
help_text=PERMISSIONS_CONFIGURATION["library"]["help_text"],
|
||||
default=False,
|
||||
)
|
||||
permission_settings = models.BooleanField(
|
||||
PERMISSIONS_CONFIGURATION["settings"]["label"],
|
||||
help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"],
|
||||
default=False,
|
||||
)
|
||||
|
||||
last_activity = models.DateTimeField(default=None, null=True, blank=True)
|
||||
|
||||
invitation = models.ForeignKey(
|
||||
"Invitation",
|
||||
related_name="users",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
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,
|
||||
),
|
||||
],
|
||||
)
|
||||
actor = models.OneToOneField(
|
||||
"federation.Actor",
|
||||
related_name="user",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
upload_quota = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
instance_support_message_display_date = models.DateTimeField(
|
||||
default=get_default_instance_support_message_display_date, null=True, blank=True
|
||||
)
|
||||
funkwhale_support_message_display_date = models.DateTimeField(
|
||||
default=get_default_funkwhale_support_message_display_date,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
settings = JSONField(default=None, null=True, blank=True, max_length=50000)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
def get_permissions(self, defaults=None):
|
||||
defaults = defaults or preferences.get("users__default_permissions")
|
||||
perms = {}
|
||||
for p in PERMISSIONS:
|
||||
v = self.is_superuser or getattr(self, f"permission_{p}") or p in defaults
|
||||
perms[p] = v
|
||||
return perms
|
||||
|
||||
@property
|
||||
def all_permissions(self):
|
||||
return self.get_permissions()
|
||||
|
||||
@transaction.atomic
|
||||
def set_settings(self, **settings):
|
||||
u = self.__class__.objects.select_for_update().get(pk=self.pk)
|
||||
if not u.settings:
|
||||
u.settings = {}
|
||||
for key, value in settings.items():
|
||||
u.settings[key] = value
|
||||
u.save(update_fields=["settings"])
|
||||
self.settings = u.settings
|
||||
|
||||
def has_permissions(self, *perms, **kwargs):
|
||||
operator = kwargs.pop("operator", "and")
|
||||
if operator not in ["and", "or"]:
|
||||
raise ValueError(f"Invalid operator {operator}")
|
||||
permissions = self.get_permissions()
|
||||
checker = all if operator == "and" else any
|
||||
return checker([permissions[p] for p in perms])
|
||||
|
||||
def get_absolute_url(self):
|
||||
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):
|
||||
return settings.FUNQUAIL_URL + f"/@{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"])
|
||||
|
||||
def create_actor(self, **kwargs):
|
||||
self.actor = create_actor(self, **kwargs)
|
||||
self.save(update_fields=["actor"])
|
||||
return self.actor
|
||||
|
||||
def get_upload_quota(self):
|
||||
return (
|
||||
self.upload_quota
|
||||
if self.upload_quota is not None
|
||||
else 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,
|
||||
"draft": data["draft"] / 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):
|
||||
groups = ["imports", "inbox"]
|
||||
groups = [f"user.{self.pk}.{g}" for g in groups]
|
||||
|
||||
for permission, value in self.all_permissions.items():
|
||||
if value:
|
||||
groups.append(f"admin.{permission}")
|
||||
|
||||
return groups
|
||||
|
||||
def full_username(self) -> str:
|
||||
return f"{self.username}@{settings.FEDERATION_HOSTNAME}"
|
||||
|
||||
def get_avatar(self):
|
||||
if not self.actor:
|
||||
return
|
||||
return self.actor.attachment_icon
|
||||
|
||||
@property
|
||||
def has_verified_primary_email(self) -> bool:
|
||||
return len(self.emailaddress_set.filter(primary=True, verified=True)) > 0
|
||||
|
||||
def should_verify_email(self):
|
||||
if self.is_superuser:
|
||||
return False
|
||||
has_unverified_email = not self.has_verified_primary_email
|
||||
mandatory_verification = settings.ACCOUNT_EMAIL_VERIFICATION != "optional"
|
||||
return has_unverified_email and mandatory_verification
|
||||
|
||||
|
||||
def generate_code(length=10):
|
||||
return "".join(
|
||||
random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length)
|
||||
)
|
||||
|
||||
|
||||
class InvitationQuerySet(models.QuerySet):
|
||||
def open(self, include=True):
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
invited_user = models.ForeignKey(
|
||||
User, related_name="user_invitations", null=True, 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)
|
||||
|
||||
def set_invited_user(self, user):
|
||||
self.invited_user = user
|
||||
super().save()
|
||||
|
||||
|
||||
class Application(oauth2_models.AbstractApplication):
|
||||
scope = models.TextField(blank=True)
|
||||
token = models.CharField(max_length=50, blank=True, null=True, unique=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
|
||||
|
||||
|
||||
class IdToken(oauth2_models.AbstractIDToken):
|
||||
pass
|
||||
|
||||
|
||||
def get_actor_data(username, **kwargs):
|
||||
slugified_username = federation_utils.slugify_username(username)
|
||||
domain = kwargs.get("domain")
|
||||
if not domain:
|
||||
domain = federation_models.Domain.objects.get_or_create(
|
||||
name=settings.FEDERATION_HOSTNAME
|
||||
)[0]
|
||||
return {
|
||||
"preferred_username": slugified_username,
|
||||
"domain": domain,
|
||||
"type": "Person",
|
||||
"name": kwargs.get("name", username),
|
||||
"summary": kwargs.get("summary"),
|
||||
"manually_approves_followers": False,
|
||||
"fid": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-detail",
|
||||
kwargs={"preferred_username": slugified_username},
|
||||
)
|
||||
),
|
||||
"shared_inbox_url": federation_models.get_shared_inbox_url(),
|
||||
"inbox_url": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-inbox",
|
||||
kwargs={"preferred_username": slugified_username},
|
||||
)
|
||||
),
|
||||
"outbox_url": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-outbox",
|
||||
kwargs={"preferred_username": slugified_username},
|
||||
)
|
||||
),
|
||||
"followers_url": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-followers",
|
||||
kwargs={"preferred_username": slugified_username},
|
||||
)
|
||||
),
|
||||
"following_url": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-following",
|
||||
kwargs={"preferred_username": slugified_username},
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_actor(user, **kwargs):
|
||||
args = get_actor_data(user.username)
|
||||
args.update(kwargs)
|
||||
private, public = keys.get_key_pair()
|
||||
args["private_key"] = private.decode("utf-8")
|
||||
args["public_key"] = public.decode("utf-8")
|
||||
|
||||
return federation_models.Actor.objects.create(user=user, **args)
|
||||
|
||||
|
||||
@receiver(ldap_populate_user)
|
||||
def init_ldap_user(sender, user, ldap_user, **kwargs):
|
||||
if not user.actor:
|
||||
user.actor = create_actor(user)
|
||||
39
api/funkwhale_api/users/mutations.py
Normal file
39
api/funkwhale_api/users/mutations.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from funkwhale_api.common import mutations, utils
|
||||
from funkwhale_api.federation import models
|
||||
|
||||
from . import tasks
|
||||
|
||||
|
||||
@mutations.registry.connect("delete_account", models.Actor)
|
||||
class DeleteAccountMutationSerializer(mutations.MutationSerializer):
|
||||
@transaction.atomic
|
||||
def apply(self, obj, validated_data):
|
||||
if not obj.is_local or not obj.user:
|
||||
raise mutations.serializers.ValidationError("Cannot delete this account")
|
||||
|
||||
# delete oauth apps / reset all passwords immediately
|
||||
obj.user.set_unusable_password()
|
||||
obj.user.subsonic_api_token = None
|
||||
# force logout
|
||||
obj.user.secret_key = uuid.uuid4()
|
||||
obj.user.users_grant.all().delete()
|
||||
obj.user.users_accesstoken.all().delete()
|
||||
obj.user.users_refreshtoken.all().delete()
|
||||
obj.user.save()
|
||||
|
||||
# since the deletion of related object/message sending can take a long time
|
||||
# we do that in a separate tasks
|
||||
utils.on_commit(tasks.delete_account.delay, user_id=obj.user.id)
|
||||
|
||||
def get_previous_state(self, obj, validated_data):
|
||||
"""
|
||||
We store usernames and ids for auditability purposes
|
||||
"""
|
||||
return {
|
||||
"user": {"username": obj.user.username, "id": obj.user.pk},
|
||||
"actor": {"preferred_username": obj.preferred_username},
|
||||
}
|
||||
0
api/funkwhale_api/users/oauth/__init__.py
Normal file
0
api/funkwhale_api/users/oauth/__init__.py
Normal file
121
api/funkwhale_api/users/oauth/permissions.py
Normal file
121
api/funkwhale_api/users/oauth/permissions.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from rest_framework import permissions
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
|
||||
from .. import models
|
||||
from . import scopes
|
||||
|
||||
|
||||
def normalize(*scope_ids):
|
||||
"""
|
||||
Given an iterable containing scopes ids such as {read, write:playlists}
|
||||
will return a set containing all the leaf scopes (and no parent scopes)
|
||||
"""
|
||||
final = set()
|
||||
for scope_id in scope_ids:
|
||||
try:
|
||||
scope_obj = scopes.SCOPES_BY_ID[scope_id]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if scope_obj.children:
|
||||
final = final | {s.id for s in scope_obj.children}
|
||||
else:
|
||||
final.add(scope_obj.id)
|
||||
return final
|
||||
|
||||
|
||||
def should_allow(required_scope, request_scopes):
|
||||
if not required_scope:
|
||||
return True
|
||||
|
||||
if not request_scopes:
|
||||
return False
|
||||
|
||||
return required_scope in normalize(*request_scopes)
|
||||
|
||||
|
||||
METHOD_SCOPE_MAPPING = {
|
||||
"get": "read",
|
||||
"post": "write",
|
||||
"patch": "write",
|
||||
"put": "write",
|
||||
"delete": "write",
|
||||
}
|
||||
|
||||
|
||||
class ScopePermission(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.method.lower() in ["options", "head"]:
|
||||
return True
|
||||
|
||||
scope_config = getattr(view, "required_scope", "noopscope")
|
||||
anonymous_policy = getattr(view, "anonymous_policy", False)
|
||||
if anonymous_policy not in [True, False, "setting"]:
|
||||
raise ImproperlyConfigured(
|
||||
f"{anonymous_policy} is not a valid value for anonymous_policy"
|
||||
)
|
||||
if isinstance(scope_config, str):
|
||||
scope_config = {
|
||||
"read": f"read:{scope_config}",
|
||||
"write": f"write:{scope_config}",
|
||||
}
|
||||
action = METHOD_SCOPE_MAPPING[request.method.lower()]
|
||||
required_scope = scope_config[action]
|
||||
else:
|
||||
# we have a dict with explicit viewset actions / scopes
|
||||
required_scope = scope_config[view.action]
|
||||
|
||||
token = request.auth
|
||||
|
||||
if isinstance(token, models.AccessToken):
|
||||
return self.has_permission_token(token, required_scope)
|
||||
elif getattr(request, "scopes", None):
|
||||
return should_allow(
|
||||
required_scope=required_scope, request_scopes=set(request.scopes)
|
||||
)
|
||||
elif request.user.is_authenticated:
|
||||
user_scopes = scopes.get_from_permissions(**request.user.get_permissions())
|
||||
return should_allow(
|
||||
required_scope=required_scope, request_scopes=user_scopes
|
||||
)
|
||||
elif hasattr(request, "actor") and request.actor:
|
||||
# we use default anonymous scopes
|
||||
user_scopes = scopes.FEDERATION_REQUEST_SCOPES
|
||||
return should_allow(
|
||||
required_scope=required_scope, request_scopes=user_scopes
|
||||
)
|
||||
else:
|
||||
if anonymous_policy is False:
|
||||
return False
|
||||
if anonymous_policy == "setting" and preferences.get(
|
||||
"common__api_authentication_required"
|
||||
):
|
||||
return False
|
||||
|
||||
user_scopes = (
|
||||
getattr(view, "anonymous_scopes", set()) | scopes.ANONYMOUS_SCOPES
|
||||
)
|
||||
return should_allow(
|
||||
required_scope=required_scope, request_scopes=user_scopes
|
||||
)
|
||||
|
||||
def has_permission_token(self, token, required_scope):
|
||||
if token.is_expired():
|
||||
return False
|
||||
|
||||
if not token.user:
|
||||
return False
|
||||
|
||||
user = token.user
|
||||
user_scopes = scopes.get_from_permissions(**user.get_permissions())
|
||||
token_scopes = set(token.scopes.keys())
|
||||
final_scopes = (
|
||||
user_scopes
|
||||
& normalize(*token_scopes)
|
||||
& token.application.normalized_scopes
|
||||
& scopes.OAUTH_APP_SCOPES
|
||||
)
|
||||
|
||||
return should_allow(required_scope=required_scope, request_scopes=final_scopes)
|
||||
105
api/funkwhale_api/users/oauth/scopes.py
Normal file
105
api/funkwhale_api/users/oauth/scopes.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
class Scope:
|
||||
def __init__(self, id, label="", children=None):
|
||||
self.id = id
|
||||
self.label = ""
|
||||
self.children = children or []
|
||||
|
||||
def copy(self, prefix):
|
||||
return Scope(f"{prefix}:{self.id}")
|
||||
|
||||
|
||||
BASE_SCOPES = [
|
||||
Scope(
|
||||
"profile", "Access profile data (e-mail, username, avatar, subsonic password…)"
|
||||
),
|
||||
Scope("libraries", "Access uploads, libraries, and audio metadata"),
|
||||
Scope("edits", "Browse and submit edits on audio metadata"),
|
||||
Scope("follows", "Access library follows"),
|
||||
Scope("favorites", "Access favorites"),
|
||||
Scope("filters", "Access content filters"),
|
||||
Scope("listenings", "Access listening history"),
|
||||
Scope("radios", "Access radios"),
|
||||
Scope("playlists", "Access playlists"),
|
||||
Scope("notifications", "Access personal notifications"),
|
||||
Scope("security", "Access security settings"),
|
||||
Scope("reports", "Access reports"),
|
||||
Scope("plugins", "Access plugins"),
|
||||
# Privileged scopes that require specific user permissions
|
||||
Scope("instance:settings", "Access instance settings"),
|
||||
Scope("instance:users", "Access local user accounts"),
|
||||
Scope("instance:invitations", "Access invitations"),
|
||||
Scope("instance:edits", "Access instance metadata edits"),
|
||||
Scope(
|
||||
"instance:libraries", "Access instance uploads, libraries and audio metadata"
|
||||
),
|
||||
Scope("instance:accounts", "Access instance federated accounts"),
|
||||
Scope("instance:domains", "Access instance domains"),
|
||||
Scope("instance:policies", "Access instance moderation policies"),
|
||||
Scope("instance:reports", "Access instance moderation reports"),
|
||||
Scope("instance:requests", "Access instance moderation requests"),
|
||||
Scope("instance:notes", "Access instance moderation notes"),
|
||||
]
|
||||
SCOPES = [
|
||||
Scope("read", children=[s.copy("read") for s in BASE_SCOPES]),
|
||||
Scope("write", children=[s.copy("write") for s in BASE_SCOPES]),
|
||||
]
|
||||
|
||||
|
||||
def flatten(*scopes):
|
||||
for scope in scopes:
|
||||
yield scope
|
||||
yield from flatten(*scope.children)
|
||||
|
||||
|
||||
SCOPES_BY_ID = {s.id: s for s in flatten(*SCOPES)}
|
||||
|
||||
FEDERATION_REQUEST_SCOPES = {"read:libraries"}
|
||||
ANONYMOUS_SCOPES = {
|
||||
"read:libraries",
|
||||
"read:playlists",
|
||||
"read:listenings",
|
||||
"read:favorites",
|
||||
"read:radios",
|
||||
"read:edits",
|
||||
}
|
||||
|
||||
COMMON_SCOPES = ANONYMOUS_SCOPES | {
|
||||
"read:profile",
|
||||
"write:profile",
|
||||
"write:libraries",
|
||||
"write:playlists",
|
||||
"read:follows",
|
||||
"write:follows",
|
||||
"write:favorites",
|
||||
"read:notifications",
|
||||
"write:notifications",
|
||||
"write:radios",
|
||||
"write:edits",
|
||||
"read:filters",
|
||||
"write:filters",
|
||||
"read:reports",
|
||||
"write:reports",
|
||||
"write:listenings",
|
||||
}
|
||||
|
||||
LOGGED_IN_SCOPES = COMMON_SCOPES | {
|
||||
"read:security",
|
||||
"write:security",
|
||||
"read:plugins",
|
||||
"write:plugins",
|
||||
}
|
||||
|
||||
# We don't allow admin access for oauth apps yet
|
||||
OAUTH_APP_SCOPES = COMMON_SCOPES
|
||||
|
||||
|
||||
def get_from_permissions(**permissions):
|
||||
from funkwhale_api.users import models
|
||||
|
||||
final = LOGGED_IN_SCOPES
|
||||
for permission_name, value in permissions.items():
|
||||
if not value:
|
||||
continue
|
||||
config = models.PERMISSIONS_CONFIGURATION[permission_name]
|
||||
final = final | config["scopes"]
|
||||
return final
|
||||
41
api/funkwhale_api/users/oauth/serializers.py
Normal file
41
api/funkwhale_api/users/oauth/serializers.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .. import models
|
||||
|
||||
|
||||
class ApplicationSerializer(serializers.ModelSerializer):
|
||||
scopes = serializers.CharField(source="scope")
|
||||
|
||||
class Meta:
|
||||
model = models.Application
|
||||
fields = ["client_id", "name", "scopes", "created", "updated"]
|
||||
|
||||
def to_representation(self, obj):
|
||||
repr = super().to_representation(obj)
|
||||
if obj.user_id:
|
||||
repr["token"] = obj.token
|
||||
return repr
|
||||
|
||||
|
||||
class CreateApplicationSerializer(serializers.ModelSerializer):
|
||||
name = serializers.CharField(required=True, max_length=255)
|
||||
scopes = serializers.CharField(source="scope", default="read")
|
||||
|
||||
class Meta:
|
||||
model = models.Application
|
||||
fields = [
|
||||
"client_id",
|
||||
"name",
|
||||
"scopes",
|
||||
"client_secret",
|
||||
"created",
|
||||
"updated",
|
||||
"redirect_uris",
|
||||
]
|
||||
read_only_fields = ["client_id", "created", "updated"]
|
||||
|
||||
def to_representation(self, obj):
|
||||
repr = super().to_representation(obj)
|
||||
if obj.user_id:
|
||||
repr["token"] = obj.token
|
||||
return repr
|
||||
41
api/funkwhale_api/users/oauth/server.py
Normal file
41
api/funkwhale_api/users/oauth/server.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import urllib.parse
|
||||
|
||||
import oauthlib.oauth2
|
||||
|
||||
from funkwhale_api.common import authentication
|
||||
|
||||
|
||||
def check(request):
|
||||
user = request.user
|
||||
request.user = user.__class__.objects.all().for_auth().get(pk=user.pk)
|
||||
if request.user.should_verify_email():
|
||||
raise authentication.UnverifiedEmail(user)
|
||||
return True
|
||||
|
||||
|
||||
class OAuth2Server(oauthlib.oauth2.Server):
|
||||
def verify_request(self, uri, *args, **kwargs):
|
||||
valid, request = super().verify_request(uri, *args, **kwargs)
|
||||
if valid:
|
||||
if not check(request):
|
||||
return False, request
|
||||
return valid, request
|
||||
|
||||
# maybe the token was given in the querystring?
|
||||
query = urllib.parse.urlparse(request.uri).query
|
||||
token = None
|
||||
if query:
|
||||
parsed_qs = urllib.parse.parse_qs(query)
|
||||
token = parsed_qs.get("token", [])
|
||||
if len(token) > 0:
|
||||
token = token[0]
|
||||
|
||||
if token:
|
||||
valid = self.request_validator.validate_bearer_token(
|
||||
token, request.scopes, request
|
||||
)
|
||||
if valid:
|
||||
if not check(request):
|
||||
return False, request
|
||||
|
||||
return valid, request
|
||||
8
api/funkwhale_api/users/oauth/tasks.py
Normal file
8
api/funkwhale_api/users/oauth/tasks.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from oauth2_provider import models as oauth2_models
|
||||
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
|
||||
@celery.app.task(name="oauth.clear_expired_tokens")
|
||||
def clear_expired_tokens():
|
||||
oauth2_models.clear_expired()
|
||||
16
api/funkwhale_api/users/oauth/urls.py
Normal file
16
api/funkwhale_api/users/oauth/urls.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.conf.urls import url
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from funkwhale_api.common import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.OptionalSlashRouter()
|
||||
router.register(r"apps", views.ApplicationViewSet, "apps")
|
||||
router.register(r"grants", views.GrantViewSet, "grants")
|
||||
|
||||
urlpatterns = router.urls + [
|
||||
url("^authorize/?$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"),
|
||||
url("^token/?$", views.TokenView.as_view(), name="token"),
|
||||
url("^revoke/?$", views.RevokeTokenView.as_view(), name="revoke"),
|
||||
]
|
||||
238
api/funkwhale_api/users/oauth/views.py
Normal file
238
api/funkwhale_api/users/oauth/views.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import json
|
||||
import secrets
|
||||
import urllib.parse
|
||||
|
||||
from django import http
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from oauth2_provider import exceptions as oauth2_exceptions
|
||||
from oauth2_provider import views as oauth_views
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
from rest_framework import mixins, permissions, response, views, viewsets
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from funkwhale_api.common import throttling
|
||||
|
||||
from .. import models
|
||||
from . import serializers
|
||||
from .permissions import ScopePermission
|
||||
|
||||
|
||||
class ApplicationViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
anonymous_policy = True
|
||||
required_scope = {
|
||||
"retrieve": None,
|
||||
"create": None,
|
||||
"destroy": "write:security",
|
||||
"update": "write:security",
|
||||
"partial_update": "write:security",
|
||||
"refresh_token": "write:security",
|
||||
"list": "read:security",
|
||||
}
|
||||
lookup_field = "client_id"
|
||||
queryset = models.Application.objects.all().order_by("-created")
|
||||
serializer_class = serializers.ApplicationSerializer
|
||||
throttling_scopes = {
|
||||
"create": {
|
||||
"anonymous": "anonymous-oauth-app",
|
||||
"authenticated": "authenticated-oauth-app",
|
||||
}
|
||||
}
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
request_data = request.data.copy()
|
||||
secret = secrets.token_hex(64)
|
||||
request_data["client_secret"] = secret
|
||||
serializer = self.get_serializer(data=request_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
data = serializer.data
|
||||
# Since the serializer returns a hashed secret, we need to override it for the response.
|
||||
data["client_secret"] = secret
|
||||
return response.Response(data, status=201, headers=headers)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method.lower() == "post":
|
||||
return serializers.CreateApplicationSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save(
|
||||
client_type=models.Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE,
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
token=models.get_token() if self.request.user.is_authenticated else None,
|
||||
)
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
serializer_class = self.get_serializer_class()
|
||||
try:
|
||||
owned = args[0].user == self.request.user
|
||||
except (IndexError, AttributeError):
|
||||
owned = False
|
||||
if owned:
|
||||
serializer_class = serializers.CreateApplicationSerializer
|
||||
|
||||
kwargs["context"] = self.get_serializer_context()
|
||||
return serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.action in [
|
||||
"list",
|
||||
"destroy",
|
||||
"update",
|
||||
"partial_update",
|
||||
"refresh_token",
|
||||
]:
|
||||
qs = qs.filter(user=self.request.user)
|
||||
return qs
|
||||
|
||||
@extend_schema(operation_id="refresh_oauth_token")
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_name="refresh_token",
|
||||
url_path="refresh-token",
|
||||
)
|
||||
def refresh_token(self, request, *args, **kwargs):
|
||||
app = self.get_object()
|
||||
if not app.user_id or request.user != app.user:
|
||||
return response.Response(status=404)
|
||||
app.token = models.get_token()
|
||||
app.save(update_fields=["token"])
|
||||
serializer = serializers.CreateApplicationSerializer(app)
|
||||
return response.Response(serializer.data, status=200)
|
||||
|
||||
|
||||
class GrantViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
This is a viewset that list applications that have access to the request user
|
||||
account, to allow revoking tokens easily.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, ScopePermission]
|
||||
required_scope = "security"
|
||||
lookup_field = "client_id"
|
||||
queryset = models.Application.objects.all().order_by("-created")
|
||||
serializer_class = serializers.ApplicationSerializer
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
now = timezone.now()
|
||||
queryset = super().get_queryset()
|
||||
grants = models.Grant.objects.filter(user=self.request.user, expires__gt=now)
|
||||
access_tokens = models.AccessToken.objects.filter(user=self.request.user)
|
||||
refresh_tokens = models.RefreshToken.objects.filter(
|
||||
user=self.request.user, revoked=None
|
||||
)
|
||||
|
||||
return queryset.filter(
|
||||
Q(pk__in=access_tokens.values("application"))
|
||||
| Q(pk__in=refresh_tokens.values("application"))
|
||||
| Q(pk__in=grants.values("application"))
|
||||
).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save(
|
||||
client_type=models.Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
application = instance
|
||||
|
||||
access_tokens = application.accesstoken_set.filter(user=self.request.user)
|
||||
for token in access_tokens:
|
||||
token.revoke()
|
||||
|
||||
refresh_tokens = application.refreshtoken_set.filter(user=self.request.user)
|
||||
for token in refresh_tokens:
|
||||
try:
|
||||
token.revoke()
|
||||
except models.AccessToken.DoesNotExist:
|
||||
token.access_token = None
|
||||
token.revoked = timezone.now()
|
||||
token.save(update_fields=["access_token", "revoked"])
|
||||
grants = application.grant_set.filter(user=self.request.user)
|
||||
grants.delete()
|
||||
|
||||
|
||||
class AuthorizeView(views.APIView, oauth_views.AuthorizationView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
server_class = oauth2_settings.OAUTH2_SERVER_CLASS
|
||||
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
|
||||
oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS
|
||||
skip_authorization_completely = False
|
||||
oauth2_data = {}
|
||||
|
||||
def form_invalid(self, form):
|
||||
"""
|
||||
Return a JSON response instead of a template one
|
||||
"""
|
||||
errors = form.errors
|
||||
|
||||
return self.json_payload(errors, status_code=400)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
throttling.check_request(request, "oauth-authorize")
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
return super().form_valid(form)
|
||||
|
||||
except models.Application.DoesNotExist:
|
||||
return self.json_payload({"non_field_errors": ["Invalid application"]}, 400)
|
||||
|
||||
def redirect(self, redirect_to, application):
|
||||
if self.request.is_ajax():
|
||||
# Web client need this to be able to redirect the user
|
||||
query = urllib.parse.urlparse(redirect_to).query
|
||||
code = urllib.parse.parse_qs(query)["code"][0]
|
||||
return self.json_payload(
|
||||
{"redirect_uri": redirect_to, "code": code}, status_code=200
|
||||
)
|
||||
|
||||
return super().redirect(redirect_to, application)
|
||||
|
||||
def error_response(self, error, application):
|
||||
if isinstance(error, oauth2_exceptions.FatalClientError):
|
||||
return self.json_payload({"detail": error.oauthlib_error.description}, 400)
|
||||
return super().error_response(error, application)
|
||||
|
||||
def json_payload(self, payload, status_code):
|
||||
return http.HttpResponse(
|
||||
json.dumps(payload), status=status_code, content_type="application/json"
|
||||
)
|
||||
|
||||
def handle_no_permission(self):
|
||||
return self.json_payload(
|
||||
{"detail": "Authentication credentials were not provided."}, 401
|
||||
)
|
||||
|
||||
|
||||
class TokenView(oauth_views.TokenView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
throttling.check_request(request, "oauth-token")
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class RevokeTokenView(oauth_views.RevokeTokenView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
throttling.check_request(request, "oauth-revoke-token")
|
||||
return super().post(request, *args, **kwargs)
|
||||
55
api/funkwhale_api/users/rest_auth_urls.py
Normal file
55
api/funkwhale_api/users/rest_auth_urls.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from dj_rest_auth import views as rest_auth_views
|
||||
from django.conf.urls import url
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# URLs that do not require a session or valid token
|
||||
url(
|
||||
r"^password/reset/?$",
|
||||
views.PasswordResetView.as_view(),
|
||||
name="rest_password_reset",
|
||||
),
|
||||
url(
|
||||
r"^password/reset/confirm/?$",
|
||||
views.PasswordResetConfirmView.as_view(),
|
||||
name="rest_password_reset_confirm",
|
||||
),
|
||||
# URLs that require a user to be logged in with a valid session / token.
|
||||
url(
|
||||
r"^user/?$", rest_auth_views.UserDetailsView.as_view(), name="rest_user_details"
|
||||
),
|
||||
url(
|
||||
r"^password/change/?$",
|
||||
views.PasswordChangeView.as_view(),
|
||||
name="rest_password_change",
|
||||
),
|
||||
# Registration URLs
|
||||
url(r"^registration/?$", views.RegisterView.as_view(), name="rest_register"),
|
||||
url(
|
||||
r"^registration/verify-email/?$",
|
||||
views.VerifyEmailView.as_view(),
|
||||
name="rest_verify_email",
|
||||
),
|
||||
url(
|
||||
r"^registration/change-password/?$",
|
||||
views.PasswordChangeView.as_view(),
|
||||
name="change_password",
|
||||
),
|
||||
# This url is used by django-allauth and empty TemplateView is
|
||||
# defined just to allow reverse() call inside app, for example when e-mail
|
||||
# with verification link is being sent, then it's required to render e-mail
|
||||
# content.
|
||||
# account_confirm_email - You should override this view to handle it in
|
||||
# your API client somehow and then, send post to /verify-email/ endpoint
|
||||
# with proper key.
|
||||
# If you don't want to use API on that step, then just use ConfirmEmailView
|
||||
# view from:
|
||||
# https://github.com/pennersr/django-allauth/blob/a62a370681/allauth/account/views.py#L291
|
||||
url(
|
||||
r"^registration/account-confirm-email/(?P<key>\w+)/?$",
|
||||
TemplateView.as_view(),
|
||||
name="account_confirm_email",
|
||||
),
|
||||
]
|
||||
343
api/funkwhale_api/users/serializers.py
Normal file
343
api/funkwhale_api/users/serializers.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import re
|
||||
|
||||
from allauth.account import models as allauth_models
|
||||
from dj_rest_auth.registration.serializers import RegisterSerializer as RS
|
||||
from dj_rest_auth.registration.serializers import get_adapter
|
||||
from dj_rest_auth.serializers import PasswordResetConfirmSerializer as PRCS
|
||||
from dj_rest_auth.serializers import PasswordResetSerializer as PRS
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.forms import PasswordResetForm
|
||||
from django.core import validators
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.common import models as common_models
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.moderation import models as moderation_models
|
||||
from funkwhale_api.moderation import tasks as moderation_tasks
|
||||
from funkwhale_api.moderation import utils as moderation_utils
|
||||
|
||||
from . import adapters
|
||||
from . import authentication as users_authentication
|
||||
from . import models
|
||||
|
||||
|
||||
@deconstructible
|
||||
class ASCIIUsernameValidator(validators.RegexValidator):
|
||||
regex = r"^[\w]+$"
|
||||
message = _(
|
||||
"Enter a valid username. This value may contain only English letters, "
|
||||
"numbers, and _ characters."
|
||||
)
|
||||
flags = re.ASCII
|
||||
|
||||
|
||||
username_validators = [ASCIIUsernameValidator()]
|
||||
NOOP = object()
|
||||
|
||||
|
||||
class RegisterSerializer(RS):
|
||||
invitation = serializers.CharField(
|
||||
required=False, allow_null=True, allow_blank=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.approval_enabled = preferences.get("moderation__signup_approval_enabled")
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.approval_enabled:
|
||||
customization = preferences.get("moderation__signup_form_customization")
|
||||
self.fields[
|
||||
"request_fields"
|
||||
] = moderation_utils.get_signup_form_additional_fields_serializer(
|
||||
customization
|
||||
)
|
||||
|
||||
def validate_invitation(self, value):
|
||||
if not value:
|
||||
return
|
||||
|
||||
try:
|
||||
return models.Invitation.objects.open().get(code__iexact=value)
|
||||
except models.Invitation.DoesNotExist:
|
||||
raise serializers.ValidationError("Invalid invitation code")
|
||||
|
||||
def validate(self, validated_data):
|
||||
data = super().validate(validated_data)
|
||||
# we create a fake user obj with validated data so we can validate
|
||||
# password properly (we have a password validator that requires
|
||||
# a user object)
|
||||
user = models.User(username=data["username"], email=data["email"])
|
||||
get_adapter().clean_password(data["password1"], user)
|
||||
return data
|
||||
|
||||
def validate_username(self, value):
|
||||
username = super().validate_username(value)
|
||||
duplicates = federation_models.Actor.objects.local().filter(
|
||||
preferred_username__iexact=username
|
||||
)
|
||||
if duplicates.exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with that username already exists."
|
||||
)
|
||||
return username
|
||||
|
||||
def save(self, request):
|
||||
user = super().save(request)
|
||||
update_fields = ["actor"]
|
||||
user.actor = models.create_actor(user)
|
||||
user_request = None
|
||||
if self.approval_enabled:
|
||||
# manually approve users
|
||||
user.is_active = False
|
||||
user_request = moderation_models.UserRequest.objects.create(
|
||||
submitter=user.actor,
|
||||
type="signup",
|
||||
metadata=self.validated_data.get("request_fields", None) or None,
|
||||
)
|
||||
update_fields.append("is_active")
|
||||
if self.validated_data.get("invitation"):
|
||||
user.invitation = self.validated_data.get("invitation")
|
||||
update_fields.append("invitation")
|
||||
user.save(update_fields=update_fields)
|
||||
if user_request:
|
||||
common_utils.on_commit(
|
||||
moderation_tasks.user_request_handle.delay,
|
||||
user_request_id=user_request.pk,
|
||||
new_status=user_request.status,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class UserActivitySerializer(activity_serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
name = serializers.CharField(source="username")
|
||||
local_id = serializers.CharField(source="username")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "local_id", "name", "type"]
|
||||
|
||||
def get_type(self, obj):
|
||||
return "Person"
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
avatar = common_serializers.AttachmentSerializer(
|
||||
source="get_avatar", allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "username", "name", "date_joined", "avatar"]
|
||||
|
||||
|
||||
class UserWriteSerializer(serializers.ModelSerializer):
|
||||
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
|
||||
avatar = common_serializers.RelatedField(
|
||||
"uuid",
|
||||
queryset=common_models.Attachment.objects.all().local().attached(False),
|
||||
serializer=None,
|
||||
queryset_filter=lambda qs, context: qs.filter(
|
||||
actor=context["request"].user.actor
|
||||
),
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"name",
|
||||
"privacy_level",
|
||||
"avatar",
|
||||
"instance_support_message_display_date",
|
||||
"funkwhale_support_message_display_date",
|
||||
"summary",
|
||||
]
|
||||
|
||||
def update(self, obj, validated_data):
|
||||
if not obj.actor:
|
||||
obj.create_actor()
|
||||
summary = validated_data.pop("summary", NOOP)
|
||||
avatar = validated_data.pop("avatar", NOOP)
|
||||
|
||||
obj = super().update(obj, validated_data)
|
||||
|
||||
if summary != NOOP:
|
||||
common_utils.attach_content(obj.actor, "summary_obj", summary)
|
||||
if avatar != NOOP:
|
||||
obj.actor.attachment_icon = avatar
|
||||
obj.actor.save(update_fields=["attachment_icon"])
|
||||
return obj
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
r["avatar"] = common_serializers.AttachmentSerializer(
|
||||
instance.get_avatar()
|
||||
).data
|
||||
return r
|
||||
|
||||
|
||||
class UserReadSerializer(serializers.ModelSerializer):
|
||||
permissions = serializers.SerializerMethodField()
|
||||
full_username = serializers.SerializerMethodField()
|
||||
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"full_username",
|
||||
"name",
|
||||
"email",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"permissions",
|
||||
"date_joined",
|
||||
"privacy_level",
|
||||
"avatar",
|
||||
]
|
||||
|
||||
def get_permissions(self, o):
|
||||
return o.get_permissions()
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_full_username(self, o):
|
||||
if o.actor:
|
||||
return o.actor.full_username
|
||||
|
||||
|
||||
class MeSerializer(UserReadSerializer):
|
||||
quota_status = serializers.SerializerMethodField()
|
||||
summary = serializers.SerializerMethodField()
|
||||
tokens = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(UserReadSerializer.Meta):
|
||||
fields = UserReadSerializer.Meta.fields + [
|
||||
"quota_status",
|
||||
"instance_support_message_display_date",
|
||||
"funkwhale_support_message_display_date",
|
||||
"summary",
|
||||
"tokens",
|
||||
"settings",
|
||||
]
|
||||
|
||||
def get_quota_status(self, o):
|
||||
return o.get_quota_status() if o.actor else 0
|
||||
|
||||
def get_summary(self, o):
|
||||
if not o.actor or not o.actor.summary_obj:
|
||||
return
|
||||
return common_serializers.ContentSerializer(o.actor.summary_obj).data
|
||||
|
||||
def get_tokens(self, o):
|
||||
return {
|
||||
"listen": users_authentication.generate_scoped_token(
|
||||
user_id=o.pk, user_secret=o.secret_key, scopes=["read:libraries"]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class PasswordResetSerializer(PRS):
|
||||
password_reset_form_class = PasswordResetForm
|
||||
|
||||
def get_email_options(self):
|
||||
return {"extra_email_context": adapters.get_email_context()}
|
||||
|
||||
|
||||
class PasswordResetConfirmSerializer(PRCS):
|
||||
def validate(self, attrs):
|
||||
from allauth.account.forms import default_token_generator
|
||||
from django.utils.http import urlsafe_base64_decode as uid_decoder
|
||||
|
||||
UserModel = auth.get_user_model()
|
||||
# Decode the uidb64 (allauth use base36) to uid to get User object
|
||||
try:
|
||||
uid = force_str(uid_decoder(attrs["uid"]))
|
||||
self.user = UserModel._default_manager.get(pk=uid)
|
||||
except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist):
|
||||
raise ValidationError({"uid": [_("Invalid value")]})
|
||||
|
||||
if not default_token_generator.check_token(self.user, attrs["token"]):
|
||||
raise ValidationError({"token": [_("Invalid value")]})
|
||||
|
||||
self.custom_validation(attrs)
|
||||
# Construct SetPasswordForm instance
|
||||
self.set_password_form = self.set_password_form_class(
|
||||
user=self.user,
|
||||
data=attrs,
|
||||
)
|
||||
if not self.set_password_form.is_valid():
|
||||
raise serializers.ValidationError(self.set_password_form.errors)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class UserDeleteSerializer(serializers.Serializer):
|
||||
password = serializers.CharField()
|
||||
confirm = serializers.BooleanField()
|
||||
|
||||
def validate_password(self, value):
|
||||
if not self.instance.check_password(value):
|
||||
raise serializers.ValidationError("Invalid password")
|
||||
|
||||
def validate_confirm(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("Please confirm deletion")
|
||||
return value
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
password = serializers.CharField()
|
||||
|
||||
def validate(self, data):
|
||||
user = auth.authenticate(request=self.context.get("request"), **data)
|
||||
if not user:
|
||||
raise serializers.ValidationError(
|
||||
"Unable to log in with provided credentials"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError("This account was disabled")
|
||||
|
||||
return user
|
||||
|
||||
def save(self, request):
|
||||
return auth.login(request, self.validated_data)
|
||||
|
||||
|
||||
class UserChangeEmailSerializer(serializers.Serializer):
|
||||
password = serializers.CharField()
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_password(self, value):
|
||||
if not self.instance.check_password(value):
|
||||
raise serializers.ValidationError("Invalid password")
|
||||
|
||||
def validate_email(self, value):
|
||||
if (
|
||||
allauth_models.EmailAddress.objects.filter(email__iexact=value)
|
||||
.exclude(user=self.context["user"])
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError("This e-mail address is already in use")
|
||||
return value
|
||||
|
||||
def save(self, request):
|
||||
current, _ = allauth_models.EmailAddress.objects.get_or_create(
|
||||
user=request.user,
|
||||
email=request.user.email,
|
||||
defaults={"verified": False, "primary": True},
|
||||
)
|
||||
current.change(request, self.validated_data["email"], confirm=True)
|
||||
23
api/funkwhale_api/users/tasks.py
Normal file
23
api/funkwhale_api/users/tasks.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import logging
|
||||
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
from . import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.app.task(name="users.delete_account")
|
||||
@celery.require_instance(models.User.objects.select_related("actor"), "user")
|
||||
def delete_account(user):
|
||||
logger.info("Starting deletion of account %s…", user.username)
|
||||
actor = user.actor
|
||||
# we start by deleting the user obj, which will cascade deletion
|
||||
# to any other object
|
||||
user.delete()
|
||||
logger.info("Deleted user object")
|
||||
|
||||
# ensure actor is set to tombstone, activities are removed, etc.
|
||||
federation_tasks.remove_actor(actor_id=actor.pk)
|
||||
logger.info("Deletion of account done %s!", actor.preferred_username)
|
||||
180
api/funkwhale_api/users/views.py
Normal file
180
api/funkwhale_api/users/views.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import json
|
||||
|
||||
from allauth.account.adapter import get_adapter
|
||||
from dj_rest_auth import views as rest_auth_views
|
||||
from dj_rest_auth.registration import views as registration_views
|
||||
from django import http
|
||||
from django.contrib import auth
|
||||
from django.middleware import csrf
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import mixins, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from funkwhale_api.common import authentication, preferences, throttling
|
||||
|
||||
from . import models, serializers, tasks
|
||||
|
||||
|
||||
@extend_schema_view(post=extend_schema(operation_id="register", methods=["post"]))
|
||||
class RegisterView(registration_views.RegisterView):
|
||||
serializer_class = serializers.RegisterSerializer
|
||||
permission_classes = []
|
||||
action = "signup"
|
||||
throttling_scopes = {"signup": {"authenticated": "signup", "anonymous": "signup"}}
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
invitation_code = request.data.get("invitation")
|
||||
if not invitation_code and not self.is_open_for_signup(request):
|
||||
r = {"detail": "Registration has been disabled"}
|
||||
return Response(r, status=403)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def is_open_for_signup(self, request):
|
||||
return get_adapter().is_open_for_signup(request)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = super().perform_create(serializer)
|
||||
if not user.is_active:
|
||||
# manual approval, we need to send the confirmation e-mail by hand
|
||||
authentication.send_email_confirmation(self.request, user)
|
||||
if user.invitation:
|
||||
user.invitation.set_invited_user(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@extend_schema_view(post=extend_schema(operation_id="verify_email"))
|
||||
class VerifyEmailView(registration_views.VerifyEmailView):
|
||||
action = "verify-email"
|
||||
|
||||
|
||||
@extend_schema_view(post=extend_schema(operation_id="change_password"))
|
||||
class PasswordChangeView(rest_auth_views.PasswordChangeView):
|
||||
action = "password-change"
|
||||
|
||||
|
||||
@extend_schema_view(post=extend_schema(operation_id="reset_password"))
|
||||
class PasswordResetView(rest_auth_views.PasswordResetView):
|
||||
action = "password-reset"
|
||||
|
||||
|
||||
@extend_schema_view(post=extend_schema(operation_id="confirm_password_reset"))
|
||||
class PasswordResetConfirmView(rest_auth_views.PasswordResetConfirmView):
|
||||
action = "password-reset-confirm"
|
||||
|
||||
|
||||
class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
||||
queryset = models.User.objects.all().select_related("actor__attachment_icon")
|
||||
serializer_class = serializers.UserWriteSerializer
|
||||
lookup_field = "username"
|
||||
lookup_value_regex = r"[a-zA-Z0-9-_.]+"
|
||||
required_scope = "profile"
|
||||
|
||||
@extend_schema(operation_id="get_authenticated_user", methods=["get"])
|
||||
@extend_schema(operation_id="delete_authenticated_user", methods=["delete"])
|
||||
@action(methods=["get", "delete"], detail=False)
|
||||
def me(self, request, *args, **kwargs):
|
||||
"""Return information about the current user or delete it"""
|
||||
if request.method.lower() == "delete":
|
||||
serializer = serializers.UserDeleteSerializer(
|
||||
request.user, data=request.data
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
tasks.delete_account.delay(user_id=request.user.pk)
|
||||
# at this point, password is valid, we launch deletion
|
||||
return Response(status=204)
|
||||
serializer = serializers.MeSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(operation_id="update_settings")
|
||||
@action(methods=["post"], detail=False, url_name="settings", url_path="settings")
|
||||
def set_settings(self, request, *args, **kwargs):
|
||||
"""Return information about the current user or delete it"""
|
||||
new_settings = request.data
|
||||
request.user.set_settings(**new_settings)
|
||||
return Response(request.user.settings)
|
||||
|
||||
@action(
|
||||
methods=["get", "post", "delete"],
|
||||
required_scope="security",
|
||||
url_path="subsonic-token",
|
||||
detail=True,
|
||||
)
|
||||
def subsonic_token(self, request, *args, **kwargs):
|
||||
if not self.request.user.username == kwargs.get("username"):
|
||||
return Response(status=403)
|
||||
if not preferences.get("subsonic__enabled"):
|
||||
return Response(status=405)
|
||||
if request.method.lower() == "get":
|
||||
return Response(
|
||||
{"subsonic_api_token": self.request.user.subsonic_api_token}
|
||||
)
|
||||
if request.method.lower() == "delete":
|
||||
self.request.user.subsonic_api_token = None
|
||||
self.request.user.save(update_fields=["subsonic_api_token"])
|
||||
return Response(status=204)
|
||||
self.request.user.update_subsonic_api_token()
|
||||
self.request.user.save(update_fields=["subsonic_api_token"])
|
||||
data = {"subsonic_api_token": self.request.user.subsonic_api_token}
|
||||
return Response(data)
|
||||
|
||||
@extend_schema(operation_id="change_email", responses={200: None, 403: None})
|
||||
@action(
|
||||
methods=["post"],
|
||||
required_scope="security",
|
||||
url_path="change-email",
|
||||
detail=False,
|
||||
)
|
||||
def change_email(self, request, *args, **kwargs):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Response(status=403)
|
||||
serializer = serializers.UserChangeEmailSerializer(
|
||||
request.user, data=request.data, context={"user": request.user}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(request)
|
||||
return Response(status=204)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
if not self.request.user.username == kwargs.get("username"):
|
||||
return Response(status=403)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
if not self.request.user.username == kwargs.get("username"):
|
||||
return Response(status=403)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
@extend_schema(operation_id="login")
|
||||
@action(methods=["post"], detail=False)
|
||||
def login(request):
|
||||
throttling.check_request(request, "login")
|
||||
if request.method != "POST":
|
||||
return http.HttpResponse(status=405)
|
||||
serializer = serializers.LoginSerializer(
|
||||
data=request.POST, context={"request": request}
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return http.HttpResponse(
|
||||
json.dumps(serializer.errors), status=400, content_type="application/json"
|
||||
)
|
||||
serializer.save(request)
|
||||
csrf.rotate_token(request)
|
||||
token = csrf.get_token(request)
|
||||
response = http.HttpResponse(status=200)
|
||||
response.set_cookie("csrftoken", token, max_age=None)
|
||||
return response
|
||||
|
||||
|
||||
@extend_schema(operation_id="logout")
|
||||
@action(methods=["post"], detail=False)
|
||||
def logout(request):
|
||||
if request.method != "POST":
|
||||
return http.HttpResponse(status=405)
|
||||
auth.logout(request)
|
||||
token = csrf.get_token(request)
|
||||
response = http.HttpResponse(status=200)
|
||||
response.set_cookie("csrftoken", token, max_age=None)
|
||||
return response
|
||||
1296
api/funkwhale_api/users/wordlist.txt
Normal file
1296
api/funkwhale_api/users/wordlist.txt
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue