diff --git a/src/c3nav/api/api.py b/src/c3nav/api/api.py
index 1a3cbf10..cb6c261b 100644
--- a/src/c3nav/api/api.py
+++ b/src/c3nav/api/api.py
@@ -8,7 +8,7 @@ from rest_framework.exceptions import ParseError, PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
-from c3nav.api.models import Token
+from c3nav.api.models import LoginToken
from c3nav.api.utils import get_api_post_data
@@ -39,8 +39,8 @@ class SessionViewSet(ViewSet):
if 'token' in data:
try:
- token = Token.get_by_token(data['token'])
- except Token.DoesNotExist:
+ token = LoginToken.get_by_token(data['token'])
+ except LoginToken.DoesNotExist:
raise PermissionDenied(_('This token does not exist or is no longer valid.'))
user = token.user
elif 'username' in data:
diff --git a/src/c3nav/api/auth.py b/src/c3nav/api/auth.py
index c7ea5528..95e1e494 100644
--- a/src/c3nav/api/auth.py
+++ b/src/c3nav/api/auth.py
@@ -5,16 +5,14 @@ from rest_framework.exceptions import AuthenticationFailed
class APISecretAuthentication(TokenAuthentication):
def authenticate_credentials(self, key):
- from c3nav.control.models import UserPermissions
-
try:
- user_perms = UserPermissions.objects.exclude(api_secret='').exclude(api_secret__isnull=True).filter(
- api_secret=key
- ).get()
- except UserPermissions.DoesNotExist:
+ from c3nav.api.models import Secret
+ secret = Secret.objects.filter(api_secret=key).select_related('user', 'user__permissions')
+ # todo: auth scopes are ignored here, we need to get rid of this
+ except Secret.DoesNotExist:
raise AuthenticationFailed(_('Invalid token.'))
- if not user_perms.user.is_active:
+ if not secret.user.is_active:
raise AuthenticationFailed(_('User inactive or deleted.'))
- return (user_perms.user, user_perms)
+ return (secret.user, secret)
diff --git a/src/c3nav/api/migrations/0003_rename_token_logintoken_secret.py b/src/c3nav/api/migrations/0003_rename_token_logintoken_secret.py
new file mode 100644
index 00000000..6a3469c7
--- /dev/null
+++ b/src/c3nav/api/migrations/0003_rename_token_logintoken_secret.py
@@ -0,0 +1,107 @@
+# Generated by Django 4.2.3 on 2023-11-27 22:32
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def forwards_func(apps, schema_editor):
+ UserPermissions = apps.get_model("control", "UserPermissions")
+ for permissions in UserPermissions.objects.select_related("user"):
+ if permissions.api_secret:
+ permissions.user.api_secrets.create(
+ name="legacy secret (migrated)",
+ api_secret=permissions.api_secret,
+ readonly=False,
+ scope_grant_permissions=True,
+ scope_editor=True,
+ scope_mesh=True,
+ valid_until=None,
+ )
+
+
+def backwards_func(apps, schema_editor):
+ UserPermissions = apps.get_model("control", "UserPermissions")
+ for permissions in UserPermissions.objects.select_related("user"):
+ secret = permissions.user.api_secrets.first()
+ if secret:
+ permissions.api_secret = secret.api_secret
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("api", "0002_django_4_0"),
+ ("control", "0010_userpermissions_mesh_control"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="Token",
+ new_name="LoginToken",
+ ),
+ migrations.CreateModel(
+ name="Secret",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(verbose_name="name")),
+ (
+ "created",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="creation date"
+ ),
+ ),
+ (
+ "api_secret",
+ models.CharField(max_length=64, unique=True, verbose_name="API secret"),
+ ),
+ ("readonly", models.BooleanField(verbose_name="readonly")),
+ (
+ "scope_grant_permissions",
+ models.BooleanField(
+ default=False, verbose_name="grant map access permissions"
+ ),
+ ),
+ (
+ "scope_editor",
+ models.BooleanField(default=False, verbose_name="editor access"),
+ ),
+ (
+ "scope_mesh",
+ models.BooleanField(default=False, verbose_name="mesh access"),
+ ),
+ (
+ "valid_until",
+ models.DateTimeField(null=True, verbose_name="valid_until"),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="api_secrets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.RunPython(forwards_func, backwards_func),
+ migrations.AlterModelOptions(
+ name="secret",
+ options={
+ "verbose_name": "API secret",
+ "verbose_name_plural": "API secrets",
+ },
+ ),
+ migrations.AlterUniqueTogether(
+ name="secret",
+ unique_together={("user", "name")},
+ ),
+ ]
diff --git a/src/c3nav/api/models.py b/src/c3nav/api/models.py
index b57aa419..8b3a9bb6 100644
--- a/src/c3nav/api/models.py
+++ b/src/c3nav/api/models.py
@@ -6,7 +6,37 @@ from django.utils.crypto import constant_time_compare, get_random_string
from django.utils.translation import gettext_lazy as _
-class Token(models.Model):
+class SecretQuerySet(models.QuerySet):
+ def get_by_secret(self, secret):
+ self.filter(secret=secret, )
+
+
+class Secret(models.Model):
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_secrets")
+ name = models.CharField(_('name'))
+ created = models.DateTimeField(auto_now_add=True, verbose_name=_('creation date'))
+ api_secret = models.CharField(max_length=64, verbose_name=_('API secret'), unique=True)
+ readonly = models.BooleanField(_('readonly'))
+ scope_grant_permissions = models.BooleanField(_('grant map access permissions'), default=False)
+ scope_editor = models.BooleanField(_('editor access'), default=False)
+ scope_mesh = models.BooleanField(_('mesh access'), default=False)
+ valid_until = models.DateTimeField(null=True, verbose_name=_('valid_until'))
+
+ def scopes_display(self):
+ return [
+ field.verbose_name for field in self._meta.get_fields()
+ if field.name.startswith('scope_') and getattr(self, field.name)
+ ] + ([_('(readonly)')] if self.readonly else [])
+
+ class Meta:
+ verbose_name = _('API secret')
+ verbose_name_plural = _('API secrets')
+ unique_together = [
+ ('user', 'name'),
+ ]
+
+
+class LoginToken(models.Model):
"""
Token for log in via API
"""
diff --git a/src/c3nav/api/newauth.py b/src/c3nav/api/newauth.py
index d674f0e5..e35a46cc 100644
--- a/src/c3nav/api/newauth.py
+++ b/src/c3nav/api/newauth.py
@@ -1,26 +1,43 @@
from collections import namedtuple
+from dataclasses import dataclass
+from enum import StrEnum
from importlib import import_module
from django.contrib.auth import get_user as auth_get_user
from django.contrib.auth.models import AnonymousUser
from django.db.models import Q
+from django.utils import timezone
+from django.utils.functional import SimpleLazyObject, lazy
from ninja.security import HttpBearer
from c3nav import settings
from c3nav.api.exceptions import APIPermissionDenied, APITokenInvalid
+from c3nav.api.models import Secret
from c3nav.api.schema import APIErrorSchema
+from c3nav.control.middleware import UserPermissionsMiddleware
from c3nav.control.models import UserPermissions
FakeRequest = namedtuple('FakeRequest', ('session', ))
+class APIAuthMethod(StrEnum):
+ ANONYMOUS = 'anonymous'
+ SESSION = 'session'
+ SECRET = 'secret'
+
+
+@dataclass
+class NewAPIAuth:
+ auth_method: APIAuthMethod
+ auth_readonly: bool
+
+
description = """
An API token can be acquired in 4 ways:
* Use `anonymous` for guest access.
-* Generate a session-bound token using the auth session endpoint.
-* Create an API token in your user account settings.
-* Create an API token by signing in through the auth endpoint.
+* Generate a session-bound temporary token using the auth session endpoint.
+* Create an API secret in your user account settings.
""".strip()
@@ -28,41 +45,78 @@ class APITokenAuth(HttpBearer):
openapi_name = "api token authentication"
openapi_description = description
- def __init__(self, logged_in=False, superuser=False):
+ def __init__(self, logged_in=False, superuser=False, permissions: set[str] = None, is_readonly=False):
super().__init__()
self.logged_in = superuser or logged_in
self.superuser = superuser
+ self.permissions = permissions or set()
+ self.is_readonly = is_readonly
engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore
- def _authenticate(self, request, token):
+ def _authenticate(self, request, token) -> NewAPIAuth:
+ request.user = AnonymousUser
+ request.user_permissions = SimpleLazyObject(lambda: UserPermissionsMiddleware.get_user_permissions(request))
+ request.user_space_accesses = lazy(UserPermissionsMiddleware.get_user_space_accesses, dict)(request)
+
if token == "anonymous":
- return AnonymousUser
+ return NewAPIAuth(
+ auth_method=APIAuthMethod.ANONYMOUS,
+ auth_readonly=True,
+ )
elif token.startswith("session:"):
session = self.SessionStore(token.removeprefix("session:"))
- # todo: ApiTokenInvalid?
+ print('session is empty:', request.session.is_empty())
user = auth_get_user(FakeRequest(session=session))
- return user
+ if not user.is_authenticated:
+ raise APITokenInvalid
+ request.user = user
+ return NewAPIAuth(
+ auth_method=APIAuthMethod.SESSION,
+ auth_readonly=True,
+ )
elif token.startswith("secret:"):
try:
- user_perms = UserPermissions.objects.filter(
- ~Q(api_secret=""),
- ~Q(api_secret__isnull=True),
- api_secret=token.removeprefix("secret:")
- ).select_related("user").get()
- except UserPermissions.DoesNotExist:
+ secret = Secret.objects.filter(
+ Q(api_secret=token.removeprefix("secret:")),
+ Q(valid_until__isnull=True) | Q(valid_until__lt=timezone.now()),
+ ).select_related("user", "user__permissions").get()
+ except Secret.DoesNotExist:
raise APITokenInvalid
- return user_perms.user
+
+ # get user permissions and restrict them based on scopes
+ user_permissions: UserPermissions = secret.user.permissions
+ if secret.scope_mesh is False:
+ user_permissions.mesh_control = False
+ if secret.scope_editor is False:
+ user_permissions.editor_access = False
+ if secret.scope_grant_permissions is False:
+ user_permissions.grant_permissions = False
+
+ request.user = secret.user
+ request.user_permissions = user_permissions
+
+ return NewAPIAuth(
+ auth_method=APIAuthMethod.SESSION,
+ auth_readonly=True
+ )
# todo: implement token (app) auth
raise APITokenInvalid
def authenticate(self, request, token):
- user = self._authenticate(request, token)
- if self.logged_in and user.is_anonymous:
- raise APIPermissionDenied
- if self.superuser and not user.is_superuser:
- raise APIPermissionDenied
- return user
+ auth_result = self._authenticate(request, token)
+ if self.logged_in and not request.user.is_authenticated:
+ raise APIPermissionDenied('You need to be signed in for this request.')
+ if self.superuser and not request.user.is_superuser:
+ raise APIPermissionDenied('You need to have admin rights for this endpoint.')
+ for permission in self.permissions:
+ if not getattr(request.user_permissions, permission):
+ raise APIPermissionDenied('You need to have the "%s" permission for this endpoint.')
+ if request.method == 'GET' and self.is_readonly:
+ raise ValueError('this makes no sense for GET')
+ if request.method != 'GET' and not self.is_readonly:
+ raise APIPermissionDenied('You need a non-readonly API access key for this endpoint.')
+ return auth_result
validate_responses = {422: APIErrorSchema, }
diff --git a/src/c3nav/control/forms.py b/src/c3nav/control/forms.py
index e57f34d3..58ceef84 100644
--- a/src/c3nav/control/forms.py
+++ b/src/c3nav/control/forms.py
@@ -158,6 +158,7 @@ class AccessPermissionForm(Form):
unique_key=unique_key)
def get_signed_data(self, key=None):
+ # todo: yep, we stil need to fix this
if not self.author.permissions.api_secret:
raise ValueError('Author has no api secret.')
data = {
diff --git a/src/c3nav/control/middleware.py b/src/c3nav/control/middleware.py
index 13d9d4d2..1a186a1d 100644
--- a/src/c3nav/control/middleware.py
+++ b/src/c3nav/control/middleware.py
@@ -17,22 +17,24 @@ class UserPermissionsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
- def get_user_permissions(self, request):
+ @staticmethod
+ def get_user_permissions(request):
try:
return getattr(request, '_user_permissions_cache')
except AttributeError:
pass
result = UserPermissions.get_for_user(request.user)
- self._user_permissions_cache = result
+ request._user_permissions_cache = result
return result
- def get_user_space_accesses(self, request):
+ @staticmethod
+ def get_user_space_accesses(request):
try:
return getattr(request, '_user_space_accesses_cache')
except AttributeError:
pass
result = UserSpaceAccess.get_for_user(request.user)
- self._user_space_accesses_cache = result
+ request._user_space_accesses_cache = result
return result
def __call__(self, request):
diff --git a/src/c3nav/control/templates/control/user.html b/src/c3nav/control/templates/control/user.html
index fc85249f..562fce50 100644
--- a/src/c3nav/control/templates/control/user.html
+++ b/src/c3nav/control/templates/control/user.html
@@ -30,36 +30,6 @@
{% endif %}
- {% if request.user_permissions.grant_permissions or request.user == user and user.permissions.api_secret %}
- {% trans 'API secret' %}
-
- {% if user.permissions.api_secret %}
- {% if request.user == user %}
- {% trans 'This user has an API secret.' %}
- {% else %}
- {% trans 'You have an API secret.' %}
- {% endif %}
- {% trans 'You can not see it, but generate a new one.' %}
- {% else %}
- {% trans 'This user has not an API secret.' %}
- {% trans 'You can create one.' %}
- {% endif %}
-
-
- {% endif %}
-
{% trans 'Access Permissions' %}
{% if access_restriction %}
diff --git a/src/c3nav/control/views/access.py b/src/c3nav/control/views/access.py
index 35bd2ac0..98d818ed 100644
--- a/src/c3nav/control/views/access.py
+++ b/src/c3nav/control/views/access.py
@@ -21,6 +21,7 @@ def grant_access(request): # todo: make class based view
if form.is_valid():
token = form.get_token()
token.save()
+ # todo: this still needs fixing
if settings.DEBUG and request.user_permissions.api_secret:
signed_data = form.get_signed_data()
print('/?'+urlencode({'access': signed_data}))
diff --git a/src/c3nav/control/views/users.py b/src/c3nav/control/views/users.py
index 8e90db59..3e8e5731 100644
--- a/src/c3nav/control/views/users.py
+++ b/src/c3nav/control/views/users.py
@@ -1,5 +1,3 @@
-import string
-
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
@@ -7,7 +5,6 @@ from django.db import IntegrityError, transaction
from django.db.models import Prefetch
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
-from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView
@@ -60,37 +57,6 @@ def user_detail(request, user): # todo: make class based view
messages.error(request, _('You cannot delete this Access Permission.'))
return redirect(request.path_info+'?restriction='+str(permission.pk)+'#access')
- api_secret_action = request.POST.get('api_secret')
- if (api_secret_action and (request.user_permissions.grant_permissions or
- (request.user == user and user.permissions.api_secret))):
-
- permissions = user.permissions
-
- if api_secret_action == 'generate' and permissions.api_secret:
- messages.error(request, _('This user already has an API secret.'))
- return redirect(request.path_info)
-
- if api_secret_action in ('delete', 'regenerate') and not permissions.api_secret:
- messages.error(request, _('This user does not have an API secret.'))
- return redirect(request.path_info)
-
- with transaction.atomic():
- if api_secret_action in ('generate', 'regenerate'):
- api_secret = '%d-%s' % (user.pk, get_random_string(62, string.ascii_letters+string.digits))
- permissions.api_secret = api_secret
- permissions.save()
-
- messages.success(request, _('The new API secret is: %s – '
- 'be sure to note it down now, it won\'t be shown again.') % api_secret)
-
- elif api_secret_action == 'delete':
- permissions.api_secret = None
- permissions.save()
-
- messages.success(request, _('API secret successfully deleted!'))
-
- return redirect(request.path_info)
-
ctx = {
'user': user,
}
diff --git a/src/c3nav/mapdata/utils/cache/stats.py b/src/c3nav/mapdata/utils/cache/stats.py
index e756bae8..b2422c86 100644
--- a/src/c3nav/mapdata/utils/cache/stats.py
+++ b/src/c3nav/mapdata/utils/cache/stats.py
@@ -14,6 +14,7 @@ from c3nav.mapdata.utils.locations import CustomLocation, get_location_by_id_for
def increment_cache_key(cache_key):
+ print('increment_cache_key', cache_key)
try:
cache.incr(cache_key)
except ValueError:
diff --git a/src/c3nav/mesh/forms.py b/src/c3nav/mesh/forms.py
index 6279aed9..5eb983bc 100644
--- a/src/c3nav/mesh/forms.py
+++ b/src/c3nav/mesh/forms.py
@@ -3,20 +3,20 @@ from dataclasses import dataclass
from dataclasses import replace as dataclass_replace
from functools import cached_property
from itertools import chain
-from typing import Sequence, Any
+from typing import Any, Sequence
from asgiref.sync import async_to_sync
from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction
-from django.forms import Form, ChoiceField, BooleanField
+from django.forms import BooleanField, ChoiceField, Form
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from c3nav.mesh.dataformats import BoardConfig, BoardType, LedType, SerialLedType
from c3nav.mesh.messages import MESH_BROADCAST_ADDRESS, MESH_ROOT_ADDRESS, MeshMessage, MeshMessageType
-from c3nav.mesh.models import MeshNode, HardwareDescription, FirmwareBuild, OTAUpdateRecipient, OTARecipientStatus, \
- OTAUpdate
+from c3nav.mesh.models import (FirmwareBuild, HardwareDescription, MeshNode, OTARecipientStatus, OTAUpdate,
+ OTAUpdateRecipient)
class MeshMessageForm(forms.Form):
@@ -300,7 +300,6 @@ class RangingForm(forms.Form):
self.fields['range_to'].choices = node_choices
-
@dataclass
class OTAFormGroup:
hardware: HardwareDescription
diff --git a/src/c3nav/mesh/newapi.py b/src/c3nav/mesh/newapi.py
index b7634561..5119b6e9 100644
--- a/src/c3nav/mesh/newapi.py
+++ b/src/c3nav/mesh/newapi.py
@@ -1,8 +1,9 @@
from datetime import datetime
-from typing import Optional, Annotated
+from typing import Annotated, Optional
from django.db import IntegrityError, transaction
-from ninja import Field as APIField, Query
+from ninja import Field as APIField
+from ninja import Query
from ninja import Router as APIRouter
from ninja import Schema, UploadedFile
from ninja.pagination import paginate
@@ -14,7 +15,7 @@ from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage
from c3nav.mesh.messages import MeshMessageType
from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage
-mesh_api_router = APIRouter(tags=["mesh"])
+mesh_api_router = APIRouter(tags=["mesh"], auth=APITokenAuth(permissions={"mesh_control"}))
class FirmwareBuildSchema(Schema):
@@ -144,7 +145,7 @@ class UploadFirmwareSchema(Schema):
@mesh_api_router.post(
- '/firmwares/upload', summary="Upload firmware", auth=APITokenAuth(superuser=True),
+ '/firmwares/upload', summary="Upload firmware",
description="your OpenAPI viewer might not show it: firmware_data is UploadFirmware as json",
response={200: FirmwareSchema, **validate_responses, **auth_permission_responses, **APIConflict.dict()}
)
@@ -166,20 +167,10 @@ def firmware_upload(request, firmware_data: UploadFirmwareSchema, binary_files:
project_name=firmware_data.project_name,
version=firmware_data.version,
idf_version=firmware_data.idf_version,
- uploader=request.auth,
+ uploader=request.user,
)
for build_data in firmware_data.builds:
- # if bin_file.size > 4 * 1024 * 1024:
- # raise ValueError # todo: better error
-
- # h = hashlib.sha256()
- # h.update(build_data.binary)
- # sha256_bin_file = h.hexdigest() # todo: verify sha256 correctly
- #
- # if sha256_bin_file != build_data.sha256_hash:
- # raise ValueError
-
try:
image = FirmwareImage.from_file(binary_files_by_name[build_data.uploaded_filename].open('rb'))
except ValueError:
@@ -224,7 +215,7 @@ class NodeMessageSchema(Schema):
@mesh_api_router.get(
- '/messages/', summary="query recorded mesh messages", auth=APITokenAuth(superuser=True),
+ '/messages/', summary="query recorded mesh messages",
response={200: list[NodeMessageSchema], **auth_permission_responses}
)
@paginate
diff --git a/src/c3nav/routing/newapi/positioning.py b/src/c3nav/routing/newapi/positioning.py
index 574a198e..c8176372 100644
--- a/src/c3nav/routing/newapi/positioning.py
+++ b/src/c3nav/routing/newapi/positioning.py
@@ -13,7 +13,7 @@ positioning_api_router = APIRouter(tags=["positioning"])
@positioning_api_router.post('/locate/', summary="locate based on wifi scans",
response={200: BoundsSchema, **auth_responses})
-def locate(request):
+def locate():
# todo: implement
return {
"bounds": Source.max_bounds(),
@@ -22,7 +22,7 @@ def locate(request):
@positioning_api_router.get('/locate-test/', summary="get dummy location for debugging",
response={200: BoundsSchema, **auth_responses})
-def locate_test(request):
+def locate_test():
# todo: implement
return {
"bounds": Source.max_bounds(),
@@ -44,5 +44,5 @@ BeaconsXYZ = dict[
@positioning_api_router.get('/beacons-xyz/', summary="get calculated x y z for all beacons",
response={200: BeaconsXYZ, **auth_responses})
-def beacons_xyz(request):
+def beacons_xyz():
return RangeLocator.load().get_all_xyz()
diff --git a/src/c3nav/routing/rangelocator.py b/src/c3nav/routing/rangelocator.py
index 2246df9f..e851ce11 100644
--- a/src/c3nav/routing/rangelocator.py
+++ b/src/c3nav/routing/rangelocator.py
@@ -2,7 +2,7 @@ import pickle
import threading
from dataclasses import dataclass
from pprint import pprint
-from typing import Self
+from typing import Annotated, Self
import numpy as np
import scipy
diff --git a/src/c3nav/settings.py b/src/c3nav/settings.py
index 7d991e10..f02ca0ea 100644
--- a/src/c3nav/settings.py
+++ b/src/c3nav/settings.py
@@ -49,7 +49,7 @@ with suppress(ImportError):
import sentry_sdk
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
- from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST
+ from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
sensitive_env_vars = ['C3NAV_DJANGO_SECRET', 'C3NAV_TILE_SECRET', 'C3NAV_DATABASE', 'C3NAV_DATABASE_PASSWORD',
'C3NAV_MEMCACHED', 'C3NAV_MEMCACHED_USER', 'C3NAV_MEMCACHED_PASSWORD',
diff --git a/src/c3nav/site/forms.py b/src/c3nav/site/forms.py
index 605790f8..8c287d21 100644
--- a/src/c3nav/site/forms.py
+++ b/src/c3nav/site/forms.py
@@ -1,20 +1,17 @@
-from dataclasses import dataclass
-from dataclasses import replace as dataclass_replace
-from functools import cached_property
-from itertools import chain
+import string
+from datetime import timedelta
from operator import attrgetter
-from typing import Any, Sequence
from django.db import transaction
-from django.forms import BooleanField, ChoiceField, Form, ModelChoiceField, ModelForm
+from django.forms import Form, IntegerField, ModelChoiceField, ModelForm
+from django.utils import timezone
+from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
+from c3nav.api.models import Secret
from c3nav.mapdata.forms import I18nModelFormMixin
from c3nav.mapdata.models.locations import Position
from c3nav.mapdata.models.report import Report, ReportUpdate
-from c3nav.mesh.messages import MeshMessageType
-from c3nav.mesh.models import (FirmwareBuild, HardwareDescription, MeshNode, OTARecipientStatus, OTAUpdate,
- OTAUpdateRecipient)
class ReportIssueForm(I18nModelFormMixin, ModelForm):
@@ -74,3 +71,35 @@ class PositionSetForm(Form):
self.fields['position'].queryset = Position.objects.filter(owner=request.user)
self.fields['position'].label_from_instance = attrgetter('name')
+
+class APISecretForm(ModelForm):
+ valid_for_days = IntegerField(min_value=0, max_value=90, label=_('valid for (days)'), initial=7)
+ valid_for_hours = IntegerField(min_value=0, max_value=24, label=_('valid for (hours)'), initial=0)
+
+ def __init__(self, *args, request, **kwargs):
+ self.request = request
+ super().__init__(*args, **kwargs)
+ if not self.request.user_permissions.grant_permissions:
+ self.fields.pop('scope_grant_permissions', None)
+ if not self.request.user_permissions.editor_access:
+ self.fields.pop('scope_editor', None)
+ if not self.request.user_permissions.mesh_control:
+ self.fields.pop('scope_mesh', None)
+
+ class Meta:
+ model = Secret
+ fields = ['name', 'readonly', 'scope_grant_permissions', 'scope_editor', 'scope_mesh']
+ # todo: allow suuplying days and hours
+
+ def save(self, *args, **kwargs):
+ self.instance.valid_until = (
+ timezone.now()
+ + timedelta(days=self.cleaned_data['valid_for_days'])
+ + timedelta(hours=self.cleaned_data['valid_for_hours'])
+ )
+ self.instance.user = self.request.user
+ self.instance.api_secret = (
+ '%d-%s' % (self.request.user.pk, get_random_string(62, string.ascii_letters + string.digits))
+ )[:64]
+
+ return super().save(*args, **kwargs)
diff --git a/src/c3nav/site/templates/site/account.html b/src/c3nav/site/templates/site/account.html
index a14eaa7d..03cbbbda 100644
--- a/src/c3nav/site/templates/site/account.html
+++ b/src/c3nav/site/templates/site/account.html
@@ -13,25 +13,24 @@
{% endwith %}
- {% if request.user_permissions.control_panel and not request.mobileclient %}
-
+ {% if request.user_permissions.control_panel or request.user_permissions.can_review_reports or request.user_permissions.mesh_control %}
- {% trans 'You can access the control panel.' %}
+ {% trans 'You can manage:' %}
- {% trans 'c3nav control panel' %}
+ {% if request.user_permissions.control_panel %}
+ {% trans 'stuff' %}
+ {% endif %}
+ {% if request.user_permissions.can_review_reports %}
+ {% trans 'reports' %}
+ {% endif %}
+ {% if request.user_permissions.mesh_control %}
+ {% trans 'mesh' %}
+ {% endif %}
{% endif %}
- {% if request.user_permissions.can_review_reports %}
-
-
- {% trans 'You can review reports' %}
-
-
- {% trans 'Show reports' %}
-
- {% elif user_has_reports %}
+ {% if not request.user_permissions.can_review_reports and user_has_reports %}
{% trans 'You have submitted reports.' %}
@@ -42,12 +41,11 @@
{% endif %}
-
- {% trans 'You can create custom positions.' %}
+ {% trans 'Manage custom positions' %}
- {% trans 'Manage your positions' %}
+ {% trans 'Manage API secrets' %}
diff --git a/src/c3nav/site/templates/site/api_secret_create.html b/src/c3nav/site/templates/site/api_secret_create.html
new file mode 100644
index 00000000..2f50f379
--- /dev/null
+++ b/src/c3nav/site/templates/site/api_secret_create.html
@@ -0,0 +1,16 @@
+{% extends 'site/base.html' %}
+{% load i18n %}
+
+{% block content %}
+
+ {% trans 'Create API secret' %}
+ {% include 'site/fragment_messages.html' %}
+ « {% trans 'back to overview' %}
+
+
+
+{% endblock %}
diff --git a/src/c3nav/site/templates/site/api_secret_list.html b/src/c3nav/site/templates/site/api_secret_list.html
new file mode 100644
index 00000000..a6653898
--- /dev/null
+++ b/src/c3nav/site/templates/site/api_secret_list.html
@@ -0,0 +1,44 @@
+{% extends 'site/base.html' %}
+{% load i18n %}
+
+{% block content %}
+
+ {% include 'site/fragment_messages.html' %}
+ {% trans 'Your API secrets' %}
+
+
+
+ {% trans 'Create API secret' %}
+
+{% endblock %}
diff --git a/src/c3nav/site/urls.py b/src/c3nav/site/urls.py
index ce651d94..2e11c147 100644
--- a/src/c3nav/site/urls.py
+++ b/src/c3nav/site/urls.py
@@ -3,9 +3,10 @@ from itertools import chain
from django.urls import path, register_converter
from c3nav.site.converters import AtPositionConverter, CoordinatesConverter, IsEmbedConverter, LocationConverter
-from c3nav.site.views import (about_view, access_redeem_view, account_view, change_password_view, choose_language,
- login_view, logout_view, map_index, position_create, position_detail, position_list,
- position_set, qr_code, register_view, report_create, report_detail, report_list)
+from c3nav.site.views import (about_view, access_redeem_view, account_view, api_secret_create, api_secret_list,
+ change_password_view, choose_language, login_view, logout_view, map_index,
+ position_create, position_detail, position_list, position_set, qr_code, register_view,
+ report_create, report_detail, report_list)
register_converter(LocationConverter, 'loc')
register_converter(CoordinatesConverter, 'coords')
@@ -55,4 +56,6 @@ urlpatterns = [
path('positions/create/', position_create, name='site.position_create'),
path('positions//', position_detail, name='site.position_detail'),
path('positions/set//', position_set, name='site.position_set'),
+ path('api-secrets/', api_secret_list, name='site.api_secret_list'),
+ path('api-secrets/create/', api_secret_create, name='site.api_secret_create'),
]
diff --git a/src/c3nav/site/views.py b/src/c3nav/site/views.py
index 20bf00cd..d8f612ec 100644
--- a/src/c3nav/site/views.py
+++ b/src/c3nav/site/views.py
@@ -18,12 +18,14 @@ from django.middleware import csrf
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
+from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from django.views.decorators.cache import cache_control, never_cache
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.http import etag
+from c3nav.api.models import Secret
from c3nav.control.forms import AccessPermissionForm, SignedPermissionDataError
from c3nav.mapdata.grid import grid
from c3nav.mapdata.models import Location, Source
@@ -35,7 +37,7 @@ from c3nav.mapdata.utils.locations import (get_location_by_id_for_request, get_l
from c3nav.mapdata.utils.user import can_access_editor, get_user_data
from c3nav.mapdata.views import set_tile_access_cookie
from c3nav.routing.models import RouteOptions
-from c3nav.site.forms import PositionForm, PositionSetForm, ReportUpdateForm
+from c3nav.site.forms import APISecretForm, PositionForm, PositionSetForm, ReportUpdateForm
from c3nav.site.models import Announcement, SiteUpdate
@@ -570,3 +572,39 @@ def position_set(request, coordinates):
'coordinates': coordinates,
'form': form,
})
+
+
+@login_required(login_url='site.login')
+def api_secret_list(request):
+ print(Secret.objects.values_list("api_secret", flat=True))
+ if request.method == 'POST' and request.POST.get('delete', 'nope').isdigit():
+ Secret.objects.filter(user=request.user, pk=int(request.POST['delete'])).delete()
+ messages.success(request, _('API secret deleted.'))
+ return redirect(reverse('site.api_secret_list'))
+ return render(request, 'site/api_secret_list.html', {
+ 'api_secrets': Secret.objects.filter(user=request.user).order_by('-created'),
+ 'user_data_json': json.dumps(get_user_data(request), cls=DjangoJSONEncoder),
+ })
+
+
+@login_required(login_url='site.login')
+def api_secret_create(request):
+ if Secret.objects.filter(user=request.user).count() >= 20:
+ messages.error(request, _('You can\'t create more than 20 API secrets.'))
+
+ if request.method == 'POST':
+ form = APISecretForm(data=request.POST, request=request)
+ if form.is_valid():
+ secret = form.save()
+ messages.success(request, format_html(
+ '{}
{}
',
+ _('API secret created. Save it now, cause it will not be shown again!'),
+ secret.api_secret,
+ ))
+ return redirect(reverse('site.api_secret_list'))
+ else:
+ form = APISecretForm(request=request)
+
+ return render(request, 'site/api_secret_create.html', {
+ 'form': form,
+ })