Merge pull request 'Added user spaces' (#24) from #15_user_spaces into dev
All checks were successful
Build on push / prepare (push) Successful in 6s
Build on push / build-redirector (push) Successful in 33s
Build on push / build-api (push) Successful in 34s
Build on push / build-web (push) Successful in 1m2s

Reviewed-on: #24
Closes #15
This commit is contained in:
Sven Heidemann 2025-04-30 16:44:53 +02:00
commit bf0f5aa54d
68 changed files with 1659 additions and 832 deletions

View File

@ -1,7 +1,6 @@
from abc import abstractmethod
from typing import Type, Union
from api_graphql.abc.input_abc import InputABC
from api_graphql.abc.query_abc import QueryABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from core.database.abc.data_access_object_abc import DataAccessObjectABC
@ -22,6 +21,7 @@ class MutationABC(QueryABC):
name: str,
mutation_name: str,
require_any_permission=None,
require_any=None,
public: bool = False,
):
"""
@ -29,24 +29,30 @@ class MutationABC(QueryABC):
:param str name: GraphQL mutation name
:param str mutation_name: Internal (class) mutation name without "Mutation" suffix
:param list[Permissions] require_any_permission: List of permissions required to access the field
:param tuple[list[Permissions], list[callable]] require_any: List of permissions and resolvers required to access the field
:param bool public: Define if the field can resolve without authentication
:return:
"""
if require_any_permission is None:
require_any_permission = []
from api_graphql.definition import QUERIES
self.field(
field = (
MutationFieldBuilder(name)
.with_resolver(
lambda *args, **kwargs: [
x for x in QUERIES if x.name == f"{mutation_name}Mutation"
][0]
)
.with_require_any_permission(require_any_permission)
.with_public(public)
)
if require_any_permission is not None:
field.with_require_any_permission(require_any_permission)
if require_any is not None:
field.with_require_any(*require_any)
self.field(field)
@staticmethod
async def _resolve_assignments(
foreign_objs: list[int],

View File

@ -79,6 +79,7 @@ class QueryABC(ObjectType):
):
return
resolver_results = []
for x in resolvers:
user = await Route.get_authenticated_user_or_api_key_or_default()
user_permissions = []
@ -86,14 +87,16 @@ class QueryABC(ObjectType):
user_permissions = await user.permissions
if iscoroutinefunction(x):
result = await x(
QueryContext(data, user, user_permissions, *args, **kwargs)
resolver_results.append(
await x(QueryContext(data, user, user_permissions, *args, **kwargs))
)
else:
result = x(QueryContext(data, user, user_permissions, *args, **kwargs))
resolver_results.append(
x(QueryContext(data, user, user_permissions, *args, **kwargs))
)
if not result:
raise AccessDenied()
if not any(resolver_results):
raise AccessDenied()
def field(
self,
@ -120,7 +123,7 @@ class QueryABC(ObjectType):
skip = None
if field.default_filter:
filters.append(field.default_filter(*args, **kwargs))
filters.append(await field.default_filter(*args, **kwargs))
if field.filter_type and "filter" in kwargs:
in_filters = kwargs["filter"]
@ -210,7 +213,6 @@ class QueryABC(ObjectType):
f"{field.name}: {field.input_type.__name__} {kwargs[field.input_key]}"
)
input_obj = field.input_type(kwargs[field.input_key])
del kwargs[field.input_key]
return await resolver_wrapper(input_obj, mutation, info, **kwargs)

View File

@ -1,5 +1,5 @@
from asyncio import iscoroutinefunction
from typing import Self, Type
from typing import Self, Type, Union
from ariadne.types import Resolver
@ -58,7 +58,11 @@ class MutationFieldBuilder(FieldBuilderABC):
self._resolver = resolver_wrapper
return self
def with_input(self, input_type: Type[InputABC], input_key: str = None) -> Self:
def with_input(
self,
input_type: Type[Union[InputABC, str, int, bool]],
input_key: str = "input",
) -> Self:
self._input_type = input_type
self._input_key = input_key
return self

View File

@ -11,3 +11,6 @@ class GroupFilter(DbModelFilterABC):
self.add_field("name", StringFilter)
self.add_field("description", StringFilter)
self.add_field("isNull", bool)
self.add_field("isNotNull", bool)

View File

@ -39,8 +39,8 @@ input StringFilter {
startsWith: String
endsWith: String
isNull: String
isNotNull: String
isNull: Boolean
isNotNull: Boolean
}
input IntFilter {
@ -51,8 +51,8 @@ input IntFilter {
less: Int
lessOrEqual: Int
isNull: Int
isNotNull: Int
isNull: Boolean
isNotNull: Boolean
in: [Int]
notIn: [Int]
}
@ -61,8 +61,8 @@ input BooleanFilter {
equal: Boolean
notEqual: Int
isNull: Int
isNotNull: Int
isNull: Boolean
isNotNull: Boolean
}
input DateFilter {
@ -78,8 +78,8 @@ input DateFilter {
contains: String
notContains: String
isNull: String
isNotNull: String
isNull: Boolean
isNotNull: Boolean
in: [String]
notIn: [String]

View File

@ -61,6 +61,9 @@ input GroupFilter {
editor: IntFilter
created: DateFilter
updated: DateFilter
isNull: Boolean
isNotNull: Boolean
}
type GroupMutation {

View File

@ -11,4 +11,6 @@ type Mutation {
setting: SettingMutation
userSetting: UserSettingMutation
featureFlag: FeatureFlagMutation
privacy: PrivacyMutation
}

View File

@ -0,0 +1,5 @@
type PrivacyMutation {
exportData(userId: Int!): String
anonymizeData(userId: Int!): Boolean
deleteData(userId: Int!): Boolean
}

View File

@ -1,4 +1,5 @@
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.require_any_resolvers import by_user_setup_mutation
from service.permission.permissions_enum import Permissions
@ -45,20 +46,26 @@ class Mutation(MutationABC):
self.add_mutation_type(
"group",
"Group",
require_any_permission=[
Permissions.groups_create,
Permissions.groups_update,
Permissions.groups_delete,
],
require_any=(
[
Permissions.groups_create,
Permissions.groups_update,
Permissions.groups_delete,
],
[by_user_setup_mutation],
),
)
self.add_mutation_type(
"shortUrl",
"ShortUrl",
require_any_permission=[
Permissions.short_urls_create,
Permissions.short_urls_update,
Permissions.short_urls_delete,
],
require_any=(
[
Permissions.short_urls_create,
Permissions.short_urls_update,
Permissions.short_urls_delete,
],
[by_user_setup_mutation],
),
)
self.add_mutation_type(
@ -79,3 +86,7 @@ class Mutation(MutationABC):
Permissions.administrator,
],
)
self.add_mutation_type(
"privacy",
"Privacy",
)

View File

@ -1,8 +1,13 @@
from typing import Optional
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.group_create_input import GroupCreateInput
from api_graphql.input.group_update_input import GroupUpdateInput
from api_graphql.require_any_resolvers import by_user_setup_mutation
from core.configuration.feature_flags import FeatureFlags
from core.configuration.feature_flags_enum import FeatureFlagsEnum
from core.logger import APILogger
from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
@ -17,27 +22,30 @@ class GroupMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "Group")
self.mutation(
"create",
self.resolve_create,
GroupCreateInput,
require_any_permission=[Permissions.groups_create],
self.field(
MutationFieldBuilder("create")
.with_resolver(self.resolve_create)
.with_input(GroupCreateInput)
.with_require_any([Permissions.groups_create], [by_user_setup_mutation])
)
self.mutation(
"update",
self.resolve_update,
GroupUpdateInput,
require_any_permission=[Permissions.groups_update],
self.field(
MutationFieldBuilder("update")
.with_resolver(self.resolve_update)
.with_input(GroupUpdateInput)
.with_require_any([Permissions.groups_update], [by_user_setup_mutation])
)
self.mutation(
"delete",
self.resolve_delete,
require_any_permission=[Permissions.groups_delete],
self.field(
MutationFieldBuilder("delete")
.with_resolver(self.resolve_delete)
.with_require_any([Permissions.groups_delete], [by_user_setup_mutation])
)
self.mutation(
"restore",
self.resolve_restore,
require_any_permission=[Permissions.groups_delete],
self.field(
MutationFieldBuilder("restore")
.with_resolver(self.resolve_restore)
.with_require_any([Permissions.groups_delete], [by_user_setup_mutation])
)
@staticmethod
@ -72,9 +80,18 @@ class GroupMutation(MutationABC):
async def resolve_create(cls, obj: GroupCreateInput, *_):
logger.debug(f"create group: {obj.__dict__}")
already_exists = await groupDao.find_by({Group.name: obj.name})
if len(already_exists) > 0:
raise ValueError(f"Group {obj.name} already exists")
group = Group(
0,
obj.name,
(
(await Route.get_user()).id
if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup)
else None
),
)
gid = await groupDao.create(group)
@ -90,6 +107,12 @@ class GroupMutation(MutationABC):
raise ValueError(f"Group with id {obj.id} not found")
if obj.name is not None:
already_exists = await groupDao.find_by(
{Group.name: obj.name, Group.id: {"ne": obj.id}}
)
if len(already_exists) > 0:
raise ValueError(f"Group {obj.name} already exists")
group = await groupDao.get_by_id(obj.id)
group.name = obj.name
await groupDao.update(group)

View File

@ -0,0 +1,63 @@
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.service.exceptions import UnauthorizedException, AccessDenied
from core.logger import APILogger
from service.data_privacy_service import DataPrivacyService
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class PrivacyMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "Privacy")
self.field(
MutationFieldBuilder("exportData")
.with_resolver(self.resolve_export_data)
.with_input(int, "userId")
.with_public(True)
)
self.field(
MutationFieldBuilder("anonymizeData")
.with_resolver(self.resolve_anonymize_data)
.with_input(int, "userId")
.with_public(True)
)
self.field(
MutationFieldBuilder("deleteData")
.with_resolver(self.resolve_delete_data)
.with_input(int, "userId")
.with_public(True)
)
@staticmethod
async def _permission_check(user_id: int):
user = await Route.get_user()
if user is None:
raise UnauthorizedException()
if user.id != user_id and not user.has_permission(Permissions.administrator):
raise AccessDenied()
@staticmethod
async def resolve_export_data(user_id: int, *_):
logger.debug(f"export data for user: {user_id}")
await PrivacyMutation._permission_check(user_id)
return await DataPrivacyService.export_user_data(user_id)
@staticmethod
async def resolve_anonymize_data(user_id: int, *_):
logger.debug(f"anonymize data for user: {user_id}")
await PrivacyMutation._permission_check(user_id)
return await DataPrivacyService.anonymize_user(user_id)
@staticmethod
async def resolve_delete_data(user_id: int, *_):
logger.debug(f"delete data for user: {user_id}")
await PrivacyMutation._permission_check(user_id)
return await DataPrivacyService.delete_user_data(user_id)

View File

@ -2,7 +2,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.setting_input import SettingInput
from core.logger import APILogger
from data.schemas.system.setting import Setting
from data.schemas.system.setting_dao import settingsDao
from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
@ -22,11 +22,11 @@ class SettingMutation(MutationABC):
async def resolve_change(obj: SettingInput, *_):
logger.debug(f"create new setting: {input}")
setting = await settingsDao.find_single_by({Setting.key: obj.key})
setting = await settingDao.find_single_by({Setting.key: obj.key})
if setting is None:
raise ValueError(f"Setting with key {obj.key} not found")
setting.value = obj.value
await settingsDao.update(setting)
await settingDao.update(setting)
return await settingsDao.get_by_id(setting.id)
return await settingDao.get_by_id(setting.id)

View File

@ -1,6 +1,11 @@
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.short_url_create_input import ShortUrlCreateInput
from api_graphql.input.short_url_update_input import ShortUrlUpdateInput
from api_graphql.require_any_resolvers import by_user_setup_mutation
from core.configuration.feature_flags import FeatureFlags
from core.configuration.feature_flags_enum import FeatureFlagsEnum
from core.logger import APILogger
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group_dao import groupDao
@ -17,38 +22,46 @@ class ShortUrlMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "ShortUrl")
self.mutation(
"create",
self.resolve_create,
ShortUrlCreateInput,
require_any_permission=[Permissions.short_urls_create],
self.field(
MutationFieldBuilder("create")
.with_resolver(self.resolve_create)
.with_input(ShortUrlCreateInput)
.with_require_any([Permissions.short_urls_create], [by_user_setup_mutation])
)
self.mutation(
"update",
self.resolve_update,
ShortUrlUpdateInput,
require_any_permission=[Permissions.short_urls_update],
self.field(
MutationFieldBuilder("update")
.with_resolver(self.resolve_update)
.with_input(ShortUrlUpdateInput)
.with_require_any([Permissions.short_urls_update], [by_user_setup_mutation])
)
self.mutation(
"delete",
self.resolve_delete,
require_any_permission=[Permissions.short_urls_delete],
self.field(
MutationFieldBuilder("delete")
.with_resolver(self.resolve_delete)
.with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation])
)
self.mutation(
"restore",
self.resolve_restore,
require_any_permission=[Permissions.short_urls_delete],
self.field(
MutationFieldBuilder("restore")
.with_resolver(self.resolve_restore)
.with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation])
)
self.mutation(
"trackVisit",
self.resolve_track_visit,
require_any_permission=[Permissions.short_urls_update],
self.field(
MutationFieldBuilder("trackVisit")
.with_resolver(self.resolve_track_visit)
.with_require_any_permission([Permissions.short_urls_update])
)
@staticmethod
async def resolve_create(obj: ShortUrlCreateInput, *_):
logger.debug(f"create short_url: {obj.__dict__}")
already_exists = await shortUrlDao.find_by({ShortUrl.short_url: obj.short_url})
if len(already_exists) > 0:
raise ValueError(f"Short URL {obj.short_url} already exists")
short_url = ShortUrl(
0,
obj.short_url,
@ -57,6 +70,11 @@ class ShortUrlMutation(MutationABC):
obj.group_id,
obj.domain_id,
obj.loading_screen,
(
(await Route.get_user()).id
if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup)
else None
),
)
nid = await shortUrlDao.create(short_url)
return await shortUrlDao.get_by_id(nid)
@ -68,6 +86,11 @@ class ShortUrlMutation(MutationABC):
short_url = await shortUrlDao.get_by_id(obj.id)
if obj.short_url is not None:
already_exists = await shortUrlDao.find_by(
{ShortUrl.short_url: obj.short_url}
)
if len(already_exists) > 0:
raise ValueError(f"Short URL {obj.short_url} already exists")
short_url.short_url = obj.short_url
if obj.target_url is not None:

View File

@ -1,10 +1,12 @@
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.user_setting_input import UserSettingInput
from core.logger import APILogger
from core.string import first_to_lower
from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingsDao
from data.schemas.system.setting_dao import settingsDao
from data.schemas.public.user_setting_dao import userSettingDao
from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
@ -13,13 +15,20 @@ logger = APILogger(__name__)
class UserSettingMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "UserSetting")
self.mutation(
"change",
self.resolve_change,
UserSettingInput,
require_any_permission=[Permissions.settings_update],
self.field(
MutationFieldBuilder("change")
.with_resolver(self.resolve_change)
.with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
)
.with_input(UserSettingInput, "input")
.with_require_any([], [self._x])
)
@staticmethod
async def _x(ctx):
return ctx.data.user_id == (await Route.get_user()).id
@staticmethod
async def resolve_change(obj: UserSettingInput, *_):
logger.debug(f"create new setting: {input}")
@ -28,13 +37,13 @@ class UserSettingMutation(MutationABC):
logger.debug("user not authorized")
return None
setting = await userSettingsDao.find_single_by(
setting = await userSettingDao.find_single_by(
[{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}]
)
if setting is None:
await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value))
await userSettingDao.create(UserSetting(0, user.id, obj.key, obj.value))
else:
setting.value = obj.value
await userSettingsDao.update(setting)
await userSettingDao.update(setting)
return await userSettingsDao.find_by_key(user, obj.key)
return await userSettingDao.find_by_key(user, obj.key)

View File

@ -1,6 +1,6 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from api_graphql.require_any_resolvers import by_assignment_resolver
from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
@ -21,7 +21,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC):
[
Permissions.groups,
],
[group_by_assignment_resolver],
[by_assignment_resolver],
)
)
self.set_field(

View File

@ -1,6 +1,6 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from api_graphql.require_any_resolvers import by_assignment_resolver
from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
@ -21,7 +21,7 @@ class GroupQuery(DbModelQueryABC):
[
Permissions.groups,
],
[group_by_assignment_resolver],
[by_assignment_resolver],
)
)
self.set_field("roles", self._get_roles)

View File

@ -11,7 +11,12 @@ from api_graphql.filter.permission_filter import PermissionFilter
from api_graphql.filter.role_filter import RoleFilter
from api_graphql.filter.short_url_filter import ShortUrlFilter
from api_graphql.filter.user_filter import UserFilter
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from api_graphql.require_any_resolvers import (
by_assignment_resolver,
by_user_setup_resolver,
)
from core.configuration.feature_flags import FeatureFlags
from core.configuration.feature_flags_enum import FeatureFlagsEnum
from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.administration.user import User
@ -27,9 +32,9 @@ from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingsDao
from data.schemas.public.user_setting_dao import userSettingDao
from data.schemas.system.feature_flag_dao import featureFlagDao
from data.schemas.system.setting_dao import settingsDao
from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions
@ -109,7 +114,8 @@ class Query(QueryABC):
]
)
)
self.field(
group_field = (
DaoFieldBuilder("groups")
.with_dao(groupDao)
.with_filter(GroupFilter)
@ -120,17 +126,35 @@ class Query(QueryABC):
Permissions.short_urls_create,
Permissions.short_urls_update,
],
[group_by_assignment_resolver],
[by_assignment_resolver, by_user_setup_resolver],
)
)
self.field(
if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup):
group_field = group_field.with_default_filter(
self._resolve_default_user_filter
)
self.field(group_field)
short_url_field = (
DaoFieldBuilder("shortUrls")
.with_dao(shortUrlDao)
.with_filter(ShortUrlFilter)
.with_sort(Sort[ShortUrl])
.with_require_any([Permissions.short_urls], [group_by_assignment_resolver])
.with_require_any(
[Permissions.short_urls],
[by_assignment_resolver, by_user_setup_resolver],
)
)
if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup):
short_url_field = short_url_field.with_default_filter(
self._resolve_default_user_filter
)
self.field(short_url_field)
self.field(
ResolverFieldBuilder("settings")
.with_resolver(self._resolve_settings)
@ -182,8 +206,8 @@ class Query(QueryABC):
@staticmethod
async def _resolve_settings(*args, **kwargs):
if "key" in kwargs:
return [await settingsDao.find_by_key(kwargs["key"])]
return await settingsDao.get_all()
return [await settingDao.find_by_key(kwargs["key"])]
return await settingDao.get_all()
@staticmethod
async def _resolve_user_settings(*args, **kwargs):
@ -192,13 +216,17 @@ class Query(QueryABC):
return None
if "key" in kwargs:
return await userSettingsDao.find_by(
return await userSettingDao.find_by(
[{UserSetting.user_id: user.id}, {UserSetting.key: kwargs["key"]}]
)
return await userSettingsDao.find_by({UserSetting.user_id: user.id})
return await userSettingDao.find_by({UserSetting.user_id: user.id})
@staticmethod
async def _resolve_feature_flags(*args, **kwargs):
if "key" in kwargs:
return [await featureFlagDao.find_by_key(kwargs["key"])]
return await featureFlagDao.get_all()
@staticmethod
async def _resolve_default_user_filter(*args, **kwargs) -> dict:
return {"user": {"id": {"equal": (await Route.get_user()).id}}}

View File

@ -1,10 +1,12 @@
from api_graphql.service.collection_result import CollectionResult
from api_graphql.service.query_context import QueryContext
from core.configuration.feature_flags import FeatureFlags
from core.configuration.feature_flags_enum import FeatureFlagsEnum
from data.schemas.public.group_dao import groupDao
from service.permission.permissions_enum import Permissions
async def group_by_assignment_resolver(ctx: QueryContext) -> bool:
async def by_assignment_resolver(ctx: QueryContext) -> bool:
if not isinstance(ctx.data, CollectionResult):
return False
@ -19,12 +21,23 @@ async def group_by_assignment_resolver(ctx: QueryContext) -> bool:
and all(r.id in role_ids for r in roles)
]
ctx.data.nodes = [
node
return all(
(await node.group) is not None and (await node.group).id in filtered_groups
for node in ctx.data.nodes
if (await node.group) is not None
and (await node.group).id in filtered_groups
]
return True
)
return True
return False
async def by_user_setup_resolver(ctx: QueryContext) -> bool:
if not isinstance(ctx.data, CollectionResult):
return False
if not FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup):
return False
return all(x.user_id == ctx.user.id for x in ctx.data.nodes)
async def by_user_setup_mutation(ctx: QueryContext) -> bool:
return await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup)

View File

@ -15,6 +15,7 @@ class QueryContext:
data: Any,
user: Optional[User],
user_permissions: Optional[list[Permissions]],
is_mutation: bool = False,
*args,
**kwargs
):
@ -31,11 +32,17 @@ class QueryContext:
self._resolve_info = arg
continue
self._filter = kwargs.get("filter", {})
self._filter = kwargs.get("filters", {})
self._sort = kwargs.get("sort", {})
self._skip = get_value(kwargs, "skip", int)
self._take = get_value(kwargs, "take", int)
self._input = kwargs.get("input", None)
self._args = args
self._kwargs = kwargs
self._is_mutation = is_mutation
@property
def data(self):
return self._data
@ -64,5 +71,21 @@ class QueryContext:
def take(self) -> Optional[int]:
return self._take
@property
def input(self) -> Optional[Any]:
return self._input
@property
def args(self) -> tuple:
return self._args
@property
def kwargs(self) -> dict:
return self._kwargs
@property
def is_mutation(self) -> bool:
return self._is_mutation
def has_permission(self, permission: Permissions) -> bool:
return permission.value in self._user_permissions

View File

@ -1,20 +1,38 @@
from typing import Union
from core.configuration.feature_flags_enum import FeatureFlagsEnum
from core.environment import Environment
from data.schemas.system.feature_flag_dao import featureFlagDao
class FeatureFlags:
_flags = {
FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025
FeatureFlagsEnum.technical_demo_banner.value: False, # 18.04.2025
FeatureFlagsEnum.per_user_setup.value: Environment.get(
"PER_USER_SETUP", bool, False
), # 18.04.2025
}
_overwrite_flags = [
FeatureFlagsEnum.per_user_setup.value,
]
@staticmethod
def overwrite_flag(key: str):
return key in FeatureFlags._overwrite_flags
@staticmethod
def get_default(key: FeatureFlagsEnum) -> bool:
return FeatureFlags._flags[key.value]
@staticmethod
async def has_feature(key: FeatureFlagsEnum) -> bool:
value = await featureFlagDao.find_by_key(key.value)
if value is None:
return False
async def has_feature(key: Union[str, FeatureFlagsEnum]) -> bool:
key_value = key.value if isinstance(key, FeatureFlagsEnum) else key
return value.value
value = await featureFlagDao.find_by_key(key_value)
return (
value.value
if value
else FeatureFlags.get_default(FeatureFlagsEnum(key_value))
)

View File

@ -2,5 +2,6 @@ from enum import Enum
class FeatureFlagsEnum(Enum):
# modules
version_endpoint = "VersionEndpoint"
technical_demo_banner = "TechnicalDemoBanner"
per_user_setup = "PerUserSetup"

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@ class DbModelABC(ABC):
from data.schemas.administration.user_dao import userDao
return await userDao.find_single_by({"id": self._editor_id})
return await userDao.get_by_id(self._editor_id)
@property
def created(self) -> datetime:
@ -63,3 +63,7 @@ class DbModelABC(ABC):
@property
def updated(self) -> datetime:
return self._updated
@updated.setter
def updated(self, value: datetime):
self._updated = value

View File

@ -14,6 +14,15 @@ class DbModelDaoABC[T_DBM](DataAccessObjectABC[T_DBM]):
self.attribute(DbModelABC.id, int, ignore=True)
self.attribute(DbModelABC.deleted, bool)
self.attribute(DbModelABC.editor_id, int, ignore=True)
self.attribute(DbModelABC.created, datetime, "created", ignore=True)
self.attribute(DbModelABC.updated, datetime, "updated", ignore=True)
self.attribute(DbModelABC.editor_id, int, ignore=True) # handled by db trigger
self.reference(
"editor", "id", DbModelABC.editor_id, "administration.users"
) # not relevant for updates due to editor_id
self.attribute(
DbModelABC.created, datetime, ignore=True
) # handled by db trigger
self.attribute(
DbModelABC.updated, datetime, ignore=True
) # handled by db trigger

View File

@ -1,9 +1,4 @@
from core.database.database_settings import DatabaseSettings
from core.database.db_context import DBContext
from core.environment import Environment
from core.logger import DBLogger
logger = DBLogger(__name__)
class Database:
@ -19,30 +14,3 @@ class Database:
@classmethod
def connect(cls):
cls._db.connect()
@classmethod
async def startup_db(cls):
from data.service.migration_service import MigrationService
logger.info("Init DB")
db = DBContext()
host = Environment.get("DB_HOST", str)
port = Environment.get("DB_PORT", int)
user = Environment.get("DB_USER", str)
password = Environment.get("DB_PASSWORD", str)
database = Environment.get("DB_DATABASE", str)
if None in [host, port, user, password, database]:
logger.fatal(
"DB settings are not set correctly",
EnvironmentError("DB settings are not set correctly"),
)
await db.connect(
DatabaseSettings(
host=host, port=port, user=user, password=password, database=database
)
)
Database.init(db)
migrations = MigrationService(db)
await migrations.migrate()

View File

@ -1,6 +1,9 @@
import uuid
from typing import Optional, Any
from psycopg import OperationalError
from psycopg_pool import PoolTimeout
from core.database.database_settings import DatabaseSettings
from core.database.postgres_pool import PostgresPool
from core.environment import Environment
@ -26,23 +29,23 @@ class DBContext:
except Exception as e:
logger.fatal("Connecting to database failed", e)
async def execute(self, statement: str, args=None) -> list[list]:
async def execute(self, statement: str, args=None, multi=True) -> list[list]:
logger.trace(f"execute {statement} with args: {args}")
return await self._pool.execute(statement, args)
return await self._pool.execute(statement, args, multi)
async def select_map(self, statement: str, args=None) -> list[dict]:
logger.trace(f"select {statement} with args: {args}")
try:
return await self._pool.select_map(statement, args)
except Exception as e:
except (OperationalError, PoolTimeout) as e:
if self._fails >= 3:
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
uid = uuid.uuid4()
raise Exception(
f"Query failed three times with {type(e).__name__}. Contact an admin with the UID: {uid}"
)
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
self._fails += 1
try:
logger.debug("Retry select")
@ -50,6 +53,9 @@ class DBContext:
except Exception as e:
pass
return []
except Exception as e:
logger.error(f"Database error caused by `{statement}`", e)
raise e
async def select(
self, statement: str, args=None
@ -57,15 +63,15 @@ class DBContext:
logger.trace(f"select {statement} with args: {args}")
try:
return await self._pool.select(statement, args)
except Exception as e:
except (OperationalError, PoolTimeout) as e:
if self._fails >= 3:
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
uid = uuid.uuid4()
raise Exception(
f"Query failed three times with {type(e).__name__}. Contact an admin with the UID: {uid}"
)
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
self._fails += 1
try:
logger.debug("Retry select")
@ -73,3 +79,6 @@ class DBContext:
except Exception as e:
pass
return []
except Exception as e:
logger.error(f"Database error caused by `{statement}`", e)
raise e

View File

@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, Any
from psycopg import sql
from psycopg_pool import AsyncConnectionPool, PoolTimeout
@ -21,7 +21,7 @@ class PostgresPool:
f"host={database_settings.host} "
f"port={database_settings.port} "
f"user={database_settings.user} "
f"password={B64Helper.decode(database_settings.password)} "
f"password={database_settings.password} "
f"dbname={database_settings.database}"
)
self._pool_size = pool_size
@ -41,18 +41,31 @@ class PostgresPool:
logger.fatal(f"Failed to connect to the database", e)
return pool
async def execute(self, query: str, args=None) -> list[list]:
@staticmethod
async def _exec_sql(cursor: Any, query: str, args=None, multi=True):
if multi:
queries = query.split(";")
for q in queries:
if q.strip() == "":
continue
await cursor.execute(sql.SQL(q), args)
else:
await cursor.execute(sql.SQL(query), args)
async def execute(self, query: str, args=None, multi=True) -> list[list]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in the psycopg module.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
async with await self._get_pool() as pool:
async with pool.connection() as con:
async with con.cursor() as cursor:
await cursor.execute(sql.SQL(query), args)
await self._exec_sql(cursor, query, args, multi)
if (
cursor.description is not None
@ -68,34 +81,36 @@ class PostgresPool:
else:
return []
async def select(self, query: str, args=None) -> list[str]:
async def select(self, query: str, args=None, multi=True) -> list[str]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in the psycopg module.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
async with await self._get_pool() as pool:
async with pool.connection() as con:
async with con.cursor() as cursor:
await cursor.execute(sql.SQL(query), args)
await self._exec_sql(cursor, query, args, multi)
res = await cursor.fetchall()
return list(res)
async def select_map(self, query: str, args=None) -> list[dict]:
async def select_map(self, query: str, args=None, multi=True) -> list[dict]:
"""
Execute a SQL statement, it could be with args and without args. The usage is
similar to the execute() function in the psycopg module.
:param query: SQL clause
:param args: args needed by the SQL clause
:param multi: if the query is a multi-statement
:return: return result
"""
async with await self._get_pool() as pool:
async with pool.connection() as con:
async with con.cursor() as cursor:
await cursor.execute(sql.SQL(query), args)
await self._exec_sql(cursor, query, args, multi)
res = await cursor.fetchall()
res_map: list[dict] = []

View File

@ -0,0 +1,150 @@
from typing import Optional
from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
class SQLSelectBuilder:
def __init__(self, table_name: str, primary_key: str):
self._table_name = table_name
self._primary_key = primary_key
self._temp_tables: dict[str, ExternalDataTempTableBuilder] = {}
self._to_use_temp_tables: list[str] = []
self._attributes: list[str] = []
self._tables: list[str] = [table_name]
self._joins: dict[str, (str, str)] = {}
self._conditions: list[str] = []
self._order_by: str = ""
self._limit: Optional[int] = None
self._offset: Optional[int] = None
def with_temp_table(
self, temp_table: ExternalDataTempTableBuilder
) -> "SQLSelectBuilder":
self._temp_tables[temp_table.table_name] = temp_table
return self
def use_temp_table(self, temp_table_name: str):
if temp_table_name not in self._temp_tables:
raise ValueError(f"Temp table {temp_table_name} not found.")
self._to_use_temp_tables.append(temp_table_name)
def with_attribute(self, attr: str, ignore_table_name=False) -> "SQLSelectBuilder":
if not ignore_table_name and not attr.startswith(self._table_name):
attr = f"{self._table_name}.{attr}"
self._attributes.append(attr)
return self
def with_foreign_attribute(self, attr: str) -> "SQLSelectBuilder":
self._attributes.append(attr)
return self
def with_table(self, table_name: str) -> "SQLSelectBuilder":
self._tables.append(table_name)
return self
def _check_prefix(self, attr: str) -> str:
assert attr is not None
valid_prefixes = [
"levenshtein",
self._table_name,
*self._joins.keys(),
*self._temp_tables.keys(),
]
if not any(attr.startswith(f"{prefix}.") for prefix in valid_prefixes):
attr = f"{self._table_name}.{attr}"
return attr
def with_value_condition(
self, attr: str, operator: str, value: str
) -> "SQLSelectBuilder":
attr = self._check_prefix(attr)
self._conditions.append(f"{attr} {operator} {value}")
return self
def with_levenshtein_condition(self, condition: str) -> "SQLSelectBuilder":
self._conditions.append(condition)
return self
def with_condition(self, attr: str, operator: str) -> "SQLSelectBuilder":
attr = self._check_prefix(attr)
self._conditions.append(f"{attr} {operator}")
return self
def with_grouped_conditions(self, conditions: list[str]) -> "SQLSelectBuilder":
self._conditions.append(f"({' AND '.join(conditions)})")
return self
def with_left_join(self, table: str, on: str) -> "SQLSelectBuilder":
if table in self._joins:
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "LEFT")
self._joins[table] = (on, "LEFT")
return self
def with_inner_join(self, table: str, on: str) -> "SQLSelectBuilder":
if table in self._joins:
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "INNER")
self._joins[table] = (on, "INNER")
return self
def with_right_join(self, table: str, on: str) -> "SQLSelectBuilder":
if table in self._joins:
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "RIGHT")
self._joins[table] = (on, "RIGHT")
return self
def with_limit(self, limit: int) -> "SQLSelectBuilder":
self._limit = limit
return self
def with_offset(self, offset: int) -> "SQLSelectBuilder":
self._offset = offset
return self
def with_order_by(self, column: str, direction: str = "ASC") -> "SQLSelectBuilder":
self._order_by = f"{column} {direction}"
return self
async def _handle_temp_table_use(self, query) -> str:
new_query = ""
for temp_table_name in self._to_use_temp_tables:
temp_table = self._temp_tables[temp_table_name]
new_query += await self._temp_tables[temp_table_name].build()
self.with_left_join(
temp_table.table_name,
f"{temp_table.join_ref_table}.{self._primary_key} = {temp_table.table_name}.{temp_table.primary_key}",
)
return f"{new_query} {query}" if new_query != "" else query
async def build(self) -> str:
query = await self._handle_temp_table_use("")
attributes = ", ".join(self._attributes) if self._attributes else "*"
query += f"SELECT {attributes} FROM {", ".join(self._tables)}"
for join in self._joins:
query += f" {self._joins[join][1]} JOIN {join} ON {self._joins[join][0]}"
if self._conditions:
query += " WHERE " + " AND ".join(self._conditions)
if self._order_by:
query += f" ORDER BY {self._order_by}"
if self._limit is not None:
query += f" LIMIT {self._limit}"
if self._offset is not None:
query += f" OFFSET {self._offset}"
return query

View File

@ -1,3 +1,4 @@
import uuid
from datetime import datetime
from typing import Optional
@ -28,10 +29,16 @@ class User(DbModelABC):
@property
def username(self):
if self._keycloak_id == str(uuid.UUID(int=0)):
return "ANONYMOUS"
return Keycloak.admin.get_user(self._keycloak_id).get("username")
@property
def email(self):
if self._keycloak_id == str(uuid.UUID(int=0)):
return "ANONYMOUS"
return Keycloak.admin.get_user(self._keycloak_id).get("email")
@async_property
@ -50,3 +57,9 @@ class User(DbModelABC):
from data.schemas.administration.user_dao import userDao
return await userDao.has_permission(self.id, permission)
async def anonymize(self):
from data.schemas.administration.user_dao import userDao
self._keycloak_id = str(uuid.UUID(int=0))
await userDao.update(self)

View File

@ -1,6 +1,7 @@
from typing import Optional, Union
from core.database.abc.db_model_dao_abc import DbModelDaoABC
from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
from core.logger import DBLogger
from data.schemas.administration.user import User
from data.schemas.permission.permission_dao import permissionDao
@ -14,7 +15,19 @@ class UserDao(DbModelDaoABC[User]):
def __init__(self):
DbModelDaoABC.__init__(self, __name__, User, "administration.users")
self.attribute(User.keycloak_id, str)
self.attribute(User.keycloak_id, str, aliases=["keycloakId"])
async def get_users():
return [(x.id, x.username, x.email) for x in await self.get_all()]
self.use_external_fields(
ExternalDataTempTableBuilder()
.with_table_name(self._table_name)
.with_field("id", "int", True)
.with_field("username", "text")
.with_field("email", "text")
.with_value_getter(get_users)
)
async def get_by_keycloak_id(self, keycloak_id: str) -> User:
return await self.get_single_by({User.keycloak_id: keycloak_id})

View File

@ -1,6 +1,8 @@
from datetime import datetime
from typing import Optional
from async_property import async_property
from core.database.abc.db_model_abc import DbModelABC
from core.typing import SerialId
@ -10,6 +12,7 @@ class Group(DbModelABC):
self,
id: SerialId,
name: str,
user_id: Optional[SerialId] = None,
deleted: bool = False,
editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None,
@ -17,6 +20,7 @@ class Group(DbModelABC):
):
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
self._name = name
self._user_id = user_id
@property
def name(self) -> str:
@ -25,3 +29,17 @@ class Group(DbModelABC):
@name.setter
def name(self, value: str):
self._name = value
@property
def user_id(self) -> Optional[SerialId]:
return self._user_id
@async_property
async def user(self):
if self._user_id is None:
return None
from data.schemas.administration.user_dao import userDao
user = await userDao.get_by_id(self.user_id)
return user

View File

@ -11,6 +11,9 @@ class GroupDao(DbModelDaoABC[Group]):
DbModelDaoABC.__init__(self, __name__, Group, "public.groups")
self.attribute(Group.name, str)
self.attribute(Group.user_id, int)
self.reference("user", "id", Group.user_id, "administration.users")
async def get_by_name(self, name: str) -> Group:
result = await self._db.select_map(
f"SELECT * FROM {self._table_name} WHERE Name = '{name}'"

View File

@ -18,6 +18,7 @@ class ShortUrl(DbModelABC):
group_id: Optional[SerialId],
domain_id: Optional[SerialId],
loading_screen: Optional[str] = None,
user_id: Optional[SerialId] = None,
deleted: bool = False,
editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None,
@ -34,6 +35,8 @@ class ShortUrl(DbModelABC):
loading_screen = False
self._loading_screen = loading_screen
self._user_id = user_id
@property
def short_url(self) -> str:
return self._short_url
@ -106,6 +109,20 @@ class ShortUrl(DbModelABC):
def loading_screen(self, value: Optional[str]):
self._loading_screen = value
@property
def user_id(self) -> Optional[SerialId]:
return self._user_id
@async_property
async def user(self):
if self._user_id is None:
return None
from data.schemas.administration.user_dao import userDao
user = await userDao.get_by_id(self.user_id)
return user
def to_dto(self) -> dict:
return {
"id": self.id,

View File

@ -18,5 +18,8 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]):
self.reference("domain", "id", ShortUrl.domain_id, "public.domains")
self.attribute(ShortUrl.loading_screen, bool)
self.attribute(ShortUrl.user_id, int)
self.reference("user", "id", ShortUrl.user_id, "administration.users")
shortUrlDao = ShortUrlDao()

View File

@ -21,4 +21,4 @@ class UserSettingDao(DbModelDaoABC[UserSetting]):
)
userSettingsDao = UserSettingDao()
userSettingDao = UserSettingDao()

View File

@ -17,4 +17,4 @@ class SettingDao(DbModelDaoABC[Setting]):
return await self.find_single_by({Setting.key: key})
settingsDao = SettingDao()
settingDao = SettingDao()

View File

@ -0,0 +1,11 @@
ALTER TABLE public.groups
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
ALTER TABLE public.groups_history
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
ALTER TABLE public.short_urls
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
ALTER TABLE public.short_urls_history
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);

View File

@ -0,0 +1,7 @@
-- Drop the existing unique constraint
ALTER TABLE administration.users DROP CONSTRAINT IF EXISTS UC_KeycloakId;
-- Add a partial unique index to allow guid-0 duplicates
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_keycloakid
ON administration.users (KeycloakId)
WHERE KeycloakId != '00000000-0000-0000-0000-000000000000';

View File

@ -21,6 +21,7 @@ class FeatureFlagsSeeder(DataSeederABC):
x.value: FeatureFlags.get_default(x) for x in FeatureFlagsEnum
}
# Create new feature flags
to_create = [
FeatureFlag(0, x, possible_feature_flags[x])
for x in possible_feature_flags.keys()
@ -31,6 +32,19 @@ class FeatureFlagsSeeder(DataSeederABC):
to_create_dicts = {x.key: x.value for x in to_create}
logger.debug(f"Created feature flags: {to_create_dicts}")
# Update existing feature flags if they can be overwritten and have a different value
to_update = [
FeatureFlag(x.id, x.key, possible_feature_flags[x.key])
for x in feature_flags
if FeatureFlags.overwrite_flag(x.key)
and x.value != possible_feature_flags[x.key]
]
if len(to_update) > 0:
await featureFlagDao.update_many(to_update)
to_update_dicts = {x.key: x.value for x in to_update}
logger.debug(f"Updated feature flags: {to_update_dicts}")
# Delete feature flags that are no longer defined
to_delete = [
x for x in feature_flags if x.key not in possible_feature_flags.keys()
]

View File

@ -3,7 +3,7 @@ from typing import Any
from core.logger import DBLogger
from data.abc.data_seeder_abc import DataSeederABC
from data.schemas.system.setting import Setting
from data.schemas.system.setting_dao import settingsDao
from data.schemas.system.setting_dao import settingDao
logger = DBLogger(__name__)
@ -18,8 +18,8 @@ class SettingsSeeder(DataSeederABC):
@staticmethod
async def _seed_if_not_exists(key: str, value: Any):
existing = await settingsDao.find_by_key(key)
existing = await settingDao.find_by_key(key)
if existing is not None:
return
await settingsDao.create(Setting(0, key, str(value)))
await settingDao.create(Setting(0, key, str(value)))

View File

@ -77,7 +77,7 @@ class MigrationService:
logger.debug(f"Running upgrade migration: {migration.name}")
await self._db.execute(migration.script)
await self._db.execute(migration.script, multi=False)
await executedMigrationDao.create(
ExecutedMigration(migration.name), skip_editor=True

View File

@ -0,0 +1,126 @@
import importlib
import json
from typing import Type
from api.auth.keycloak_client import Keycloak
from api.broadcast import broadcast
from core.database.abc.data_access_object_abc import DataAccessObjectABC
from core.database.abc.db_model_dao_abc import DbModelDaoABC
from core.logger import Logger
from core.string import first_to_lower
from data.schemas.administration.user_dao import userDao
logger = Logger("DataPrivacy")
class DataPrivacyService:
@staticmethod
def _dynamic_import_dao(dao_class: Type[DataAccessObjectABC]):
"""
Dynamically import a DAO class and its instance.
:param dao_class: The DAO class to be imported.
:return: The DAO instance.
"""
module = importlib.import_module(dao_class.__module__)
dao_instance = getattr(
module, first_to_lower(first_to_lower(dao_class.__name__))
)
return dao_instance
@classmethod
async def _collect_user_relevant_dao(cls):
"""
Collect all DAO classes that are relevant for data privacy.
:return: List of relevant DAO classes.
"""
# This method should return a list of DAOs that are relevant for data privacy
# For example, it could return a list of DAOs that contain user data
classes: list[DataAccessObjectABC] = [
cls._dynamic_import_dao(dao) for dao in DbModelDaoABC.__subclasses__()
]
return [x for x in classes if x.has_attribute("user_id")]
@classmethod
async def export_user_data(cls, user_id: int):
"""
Export user data from the database.
:param user_id: ID of the user whose data is to be exported.
:return: User data in a structured format.
"""
# Logic to export user data
user = await userDao.find_by_id(user_id)
if user is None:
raise ValueError("User not found")
collected_data = {"user": userDao.to_dict(await userDao.find_by_id(user_id))}
daos = await cls._collect_user_relevant_dao()
for dao in daos:
data = await dao.find_by([{"userid": user_id}])
collected_data[first_to_lower(type(dao).__name__.replace("Dao", "s"))] = [
dao.to_dict(x) for x in data
]
return json.dumps(collected_data, default=str)
@staticmethod
async def anonymize_user(user_id: int):
"""
Anonymize user data in the database.
:param user_id: ID of the user to be anonymized.
"""
user = await userDao.find_by_id(user_id)
if user is None:
raise ValueError("User not found")
keycloak_id = user.keycloak_id
# Anonymize internal data
await user.anonymize()
# Anonymize external data
try:
Keycloak.admin.delete_user(keycloak_id)
await broadcast.publish("userLogout", user.id)
except Exception as e:
logger.error(f"Failed to anonymize external data for user {user_id}", e)
raise ValueError("Failed to anonymize external data") from e
return True
@classmethod
async def delete_user_data(cls, user_id: int):
"""
Delete user data from the database.
:param user_id: ID of the user whose data is to be deleted.
"""
user = await userDao.find_by_id(user_id)
if user is None:
raise ValueError("User not found")
keycloak_id = user.keycloak_id
daos = await cls._collect_user_relevant_dao()
for dao in daos:
data = await dao.find_by([{"userid": user_id}])
try:
await dao.delete_many(data, hard_delete=True)
except Exception as e:
logger.error(f"Failed to delete data for user {user_id}", e)
raise ValueError("Failed to delete data") from e
try:
await userDao.delete(user)
except Exception as e:
logger.error(f"Failed to delete user {user_id}", e)
raise ValueError("Failed to delete user") from e
# Delete external data
try:
Keycloak.admin.delete_user(keycloak_id)
except Exception as e:
logger.error(f"Failed to delete external data for user {user_id}", e)
raise ValueError("Failed to delete external data") from e
return True

View File

@ -17,7 +17,7 @@
<app-footer></app-footer>
<p-toast></p-toast>
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000">
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000" styleClass="max-w-md">
<ng-template pTemplate="footer">
<div class="flex gap-2.5 items-center justify-end">
<p-button

View File

@ -32,6 +32,7 @@ import { ConfigService } from 'src/app/service/config.service';
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
import { SpinnerService } from 'src/app/service/spinner.service';
import { AuthService } from 'src/app/service/auth.service';
import { TranslationPreloaderService } from 'src/app/service/translation-preloader.service';
if (environment.production) {
Logger.enableProductionMode();
@ -39,8 +40,12 @@ if (environment.production) {
Logger.enableDevMode();
}
export function preloadTranslations(preloader: TranslationPreloaderService) {
return () => preloader.preloadLanguages();
}
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http);
return new TranslateHttpLoader(http, '/assets/i18n/', '.json');
}
export function appInitializerFactory(
@ -89,6 +94,12 @@ export function appInitializerFactory(
AppRoutingModule,
],
providers: [
{
provide: APP_INITIALIZER,
useFactory: preloadTranslations,
deps: [TranslationPreloaderService],
multi: true,
},
MessageService,
ConfirmationService,
DialogService,

View File

@ -11,7 +11,7 @@
<!-- <img src="/assets/images/logo.svg" alt="logo"/>-->
</div>
<div class="app-name">
<h1>LAN-Maestro</h1>
<h1>Open-Redirect</h1>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, EventEmitter, OnDestroy, OnInit } from '@angular/core';
import { MenuItem, PrimeNGConfig } from 'primeng/api';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -9,8 +9,13 @@ import { AuthService } from 'src/app/service/auth.service';
import { MenuElement } from 'src/app/model/view/menu-element';
import { SidebarService } from 'src/app/service/sidebar.service';
import { ConfigService } from 'src/app/service/config.service';
import { UserSettingsService } from 'src/app/service/user_settings.service';
import { UserSettingsService } from 'src/app/service/user-settings.service';
import { SettingsService } from 'src/app/service/settings.service';
import { DataPrivacyService } from 'src/app/service/data-privacy.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
import { Logger } from 'src/app/service/logger.service';
const logger = new Logger('Header');
@Component({
selector: 'app-header',
@ -28,6 +33,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
menu: MenuElement[] = [];
private langChange: EventEmitter<void> = new EventEmitter<void>();
constructor(
private translateService: TranslateService,
private ngConfig: PrimeNGConfig,
@ -36,8 +43,14 @@ export class HeaderComponent implements OnInit, OnDestroy {
private sidebarService: SidebarService,
private config: ConfigService,
private settings: SettingsService,
private userSettings: UserSettingsService
private userSettings: UserSettingsService,
private dataPrivacyService: DataPrivacyService,
private confirmation: ConfirmationDialogService
) {
this.langChange.subscribe(async () => {
await this.initUserMenuList();
});
this.guiService.isMobile$
.pipe(takeUntil(this.unsubscribe$))
.subscribe(isMobile => {
@ -48,11 +61,11 @@ export class HeaderComponent implements OnInit, OnDestroy {
});
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
await this.initMenuLists();
this.user = user;
await this.loadTheme();
await this.loadLang();
this.user = user;
this.guiService.loadedGuiSettings$.next(true);
});
@ -117,23 +130,71 @@ export class HeaderComponent implements OnInit, OnDestroy {
visible: !!this.user,
},
{
separator: true,
label: this.translateService.instant('header.privacy'),
items: [
{
label: this.translateService.instant('privacy.export_data'),
command: () => {
if (!this.user) {
return;
}
this.dataPrivacyService.downloadDataExportJson(this.user?.id);
},
icon: 'pi pi-download',
},
{
label: this.translateService.instant('privacy.delete_data'),
command: () => {
this.confirmation.confirmDialog({
header: 'privacy.delete_data_header',
message: 'privacy.delete_data_message',
accept: () => {
if (!this.user) {
return;
}
this.dataPrivacyService
.anonymizeData(this.user.id)
.subscribe(() => {});
},
});
},
icon: 'pi pi-trash',
},
],
},
{
label: this.translateService.instant('header.logout'),
command: async () => {
await this.auth.logout();
},
icon: 'pi pi-sign-out',
label: this.translateService.instant('header.profile'),
items: [
{
label: this.translateService.instant('header.edit_profile'),
command: () => {
window.open(
`${this.config.settings.keycloak.url}/realms/${this.config.settings.keycloak.realm}/account`,
'_blank'
);
},
icon: 'pi pi-user-edit',
},
{
label: this.translateService.instant('header.logout'),
command: async () => {
await this.auth.logout();
},
icon: 'pi pi-sign-out',
},
],
},
];
}
translate(lang: string) {
logger.debug('translate', lang);
this.translateService.use(lang);
this.translateService
.get('primeng')
.subscribe(res => this.ngConfig.setTranslation(res));
this.langChange.next();
}
async loadTheme() {

View File

@ -4,6 +4,7 @@ import { Logger } from 'src/app/service/logger.service';
import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
const log = new Logger('PermissionGuard');
@ -14,11 +15,18 @@ export class PermissionGuard {
constructor(
private router: Router,
private toast: ToastService,
private auth: AuthService
private auth: AuthService,
private features: FeatureFlagService
) {}
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const permissions = route.data['permissions'] as PermissionsEnum[];
const checkByPerUserSetup = route.data['checkByPerUserSetup'] as boolean;
const isPerUserSetup = await this.features.get('PerUserSetup');
if (checkByPerUserSetup && isPerUserSetup) {
return true;
}
if (!permissions || permissions.length === 0) {
return true;

View File

@ -1,5 +1,16 @@
import { Theme } from 'src/app/model/view/theme';
export interface AppSettingsFromConfig {
termsUrl: string;
privacyURL: string;
imprintURL: string;
themes: Theme[];
loadingScreen: LoadingScreenSettings;
keycloak: KeycloakSettings;
api: ApiSettings;
languages?: string[];
}
export interface AppSettings {
termsUrl: string;
privacyURL: string;
@ -8,6 +19,7 @@ export interface AppSettings {
loadingScreen: LoadingScreenSettings;
keycloak: KeycloakSettings;
api: ApiSettings;
languages: string[];
}
export interface LoadingScreenSettings {

View File

@ -22,7 +22,7 @@ const routes: Routes = [
m => m.GroupsModule
),
canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.groups] },
data: { permissions: [PermissionsEnum.groups], checkByPerUserSetup: true },
},
{
path: 'urls',
@ -36,6 +36,7 @@ const routes: Routes = [
PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment,
],
checkByPerUserSetup: true,
},
},
{

View File

@ -228,7 +228,7 @@ export class DomainsDataService
return this.apollo
.mutate<{ domain: { delete: boolean } }>({
mutation: gql`
mutation deleteDomain($id: ID!) {
mutation deleteDomain($id: Int!) {
domain {
delete(id: $id)
}
@ -251,7 +251,7 @@ export class DomainsDataService
return this.apollo
.mutate<{ domain: { restore: boolean } }>({
mutation: gql`
mutation restoreDomain($id: ID!) {
mutation restoreDomain($id: Int!) {
domain {
restore(id: $id)
}

View File

@ -27,8 +27,9 @@
type="text"
formControlName="name"/>
</div>
<div class="divider"></div>
<div *ngIf="!isPerUserSetup" class="divider"></div>
<p-multiSelect
*ngIf="!isPerUserSetup"
[options]="roles"
formControlName="roles"
optionLabel="name"

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from 'src/app/core/base/form-page-base';
@ -10,28 +10,40 @@ import {
import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service';
import { Role } from 'src/app/model/entities/role';
import { CommonDataService } from 'src/app/modules/shared/service/common-data.service';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-group-form-page',
templateUrl: './group-form-page.component.html',
styleUrl: './group-form-page.component.scss',
})
export class GroupFormPageComponent extends FormPageBase<
Group,
GroupCreateInput,
GroupUpdateInput,
GroupsDataService
> {
export class GroupFormPageComponent
extends FormPageBase<
Group,
GroupCreateInput,
GroupUpdateInput,
GroupsDataService
>
implements OnInit
{
roles: Role[] = [];
isPerUserSetup = true;
constructor(
private features: FeatureFlagService,
private toast: ToastService,
private cds: CommonDataService
) {
super();
this.cds.getAllRoles().subscribe(roles => {
this.roles = roles;
});
}
async ngOnInit() {
this.isPerUserSetup = await this.features.get('PerUserSetup');
if (!this.isPerUserSetup) {
this.cds.getAllRoles().subscribe(roles => {
this.roles = roles;
});
}
if (!this.nodeId) {
this.node = this.new();

View File

@ -238,7 +238,7 @@ export class GroupsDataService
return this.apollo
.mutate<{ group: { delete: boolean } }>({
mutation: gql`
mutation deleteGroup($id: ID!) {
mutation deleteGroup($id: Int!) {
group {
delete(id: $id)
}
@ -261,7 +261,7 @@ export class GroupsDataService
return this.apollo
.mutate<{ group: { restore: boolean } }>({
mutation: gql`
mutation restoreGroup($id: ID!) {
mutation restoreGroup($id: Int!) {
group {
restore(id: $id)
}

View File

@ -21,7 +21,8 @@ const routes: Routes = [
component: GroupFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysCreate],
permissions: [PermissionsEnum.groupsCreate],
checkByPerUserSetup: true,
},
},
{
@ -29,7 +30,8 @@ const routes: Routes = [
component: GroupFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysUpdate],
permissions: [PermissionsEnum.groupsUpdate],
checkByPerUserSetup: true,
},
},
{
@ -37,7 +39,8 @@ const routes: Routes = [
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.domains],
permissions: [PermissionsEnum.groups],
checkByPerUserSetup: true,
},
},
],

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { PageBase } from 'src/app/core/base/page-base';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
@ -6,28 +6,63 @@ import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { Group } from 'src/app/model/entities/group';
import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service';
import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns';
import { AuthService } from 'src/app/service/auth.service';
import { ConfigService } from 'src/app/service/config.service';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-groups',
templateUrl: './groups.page.html',
styleUrl: './groups.page.scss',
})
export class GroupsPage extends PageBase<
Group,
GroupsDataService,
GroupsColumns
> {
export class GroupsPage
extends PageBase<Group, GroupsDataService, GroupsColumns>
implements OnInit
{
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService
private confirmation: ConfirmationDialogService,
private auth: AuthService,
private config: ConfigService,
private features: FeatureFlagService
) {
super(true, {
read: [PermissionsEnum.groups],
create: [PermissionsEnum.groupsCreate],
update: [PermissionsEnum.groupsUpdate],
delete: [PermissionsEnum.groupsDelete],
restore: [PermissionsEnum.groupsDelete],
});
super(true);
}
async ngOnInit() {
this.requiredPermissions = {
read: (await this.features.get('PerUserSetup'))
? []
: [PermissionsEnum.groups],
create: (await this.features.get('PerUserSetup'))
? []
: (await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.create ?? []
))
? (this.requiredPermissions.create ?? [])
: [],
update: (await this.features.get('PerUserSetup'))
? []
: (await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.update ?? []
))
? (this.requiredPermissions.update ?? [])
: [],
delete: (await this.features.get('PerUserSetup'))
? []
: (await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.delete ?? []
))
? (this.requiredPermissions.delete ?? [])
: [],
restore: (await this.features.get('PerUserSetup'))
? []
: (await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.restore ?? []
))
? (this.requiredPermissions.restore ?? [])
: [],
};
}
load(silent?: boolean): void {

View File

@ -65,7 +65,7 @@
></p-dropdown>
</div>
</div>
<div class="form-page-input">
<div class="form-page-input" *ngIf="!isPerUserSetup">
<p class="label">{{ 'common.domain' | translate }}</p>
<div
class="value">

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from 'src/app/core/base/form-page-base';
@ -10,29 +10,45 @@ import {
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-short-url-form-page',
templateUrl: './short-url-form-page.component.html',
styleUrl: './short-url-form-page.component.scss',
})
export class ShortUrlFormPageComponent extends FormPageBase<
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
ShortUrlsDataService
> {
export class ShortUrlFormPageComponent
extends FormPageBase<
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
ShortUrlsDataService
>
implements OnInit
{
groups: Group[] = [];
domains: Domain[] = [];
constructor(private toast: ToastService) {
isPerUserSetup = true;
constructor(
private features: FeatureFlagService,
private toast: ToastService
) {
super();
this.dataService.getAllGroups().subscribe(groups => {
this.groups = groups;
});
this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains;
});
}
async ngOnInit() {
this.isPerUserSetup = await this.features.get('PerUserSetup');
if (!this.isPerUserSetup) {
this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains;
});
}
if (!this.nodeId) {
this.node = this.new();

View File

@ -1,5 +1,5 @@
import { Injectable, Provider } from '@angular/core';
import { merge, Observable } from 'rxjs';
import { forkJoin, merge, Observable } from 'rxjs';
import {
Create,
Delete,
@ -48,50 +48,88 @@ export class ShortUrlsDataService
skip?: number | undefined,
take?: number | undefined
): Observable<QueryResult<ShortUrl>> {
return this.apollo
.query<{ shortUrls: QueryResult<ShortUrl> }>({
query: gql`
query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
shortUrls(filter: $filter, sort: $sort) {
count
totalCount
nodes {
const query1 = this.apollo.query<{ shortUrls: QueryResult<ShortUrl> }>({
query: gql`
query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
shortUrls(filter: $filter, sort: $sort) {
nodes {
id
shortUrl
targetUrl
description
loadingScreen
visits
group {
id
shortUrl
targetUrl
description
loadingScreen
visits
group {
id
name
}
domain {
id
name
}
...DB_MODEL
name
}
domain {
id
name
}
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])],
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
skip: skip,
take: take,
},
${DB_MODEL_FRAGMENT}
`,
variables: {
filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])],
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
skip,
take,
},
});
const query2 = this.apollo.query<{ shortUrls: QueryResult<ShortUrl> }>({
query: gql`
query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
shortUrls(filter: $filter, sort: $sort) {
nodes {
id
shortUrl
targetUrl
description
loadingScreen
visits
group {
id
name
}
domain {
id
name
}
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
filter: [{ group: { isNull: true } }, ...(filter ?? [])],
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
skip,
take,
},
});
return forkJoin([query1, query2]).pipe(
map(([result1, result2]) => {
const nodes = [
...result1.data.shortUrls.nodes,
...result2.data.shortUrls.nodes,
];
const uniqueNodes = Array.from(
new Map(nodes.map(node => [node.id, node])).values()
);
return { ...result1.data.shortUrls, nodes: uniqueNodes };
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.shortUrls));
);
}
loadById(id: number): Observable<ShortUrl> {
@ -285,7 +323,7 @@ export class ShortUrlsDataService
return this.apollo
.mutate<{ shortUrl: { delete: boolean } }>({
mutation: gql`
mutation deleteShortUrl($id: ID!) {
mutation deleteShortUrl($id: Int!) {
shortUrl {
delete(id: $id)
}
@ -308,7 +346,7 @@ export class ShortUrlsDataService
return this.apollo
.mutate<{ shortUrl: { restore: boolean } }>({
mutation: gql`
mutation restoreShortUrl($id: ID!) {
mutation restoreShortUrl($id: Int!) {
shortUrl {
restore(id: $id)
}

View File

@ -22,6 +22,7 @@ const routes: Routes = [
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.shortUrlsCreate],
checkByPerUserSetup: true,
},
},
{
@ -30,6 +31,7 @@ const routes: Routes = [
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.shortUrlsUpdate],
checkByPerUserSetup: true,
},
},
{
@ -37,7 +39,8 @@ const routes: Routes = [
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.domains],
permissions: [PermissionsEnum.shortUrls],
checkByPerUserSetup: true,
},
},
],

View File

@ -41,7 +41,7 @@
icon="pi pi-pencil"
tooltipPosition="left"
pTooltip="{{ 'table.update' | translate }}"
[disabled]="url?.deleted"
[disabled]="url.deleted"
routerLink="edit/{{ url.id }}"></p-button>
<p-button
*ngIf="hasPermissions.delete"
@ -49,10 +49,10 @@
icon="pi pi-trash"
tooltipPosition="left"
pTooltip="{{ 'table.delete' | translate }}"
[disabled]="url?.deleted"
[disabled]="url.deleted"
(click)="delete(url)"></p-button>
<p-button
*ngIf="url?.deleted && hasPermissions.restore"
*ngIf="url.deleted && hasPermissions.restore"
class="icon-btn btn"
icon="pi pi-undo"
tooltipPosition="left"

View File

@ -12,6 +12,7 @@ import QrCodeWithLogo from 'qrcode-with-logos';
import { FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload';
import { ConfigService } from 'src/app/service/config.service';
import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({
selector: 'app-short-urls',
@ -67,7 +68,8 @@ export class ShortUrlsPage
private toast: ToastService,
private confirmation: ConfirmationDialogService,
private auth: AuthService,
private config: ConfigService
private config: ConfigService,
private features: FeatureFlagService
) {
super(true, {
read: [PermissionsEnum.shortUrls],
@ -80,21 +82,26 @@ export class ShortUrlsPage
async ngOnInit() {
this.hasPermissions = {
read: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.read ?? []
),
create: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.create ?? []
),
update: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.update ?? []
),
delete: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.delete ?? []
),
restore: await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.restore ?? []
),
read:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.read ?? []
)) || (await this.features.get('PerUserSetup')),
create:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.create ?? []
)) || (await this.features.get('PerUserSetup')),
update:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.update ?? []
)) || (await this.features.get('PerUserSetup')),
delete:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.delete ?? []
)) || (await this.features.get('PerUserSetup')),
restore:
(await this.auth.hasAnyPermissionLazy(
this.requiredPermissions.restore ?? []
)) || (await this.features.get('PerUserSetup')),
};
}

View File

@ -1,7 +1,10 @@
import { Injectable } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppSettings } from 'src/app/model/config/app-settings';
import {
AppSettings,
AppSettingsFromConfig,
} from 'src/app/model/config/app-settings';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
@ -33,6 +36,7 @@ export class ConfigService {
realm: '',
clientId: '',
},
languages: ['en', 'de'],
};
constructor(private http: HttpClient) {}
@ -40,7 +44,7 @@ export class ConfigService {
loadSettings(): Promise<void> {
return new Promise((resolve, reject) => {
this.http
.get<AppSettings>(`/assets/config/${environment.config}`)
.get<AppSettingsFromConfig>(`/assets/config/${environment.config}`)
.pipe(
catchError(error => {
reject(error);
@ -48,13 +52,17 @@ export class ConfigService {
})
)
.subscribe(settings => {
this.settings = settings;
if (this.settings.themes.length === 0) {
this.settings.themes.push({
if (settings.themes.length === 0) {
settings.themes.push({
label: 'Maxlan',
name: 'maxlan',
});
}
if (settings.languages?.length === 0) {
settings.languages = ['en', 'de'];
}
this.settings = settings as AppSettings;
resolve();
});
});

View File

@ -0,0 +1,77 @@
import { Injectable } from '@angular/core';
import { Apollo, gql } from 'apollo-angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class DataPrivacyService {
constructor(private apollo: Apollo) {}
downloadDataExportJson(userId: number): void {
this.exportDataPrivacy(userId).subscribe(dataString => {
const jsonData = JSON.stringify(JSON.parse(dataString), null, 2); // Format nicely
const blob = new Blob([jsonData], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'exported-data.json';
anchor.click();
window.URL.revokeObjectURL(url);
});
}
exportDataPrivacy(userId: number): Observable<string> {
return this.apollo
.mutate<{ privacy: { exportData: string } }>({
mutation: gql`
mutation exportDataPrivacy($userId: Int!) {
privacy {
exportData(userId: $userId)
}
}
`,
variables: {
userId,
},
})
.pipe(map(result => result.data?.privacy.exportData ?? ''));
}
anonymizeData(userId: number): Observable<boolean> {
return this.apollo
.mutate<{ privacy: { anonymizeData: boolean } }>({
mutation: gql`
mutation anonymizeDataPrivacy($userId: Int!) {
privacy {
anonymizeData(userId: $userId)
}
}
`,
variables: {
userId,
},
})
.pipe(map(result => result.data?.privacy.anonymizeData ?? false));
}
deleteData(userId: number): Observable<boolean> {
return this.apollo
.mutate<{ privacy: { deleteData: boolean } }>({
mutation: gql`
mutation deleteDataPrivacy($userId: Int!) {
privacy {
deleteData(userId: $userId)
}
}
`,
variables: {
userId,
},
})
.pipe(map(result => result.data?.privacy.deleteData ?? false));
}
}

View File

@ -3,6 +3,7 @@ import { BehaviorSubject } from 'rxjs';
import { MenuElement } from 'src/app/model/view/menu-element';
import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Injectable({
providedIn: 'root',
@ -11,7 +12,10 @@ export class SidebarService {
visible$ = new BehaviorSubject<boolean>(true);
elements$ = new BehaviorSubject<MenuElement[]>([]);
constructor(private auth: AuthService) {
constructor(
private auth: AuthService,
private featureFlags: FeatureFlagService
) {
this.auth.user$.subscribe(async () => {
await this.setElements();
});
@ -40,16 +44,19 @@ export class SidebarService {
label: 'common.groups',
icon: 'pi pi-tags',
routerLink: ['/admin/groups'],
visible: await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups]),
visible:
(await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])) ||
(await this.featureFlags.get('PerUserSetup')),
},
{
label: 'common.urls',
icon: 'pi pi-tag',
routerLink: ['/admin/urls'],
visible: await this.auth.hasAnyPermissionLazy([
PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment,
]),
visible:
(await this.auth.hasAnyPermissionLazy([
PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment,
])) || (await this.featureFlags.get('PerUserSetup')),
},
await this.sectionAdmin(),
];

View File

@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { HttpClient } from '@angular/common/http';
import { lastValueFrom } from 'rxjs';
import { ConfigService } from 'src/app/service/config.service';
@Injectable({ providedIn: 'root' })
export class TranslationPreloaderService {
constructor(
private translate: TranslateService,
private http: HttpClient,
private config: ConfigService
) {}
preloadLanguages(): Promise<void> {
const loadRequests = this.config.settings.languages.map(async lang => {
const translations = await lastValueFrom(
this.http.get(`/assets/i18n/${lang}.json`)
);
this.translate.setTranslation(lang, translations, true); // merge = true
});
return Promise.all(loadRequests).then(() => {});
}
}

View File

@ -76,7 +76,10 @@
"count_header": "Gruppe(n)"
},
"header": {
"logout": "Ausloggen"
"edit_profile": "Profil bearbeiten",
"logout": "Ausloggen",
"privacy": "Datenschutz",
"profile": "Profil"
},
"history": {
"header": "Historie"
@ -211,6 +214,12 @@
"weak": "Woche",
"weekHeader": "Wk"
},
"privacy": {
"delete_data": "Daten löschen",
"delete_data_header": "Bestätigung zur Löschung deiner personenbezogenen Daten",
"delete_data_message": "Bestätigung zur Löschung deiner personenbezogenen Daten<br><br>Du bist dabei, dein Konto dauerhaft zu löschen.<br>Damit werden deine personenbezogenen Daten unkenntlich gemacht.<br>Eine Wiederherstellung deines Kontos oder deiner Daten ist anschließend nicht mehr möglich.<br><br>Hinweis:<br>Aus rechtlichen oder betrieblichen Gründen (z.B. gesetzliche Aufbewahrungspflichten) können bestimmte Daten weiterhin in anonymisierter oder pseudonymisierter Form gespeichert bleiben. Diese enthalten keine Informationen mehr, die dich als Person identifizierbar machen.<br><br>Bitte bestätige, dass du dies verstanden hast und mit der Löschung fortfahren möchtest.",
"export_data": "Daten exportieren"
},
"role": {
"count_header": "Rolle(n)"
},

View File

@ -76,7 +76,10 @@
"count_header": "Group(s)"
},
"header": {
"logout": "Logout"
"edit_profile": "Edit profile",
"logout": "Logout",
"privacy": "Privacy",
"profile": "Profile"
},
"history": {
"header": "History"
@ -211,6 +214,12 @@
"weak": "Weak",
"weekHeader": "Wk"
},
"privacy": {
"delete_data": "Delete data",
"delete_data_header": "Confirmation of deletion of your personal data",
"delete_data_message": "Confirmation of deletion of your personal data<br><br>You are about to permanently delete your account.<br>Your personal data will be anonymized and cannot be recovered.<br>It will no longer be possible to restore your account or associated data.<br><br>Note:<br>For legal or operational reasons (e.g. statutory retention obligations), certain data may continue to be stored in anonymized or pseudonymized form. This data no longer contains any information that can identify you as a person.<br><br>Please confirm that you understand this and wish to proceed with the deletion.",
"export_data": "Export data"
},
"role": {
"count_header": "Role(s)"
},