Skip to content

Commit

Permalink
app/permissions: implement ability to custom app tile logo, descripti…
Browse files Browse the repository at this point in the history
…on, order, hide from public app list view
  • Loading branch information
alexAubin committed Feb 7, 2025
1 parent a28bc90 commit 8de021b
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 59 deletions.
21 changes: 19 additions & 2 deletions share/actionsmap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,27 @@ user:
help: Permission to manage (e.g. mail or nextcloud or wordpress.editors) (use "yunohost user permission list" and "yunohost user permission -f" to see all the current permissions)
-l:
full: --label
help: Label for this permission. This label will be shown on the SSO and in the admin
help: Custom label for this app / permission
-s:
full: --show_tile
help: Define if a tile will be shown in the SSO
help: Define if a tile will be shown in the user portal
choices:
- 'True'
- 'False'
-L:
full: --logo
help: File to use as logo for this app / permission. Only PNG are supported.
type: argparse.FileType('rb')
-d:
full: --description
help: Custom description for this app / permission
-o:
full: --order
help: Order number to be used when displaying the tiles in the user portal. Default is 100 so set this to any low value for the tile to appear first, or higher value to appear last.
type: int
-H:
full: --hide_from_public
help: Mark the tile as to be hidden from the 'public app list' (if enabled). Useful for apps such as Nextcloud that need to be exposed to be publicly exposed for desktop/mobile client to be able to connect to, but not meant to be listed for visitors.
choices:
- 'True'
- 'False'
Expand Down
28 changes: 16 additions & 12 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1618,24 +1618,23 @@ def app_register_url(app, domain, path):
_sync_permissions_with_ldap()


def app_ssowatconf():
def app_ssowatconf() -> None:
"""
Regenerate SSOwat configuration file
"""
from yunohost.domain import (
_get_domain_portal_dict,
_get_raw_domain_settings,
domain_list,
)
from yunohost.permission import user_permission_list
from yunohost.permission import AppPermInfos, user_permission_list

domain_portal_dict = _get_domain_portal_dict()

domains = domain_list()["domains"]
portal_domains = domain_list(exclude_subdomains=True)["domains"]
all_permissions = user_permission_list(
all_permissions: dict[str, AppPermInfos] = user_permission_list(
full=True, ignore_system_perms=True, absolute_urls=True
)["permissions"]

Expand Down Expand Up @@ -1675,7 +1674,9 @@ def app_ssowatconf():
redirected_urls[domain + "/"] = domain_portal_dict[domain]

# Will organize apps by portal domain
portal_domains_apps = {domain: {} for domain in portal_domains}
portal_domains_apps: dict[str, dict[str, dict]] = {
domain: {} for domain in portal_domains
}

# This check is to prevent an issue during postinstall if the catalog cant
# be initialized (because of offline postinstall) and it's not a big deal
Expand Down Expand Up @@ -1765,16 +1766,19 @@ def app_ssowatconf():
"users": perm_info["corresponding_users"],
"public": "visitors" in perm_info["allowed"],
"url": uris[0],
"description": local_manifest["description"],
"description": perm_info.get("description") or local_manifest["description"],
"order": perm_info.get("order", 100),
}

# FIXME : find a smarter way to get this info ? (in the settings maybe..)
# Also ideally we should not rely on the webadmin route for this, maybe expose these through a different route in nginx idk
# Also related to "people will want to customize those.."
app_catalog_info = apps_catalog.get(app_id.split("__")[0])
if app_catalog_info and "logo_hash" in app_catalog_info:
if perm_info.get("hide_from_public"):
app_portal_info["hide_from_public"] = True

# Logo may be customized via the perm setting, otherwise we use the default logo that we fetch from the catalog infos
app_base_id = app_id.split("__")[0]
logo_hash = perm_info.get("logo_hash") or apps_catalog.get(app_base_id, {}).get("logo_hash")
if logo_hash:
app_portal_info["logo"] = (
f"/yunohost/sso/applogos/{app_catalog_info['logo_hash']}.png"
f"/yunohost/sso/applogos/{logo_hash}.png"
)

portal_domains_apps[app_portal_domain][perm_name] = app_portal_info
Expand Down
99 changes: 70 additions & 29 deletions src/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import re
import os
from logging import getLogger
from typing import TYPE_CHECKING, Literal, TypedDict, NotRequired, cast
from typing import TYPE_CHECKING, BinaryIO, Literal, TypedDict, NotRequired, cast

from moulinette import m18n
from moulinette.utils.filesystem import read_yaml, write_to_yaml
Expand Down Expand Up @@ -56,6 +56,10 @@ class AppPermInfos(SystemPermInfos):
auth_header: bool
protected: bool
show_tile: bool | None
hide_from_public: NotRequired[bool]
logo_hash: NotRequired[str]
description: NotRequired[str]
order: NotRequired[int]


PermInfos = AppPermInfos | SystemPermInfos
Expand All @@ -81,10 +85,6 @@ def user_permission_list(
from yunohost.app import _installed_apps, _get_app_settings
from yunohost.user import user_group_list

map_group_to_users = {
g: infos["members"] for g, infos in user_group_list()["groups"].items()
}

# Parse / organize information to be outputed
filter_ = apps
if filter_:
Expand All @@ -97,13 +97,15 @@ def user_permission_list(
settings = _get_app_settings(app)

subperms = settings.get("_permissions", {})
default_app_label = settings.get("label") or app.title()
if "main" not in subperms:
subperms["main"] = {}

app_label = subperms["main"].get("label") or settings.get("label") or app.title()

for subperm, infos in subperms.items():
name = f"{app}.{subperm}"
perm: AppPermInfos = {
"label": default_app_label,
"label": "",
"url": None,
"additional_urls": [],
"auth_header": True,
Expand All @@ -113,7 +115,12 @@ def user_permission_list(
}
perm.update(infos)
if subperm != "main":
perm["label"] += " (" + settings.get("label", subperm) + ")"
# Redefine the subperm label to : <main_label> (<subperm>)
subperm_label = (perm["label"] or subperm.title())
perm["label"] = f"{app_label} ({subperm_label})"
elif not perm["label"]:
perm["label"] = app_label

if perm["show_tile"] is None and perm["url"] is not None:
perm["show_tile"] = True

Expand Down Expand Up @@ -147,18 +154,27 @@ def user_permission_list(
permissions[f"{name}.main"] = system_perm_conf[name]

if full:
for permission in permissions.values():
permission["corresponding_users"] = set()
for group in permission["allowed"]:
map_group_to_users = {
g: infos["members"] for g, infos in user_group_list()["groups"].items()
}
for infos in permissions.values():
infos["corresponding_users"] = set()
for group in infos["allowed"]:
# FIXME: somewhere we may want to have some sort of garbage collection
# to automatically remove user/groups from the "allowed" info when they
# somehow disappared from the system (for example this may happen when
# somehow disappeared from the system (for example this may happen when
# restoring an app on which not all the user/group exist)
users_in_group = set(map_group_to_users.get(group, []))
permission["corresponding_users"] |= users_in_group # type: ignore
permission["corresponding_users"] = list(
sorted(permission["corresponding_users"])
infos["corresponding_users"] |= users_in_group
infos["corresponding_users"] = list(
sorted(infos["corresponding_users"])
)
else:
# Keep the output concise when used without --full, meant to not bloat CLI
for infos in permissions.values():
for key in ["additional_urls", "auth_header", "logo_hash", "order", "protected", "show_tile"]:
if key in infos:
del infos[key]

return {"permissions": permissions}

Expand Down Expand Up @@ -268,13 +284,6 @@ def user_permission_update(
if "visitors" not in new_allowed_groups or len(new_allowed_groups) >= 3:
logger.warning(m18n.n("permission_currently_allowed_for_all_users"))

# Note that we can get this argument as string if we it come from the CLI
if isinstance(show_tile, str):
if show_tile.lower() == "true":
show_tile = True
else:
show_tile = False

if (
existing_permission.get("url")
and existing_permission["url"].startswith("re:") # type: ignore
Expand Down Expand Up @@ -560,7 +569,7 @@ def permission_url(
def permission_delete(
permission: str, force: bool = False, sync_perm: bool = True
) -> None:
from yunohost.app import app_setting, _is_installed, app_ssowatconf
from yunohost.app import app_setting, _assert_is_installed, app_ssowatconf

# By default, manipulate main permission
if "." not in permission:
Expand All @@ -571,12 +580,16 @@ def permission_delete(

app, subperm = permission.split(".")

if _is_installed(app):
# Actually delete the permission
perm_settings = app_setting(app, "_permissions") or {}
if subperm in perm_settings:
del perm_settings[subperm]
app_setting(app, "_permissions", perm_settings)
if app in SYSTEM_PERMS:
raise YunohostValidationError(f"Cannot delete system permission {permission}", raw_msg=True)

_assert_is_installed(app)

# Actually delete the permission
perm_settings = app_setting(app, "_permissions") or {}
if subperm in perm_settings:
del perm_settings[subperm]
app_setting(app, "_permissions", perm_settings)

if sync_perm:
_sync_permissions_with_ldap()
Expand Down Expand Up @@ -699,6 +712,10 @@ def _update_app_permission_setting(
show_tile: bool | None = None,
protected: bool | None = None,
allowed: str | list[str] | None = None,
logo: BinaryIO | None = None,
description: str | None = None,
hide_from_public: bool | None = None,
order: int | None = None,
) -> None:
from yunohost.app import app_setting

Expand All @@ -711,6 +728,30 @@ def _update_app_permission_setting(
if label is not None:
update_settings["label"] = str(label)

if description is not None:
update_settings["description"] = description

if hide_from_public is not None:
update_settings["hide_from_public"] = hide_from_public

if order is not None:
update_settings["order"] = order

if logo is not None:

from yunohost.app import APPS_CATALOG_LOGOS
import hashlib

logo_content = logo.read()
if not logo_content.startswith(b"\x89PNG\r\n\x1a\n"):
raise YunohostValidationError("The provided logo file doesn't seem to be a PNG file. Only PNG logos are supported.", raw_msg=True)

logo_hash = hashlib.sha256(logo_content).hexdigest()
with open(f"{APPS_CATALOG_LOGOS}/{logo_hash}.png", "wb") as f:
f.write(logo_content)

update_settings["logo_hash"] = logo_hash

if protected is not None:
update_settings["protected"] = protected

Expand Down
20 changes: 14 additions & 6 deletions src/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def _get_user_infos(

def _get_portal_settings(
domain: Union[str, None] = None, username: Union[str, None] = None
):
) -> dict[str, Any]:
"""
Returns domain's portal settings which are a combo of domain's portal config panel options
and the list of apps availables on this domain computed by `app.app_ssowatconf()`.
Expand Down Expand Up @@ -101,19 +101,27 @@ def _get_portal_settings(
if username:
# Add user allowed or public apps
settings["apps"] = {
name: app
for name, app in apps.items()
if username in app["users"] or app["public"]
app: infos
for app, infos in apps.items()
if username in infos["users"] or infos["public"]
}
elif settings["public"]:
# Add public apps (e.g. with "visitors" in group permission)
settings["apps"] = {name: app for name, app in apps.items() if app["public"]}
settings["apps"] = {
app: infos
for app, infos in apps.items()
if infos["public"] and not infos.get("hide_from_public")
}

# Sort dictionnary according to the "order" info
settings["apps"] = dict(sorted([(app, infos) for app, infos in settings["apps"].items()], key=lambda v: (v[1].get("order", 100), v[0])))

return settings


def portal_public():
"""Get public settings
"""
Get public settings
If the portal is set as public, it will include the list of public apps
"""

Expand Down
51 changes: 42 additions & 9 deletions src/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import re
import subprocess
from logging import getLogger
from typing import TYPE_CHECKING, Any, Callable, TextIO, Union, cast, Literal
from typing import TYPE_CHECKING, Any, Callable, TextIO, BinaryIO, Union, cast, Literal

from moulinette import Moulinette, m18n
from moulinette.utils.process import check_output
Expand Down Expand Up @@ -1494,16 +1494,49 @@ def user_permission_list(
@is_unit_operation(flash=True)
def user_permission_update(
permission: str,
label: Optional[str] = None,
show_tile: Optional[bool] = None,
sync_perm: bool = True,
) -> "PermInfos":
from yunohost.permission import user_permission_update

return user_permission_update(
permission, label=label, show_tile=show_tile, sync_perm=sync_perm
label: str | None = None,
show_tile: bool | None = None,
logo: BinaryIO | None = None,
description: str | None = None,
hide_from_public: bool | None = None,
order: int | None = None,
) -> dict[str, Any]:

from yunohost.app import _assert_is_installed, app_ssowatconf, app_setting
from yunohost.permission import _update_app_permission_setting

# By default, manipulate main permission
if "." not in permission:
permission = permission + ".main"

app, permname = permission.split(".", 1)
_assert_is_installed(app)

if permname not in (app_setting(app, "_permissions") or {}):
raise YunohostValidationError(f"Unknown permission {permname} for app {app}", raw_msg=True)

# We get these from CLI as string (because we want to be able to differentiate between True, False and "unspecified" = "do not change the value"
if isinstance(show_tile, str):
show_tile = True if show_tile.lower() == "true" else False
if isinstance(hide_from_public, str):
hide_from_public = True if hide_from_public.lower() == "true" else False

_update_app_permission_setting(
permission=permission,
label=label,
show_tile=show_tile,
logo=logo,
description=description,
hide_from_public=hide_from_public,
order=order,
)

app_ssowatconf()

logger.success(m18n.n("permission_updated", permission=permission))

return (app_setting(app, "_permissions") or {}).get(permname, "")


@is_unit_operation(flash=True)
def user_permission_add(
Expand Down
2 changes: 1 addition & 1 deletion src/utils/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ def provision_or_update(self, context: Dict = {}):
):
self.set_setting("path", "/")

existing_perms = user_permission_list(apps=[self.app])[
existing_perms = user_permission_list(apps=[self.app], full=True)[
"permissions"
]
for perm in existing_perms:
Expand Down

0 comments on commit 8de021b

Please sign in to comment.