発散解像度 -divergence resolution-

Signed-off-by: Shin'ya Minazuki <shinyoukai@laidback.moe>
This commit is contained in:
Shin'ya Minazuki 2026-01-25 21:15:56 +01:00
commit 01bb65f8da
457 changed files with 929 additions and 602 deletions

View file

View 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

View 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"]

View 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

View 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

View 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

View 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."

View 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"

View 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

View 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())],
)
]

View file

@ -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",
),
),
]

View file

@ -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"
),
),
]

View file

@ -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,
),
)
]

View file

@ -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),
)
]

View file

@ -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),
),
]

View file

@ -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"
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View 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
)
],
),
)
]

View file

@ -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,
),
],
),
),
]

View 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),
)
]

View file

@ -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'),
),
]

View 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")}
),
]

View 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),
),
]

View file

@ -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),
]

View 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)]

View file

@ -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),
),
]

View file

@ -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),
),
]

View 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),
),
]

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -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),
]

View file

@ -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 = [
]

View 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)

View 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},
}

View 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)

View 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

View 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

View 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

View 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()

View 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"),
]

View 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)

View 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",
),
]

View 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)

View 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)

View 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

File diff suppressed because it is too large Load diff