dynamic html load (#63)

* dynamic html load
* split by classes
This commit is contained in:
2022-05-20 22:29:36 +03:00
committed by GitHub
parent 375f9fcfb7
commit b1dfafe275
72 changed files with 720 additions and 706 deletions

View File

@ -52,8 +52,8 @@ class Status(Handler):
# we are using reporter here
client = Application(architecture, configuration, no_report=False, unsafe=unsafe).repository.reporter
if args.ahriman:
ahriman = client.get_self()
StatusPrinter(ahriman).print(args.info)
service_status = client.get_internal()
StatusPrinter(service_status.status).print(args.info)
if args.package:
packages: Iterable[Tuple[Package, BuildStatus]] = sum(
[client.get(base) for base in args.package],

View File

@ -37,7 +37,7 @@ class Auth:
enabled(bool): indicates if authorization is enabled
logger(logging.Logger): class logger
max_age(int): session age in seconds. It will be used for both client side and server side checks
safe_build_status(bool): allow read only access to the index page
allow_read_only(bool): allow read only access to APIs
"""
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
@ -50,7 +50,7 @@ class Auth:
"""
self.logger = logging.getLogger("http")
self.safe_build_status = configuration.getboolean("auth", "safe_build_status")
self.allow_read_only = configuration.getboolean("auth", "allow_read_only")
self.enabled = provider.is_enabled
self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600)

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2022 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__all__ = ["steps"]
steps = [
"""
update users set access = 'read' where access = 'safe'
""",
"""
update users set access = 'reporter' where access = 'read'
""",
"""
update users set access = 'full' where access = 'write'
""",
]

View File

@ -80,16 +80,7 @@ class Client:
Returns:
InternalStatus: current internal (web) service status
"""
return InternalStatus()
def get_self(self) -> BuildStatus: # pylint: disable=no-self-use
"""
get ahriman status itself
Returns:
BuildStatus: current ahriman status
"""
return BuildStatus()
return InternalStatus(BuildStatus())
def remove(self, base: str) -> None:
"""

View File

@ -57,16 +57,6 @@ class WebClient(Client):
self.__session = requests.session()
self._login()
@property
def _ahriman_url(self) -> str:
"""
get url for the service status api
Returns:
str: full url for web service for ahriman service itself
"""
return f"{self.address}/status-api/v1/ahriman"
@property
def _login_url(self) -> str:
"""
@ -201,26 +191,7 @@ class WebClient(Client):
self.logger.exception("could not get web service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get web service status")
return InternalStatus()
def get_self(self) -> BuildStatus:
"""
get ahriman status itself
Returns:
BuildStatus: current ahriman status
"""
try:
response = self.__session.get(self._ahriman_url)
response.raise_for_status()
status_json = response.json()
return BuildStatus.from_json(status_json)
except requests.HTTPError as e:
self.logger.exception("could not get service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get service status")
return BuildStatus()
return InternalStatus(BuildStatus())
def remove(self, base: str) -> None:
"""
@ -265,7 +236,7 @@ class WebClient(Client):
payload = {"status": status.value}
try:
response = self.__session.post(self._ahriman_url, json=payload)
response = self.__session.post(self._status_url, json=payload)
response.raise_for_status()
except requests.HTTPError as e:
self.logger.exception("could not update service status: %s", exception_response_text(e))

View File

@ -46,40 +46,6 @@ class BuildStatusEnum(str, Enum):
Failed = "failed"
Success = "success"
def badges_color(self) -> str:
"""
convert itself to shield.io badges color
Returns:
str: shields.io color
"""
if self == BuildStatusEnum.Pending:
return "yellow"
if self == BuildStatusEnum.Building:
return "yellow"
if self == BuildStatusEnum.Failed:
return "critical"
if self == BuildStatusEnum.Success:
return "success"
return "inactive"
def bootstrap_color(self) -> str:
"""
converts itself to bootstrap color
Returns:
str: bootstrap color
"""
if self == BuildStatusEnum.Pending:
return "warning"
if self == BuildStatusEnum.Building:
return "warning"
if self == BuildStatusEnum.Failed:
return "danger"
if self == BuildStatusEnum.Success:
return "success"
return "secondary"
@dataclass
class BuildStatus:

View File

@ -22,6 +22,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Optional, Type
from ahriman.models.build_status import BuildStatus
from ahriman.models.counters import Counters
@ -31,12 +32,14 @@ class InternalStatus:
internal server status
Attributes:
status(BuildStatus): service status
architecture(Optional[str]): repository architecture
packages(Counters): packages statuses counter object
repository(Optional[str]): repository name
version(Optional[str]): service version
"""
status: BuildStatus
architecture: Optional[str] = None
packages: Counters = field(default=Counters(total=0))
repository: Optional[str] = None
@ -54,7 +57,8 @@ class InternalStatus:
InternalStatus: internal status
"""
counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0)
return cls(architecture=dump.get("architecture"),
return cls(status=BuildStatus.from_json(dump.get("status", {})),
architecture=dump.get("architecture"),
packages=counters,
repository=dump.get("repository"),
version=dump.get("version"))

View File

@ -142,9 +142,7 @@ class User:
Returns:
bool: True in case if user is allowed to do this request and False otherwise
"""
if self.access == UserAccess.Write:
return True # everything is allowed
return self.access == required
return self.access.permits(required)
def __repr__(self) -> str:
"""

View File

@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from __future__ import annotations
from enum import Enum
@ -25,12 +27,31 @@ class UserAccess(str, Enum):
web user access enumeration
Attributes:
Safe(UserAccess): (class attribute) user can access the page without authorization,
should not be used for user configuration
Unauthorized(UserAccess): (class attribute) user can access specific resources which are marked as available
without authorization (e.g. login, logout, static)
Read(UserAccess): (class attribute) user can read the page
Write(UserAccess): (class attribute) user can modify task and package list
Reporter(UserAccess): (class attribute) user can read everything and is able to perform some modifications
Full(UserAccess): (class attribute) user has full access
"""
Safe = "safe"
Unauthorized = "unauthorized"
Read = "read"
Write = "write"
Reporter = "reporter"
Full = "full"
def permits(self, other: UserAccess) -> bool:
"""
compare enumeration between each other and check if current permission allows the ``other``
Args:
other(UserAccess): other permission to compare
Returns:
bool: True in case if current permission allows the operation and False otherwise
"""
for member in UserAccess:
if member == other:
return True
if member == self:
return False
return False # must never happen

View File

@ -89,10 +89,13 @@ class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type
return await self.validator.verify_access(user.username, permission, context)
def auth_handler() -> MiddlewareType:
def auth_handler(allow_read_only: bool) -> MiddlewareType:
"""
authorization and authentication middleware
Args:
allow_read_only: allow
Returns:
MiddlewareType: built middleware
"""
@ -102,10 +105,14 @@ def auth_handler() -> MiddlewareType:
permission = await permission_method(request)
elif isinstance(handler, types.MethodType): # additional wrapper for static resources
handler_instance = getattr(handler, "__self__", None)
permission = UserAccess.Safe if isinstance(handler_instance, StaticResource) else UserAccess.Write
permission = UserAccess.Unauthorized if isinstance(handler_instance, StaticResource) else UserAccess.Full
else:
permission = UserAccess.Full
if permission == UserAccess.Unauthorized: # explicit if elif else for better code coverage
pass
elif allow_read_only and UserAccess.Read.permits(permission):
pass
else:
permission = UserAccess.Write
if permission != UserAccess.Safe:
await aiohttp_security.check_permission(request, permission, request.path)
return await handler(request)
@ -133,6 +140,6 @@ def setup_auth(application: web.Application, validator: Auth) -> web.Application
identity_policy = aiohttp_security.SessionIdentityPolicy()
aiohttp_security.setup(application, identity_policy, authorization_policy)
application.middlewares.append(auth_handler())
application.middlewares.append(auth_handler(validator.allow_read_only))
return application

View File

@ -25,7 +25,6 @@ from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.request import RequestView
from ahriman.web.views.service.search import SearchView
from ahriman.web.views.status.ahriman import AhrimanView
from ahriman.web.views.status.package import PackageView
from ahriman.web.views.status.packages import PackagesView
from ahriman.web.views.status.status import StatusView
@ -55,9 +54,6 @@ def setup_routes(application: Application, static_path: Path) -> None:
* POST /service-api/v1/update update packages in repository, actually it is just alias for add
* GET /status-api/v1/ahriman get current service status
* POST /status-api/v1/ahriman update service status
* GET /status-api/v1/packages get all known packages
* POST /status-api/v1/packages force update every package from repository
@ -65,7 +61,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
* GET /status-api/v1/package/:base get package base status
* POST /status-api/v1/package/:base update package base status
* GET /status-api/v1/status get web service status itself
* GET /status-api/v1/status get service status itself
* POST /status-api/v1/status update service status itself
* GET /user-api/v1/login OAuth2 handler for login
* POST /user-api/v1/login login to service
@ -90,9 +87,6 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/service-api/v1/update", AddView)
application.router.add_get("/status-api/v1/ahriman", AhrimanView, allow_head=True)
application.router.add_post("/status-api/v1/ahriman", AhrimanView)
application.router.add_get("/status-api/v1/packages", PackagesView, allow_head=True)
application.router.add_post("/status-api/v1/packages", PackagesView)
@ -101,6 +95,7 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/status-api/v1/packages/{package}", PackageView)
application.router.add_get("/status-api/v1/status", StatusView, allow_head=True)
application.router.add_post("/status-api/v1/status", StatusView)
application.router.add_get("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/login", LoginView)

View File

@ -101,7 +101,7 @@ class BaseView(View):
Returns:
UserAccess: extracted permission
"""
permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Write)
permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Full)
return permission
async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:

View File

@ -21,9 +21,7 @@ import aiohttp_jinja2
from typing import Any, Dict
from ahriman import version
from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.util import pretty_datetime
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
@ -34,37 +32,19 @@ class IndexView(BaseView):
It uses jinja2 templates for report generation, the following variables are allowed:
* architecture - repository architecture, string, required
* auth - authorization descriptor, required
* authenticated - alias to check if user can see the page, boolean, required
* control - HTML to insert for login control, HTML string, required
* enabled - whether authorization is enabled by configuration or not, boolean, required
* username - authenticated username if any, string, null means not authenticated
* index_url - url to the repository index, string, optional
* packages - sorted list of packages properties, required
* base, string
* depends, sorted list of strings
* groups, sorted list of strings
* licenses, sorted list of strings
* packages, sorted list of strings
* status, string based on enum value
* status_color, string based on enum value
* timestamp, pretty printed datetime, string
* version, string
* web_url, string
* repository - repository name, string, required
* service - service status properties, required
* status, string based on enum value
* status_color, string based on enum value
* timestamp, pretty printed datetime, string
* version - ahriman version, string, required
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
"""
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Safe
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Unauthorized
@aiohttp_jinja2.template("build-status.jinja2")
async def get(self) -> Dict[str, Any]:
@ -74,43 +54,15 @@ class IndexView(BaseView):
Returns:
Dict[str, Any]: parameters for jinja template
"""
# some magic to make it jinja-friendly
packages = [
{
"base": package.base,
"depends": package.depends,
"groups": package.groups,
"licenses": package.licenses,
"packages": list(sorted(package.packages)),
"status": status.status.value,
"status_color": status.status.bootstrap_color(),
"timestamp": pretty_datetime(status.timestamp),
"version": package.version,
"web_url": package.remote.web_url if package.remote is not None else None,
} for package, status in sorted(self.service.packages, key=lambda item: item[0].base)
]
service = {
"status": self.service.status.status.value,
"status_color": self.service.status.status.badges_color(),
"timestamp": pretty_datetime(self.service.status.timestamp),
}
# auth block
auth_username = await authorized_userid(self.request)
authenticated = not self.validator.enabled or self.validator.safe_build_status or auth_username is not None
auth = {
"authenticated": authenticated,
"control": self.validator.auth_control,
"enabled": self.validator.enabled,
"username": auth_username,
}
return {
"architecture": self.service.architecture,
"auth": auth,
"index_url": self.configuration.get("web", "index_url", fallback=None),
"packages": packages,
"repository": self.service.repository.name,
"service": service,
"version": version.__version__,
}

View File

@ -31,7 +31,7 @@ class AddView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Write
POST_PERMISSION = UserAccess.Full
async def post(self) -> None:
"""

View File

@ -31,7 +31,7 @@ class RemoveView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Write
POST_PERMISSION = UserAccess.Full
async def post(self) -> None:
"""

View File

@ -31,7 +31,7 @@ class RequestView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Reporter
async def post(self) -> None:
"""

View File

@ -35,7 +35,7 @@ class SearchView(BaseView):
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
"""
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Reporter
async def get(self) -> Response:
"""

View File

@ -1,71 +0,0 @@
#
# Copyright (c) 2021-2022 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class AhrimanView(BaseView):
"""
service status web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Write
async def get(self) -> Response:
"""
get current service status
Returns:
Response: 200 with service status object
"""
return json_response(self.service.status.view())
async def post(self) -> None:
"""
update service status
JSON body must be supplied, the following model is used::
{
"status": "unknown", # service status string, must be valid ``BuildStatusEnum``
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
"""
try:
data = await self.extract_data()
status = BuildStatusEnum(data["status"])
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.service.update_self(status)
raise HTTPNoContent()

View File

@ -37,7 +37,7 @@ class PackageView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Write
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
async def get(self) -> Response:

View File

@ -34,7 +34,7 @@ class PackagesView(BaseView):
"""
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Write
POST_PERMISSION = UserAccess.Full
async def get(self) -> Response:
"""

View File

@ -17,9 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import Response, json_response
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman import version
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus
from ahriman.models.user_access import UserAccess
@ -33,9 +34,11 @@ class StatusView(BaseView):
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Full
async def get(self) -> Response:
"""
@ -46,9 +49,34 @@ class StatusView(BaseView):
"""
counters = Counters.from_packages(self.service.packages)
status = InternalStatus(
status=self.service.status,
architecture=self.service.architecture,
packages=counters,
repository=self.service.repository.name,
version=version.__version__)
return json_response(status.view())
async def post(self) -> None:
"""
update service status
JSON body must be supplied, the following model is used::
{
"status": "unknown", # service status string, must be valid ``BuildStatusEnum``
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
"""
try:
data = await self.extract_data()
status = BuildStatusEnum(data["status"])
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.service.update_self(status)
raise HTTPNoContent()

View File

@ -34,7 +34,7 @@ class LoginView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = POST_PERMISSION = UserAccess.Safe
GET_PERMISSION = POST_PERMISSION = UserAccess.Unauthorized
async def get(self) -> None:
"""

View File

@ -32,7 +32,7 @@ class LogoutView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Safe
POST_PERMISSION = UserAccess.Unauthorized
async def post(self) -> None:
"""