Compare commits

...

6 Commits

18 changed files with 330 additions and 23 deletions

View File

@@ -1,7 +1,7 @@
version: 2
build:
os: ubuntu-20.04
os: ubuntu-lts-latest
tools:
python: "3.12"
apt_packages:

View File

@@ -12,6 +12,14 @@ ahriman.web.middlewares.auth\_handler module
:no-undoc-members:
:show-inheritance:
ahriman.web.middlewares.etag\_handler module
--------------------------------------------
.. automodule:: ahriman.web.middlewares.etag_handler
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.middlewares.exception\_handler module
-------------------------------------------------

View File

@@ -180,6 +180,10 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
* ``cors_allow_headers`` - allowed CORS headers, space separated list of strings, optional.
* ``cors_allow_methods`` - allowed CORS methods, space separated list of strings, optional.
* ``cors_allow_origins`` - allowed CORS origins, space separated list of strings, optional, default ``*``.
* ``cors_expose_headers`` - exposed CORS headers, space separated list of strings, optional.
* ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``.
* ``host`` - host to bind, string, optional.
* ``index_url`` - full URL of the repository index page, string, optional.

View File

@@ -10,6 +10,7 @@
"react": ">=19.2.0 <19.3.0",
"react-chartjs-2": ">=5.3.0 <5.4.0",
"react-dom": ">=19.2.0 <19.3.0",
"react-error-boundary": ">=6.1.0 <6.2.0",
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
},
"devDependencies": {

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2021-2026 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/>.
*/
import { Box, Button, Typography } from "@mui/material";
import type React from "react";
import type { FallbackProps } from "react-error-boundary";
interface ErrorDetails {
message: string;
stack: string | undefined;
}
export default function ErrorFallback({ error }: FallbackProps): React.JSX.Element {
const details: ErrorDetails = error instanceof Error
? { message: error.message, stack: error.stack }
: { message: String(error), stack: undefined };
return <Box role="alert" sx={{ color: "text.primary", minHeight: "100vh", p: 6 }}>
<Typography sx={{ fontWeight: 700 }} variant="h4">
Something went wrong
</Typography>
<Typography color="error" sx={{ fontFamily: "monospace", mt: 2 }}>
{details.message}
</Typography>
{details.stack && <Typography
component="pre"
sx={{ color: "text.secondary", fontFamily: "monospace", fontSize: "0.75rem", mt: 3, whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{details.stack}
</Typography>}
<Box sx={{ display: "flex", gap: 2, mt: 4 }}>
<Button onClick={() => window.location.reload()} variant="outlined">Reload page</Button>
</Box>
</Box>;
}

View File

@@ -21,11 +21,18 @@ import "chartSetup";
import "utils";
import App from "App";
import ErrorFallback from "components/common/ErrorBoundary";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ErrorBoundary } from "react-error-boundary";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
>
<App />
</ErrorBoundary>
</StrictMode>,
);

View File

@@ -30,6 +30,14 @@ allow_read_only = yes
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
; by default.
autorefresh_intervals = 5 1 10 30 60
; Allowed CORS headers. By default everything is allowed.
;cors_allow_headers =
; Allowed CORS methods. By default everything is allowed.
;cors_allow_methods =
; Allowed CORS origins.
;cors_allow_origins = *
; Exposed CORS headers. By default everything is exposed.
;cors_expose_headers =
; Enable file upload endpoint used by some triggers.
;enable_archive_upload = no
; Address to bind the server.

View File

@@ -80,7 +80,7 @@ class Configuration(configparser.RawConfigParser):
"""
configparser.RawConfigParser.__init__(
self,
dict_type=ConfigurationMultiDict if allow_multi_key else dict, # type: ignore[arg-type]
dict_type=ConfigurationMultiDict if allow_multi_key else dict,
allow_no_value=allow_no_value,
strict=False,
empty_lines_in_values=not allow_multi_key,

View File

@@ -358,6 +358,38 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"min": 0,
},
},
"cors_allow_headers": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"cors_allow_methods": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"cors_allow_origins": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"cors_expose_headers": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"enable_archive_upload": {
"type": "boolean",
"coerce": "boolean",

View File

@@ -150,6 +150,6 @@ class ShellTemplate(Template):
break
kwargs.update(mapping)
substituted = dict(generator(kwargs))
kwargs.update(dict(generator(kwargs)))
return self.safe_substitute(kwargs | substituted)
return self.safe_substitute(kwargs)

View File

@@ -41,7 +41,7 @@ class HttpUpload(SyncHttpClient):
str: calculated checksum of the file
"""
with path.open("rb") as local_file:
md5 = hashlib.md5(local_file.read()) # nosec
md5 = hashlib.md5(local_file.read(), usedforsecurity=False)
return md5.hexdigest()
@staticmethod

View File

@@ -62,9 +62,7 @@ class S3(Upload):
@staticmethod
def calculate_etag(path: Path, chunk_size: int) -> str:
"""
calculate amazon s3 etag
credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
For this method we have to define nosec because it is out of any security context and provided by AWS
calculate amazon s3 etag. Credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
Args:
path(Path): path to local file
@@ -76,14 +74,17 @@ class S3(Upload):
md5s = []
with path.open("rb") as local_file:
for chunk in iter(lambda: local_file.read(chunk_size), b""):
md5s.append(hashlib.md5(chunk)) # nosec
md5s.append(hashlib.md5(chunk, usedforsecurity=False))
# in case if there is only one chunk it must be just this checksum
# and checksum of joined digest otherwise (including empty list)
checksum = md5s[0] if len(md5s) == 1 else hashlib.md5(b"".join(md5.digest() for md5 in md5s)) # nosec
# in case if there are more than one chunk it should be appended with amount of chunks
if len(md5s) == 1:
return md5s[0].hexdigest()
# otherwise it is checksum of joined digest (including empty list)
md5 = hashlib.md5(b"".join(md5.digest() for md5 in md5s), usedforsecurity=False)
# in case if there are more (exactly) than one chunk it should be appended with amount of chunks
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
return f"{checksum.hexdigest()}{suffix}"
return f"{md5.hexdigest()}{suffix}"
@staticmethod
def files_remove(local_files: dict[Path, str], remote_objects: dict[Path, Any]) -> None:

View File

@@ -21,26 +21,34 @@ import aiohttp_cors
from aiohttp.web import Application
from ahriman.core.configuration import Configuration
__all__ = ["setup_cors"]
def setup_cors(application: Application) -> aiohttp_cors.CorsConfig:
def setup_cors(application: Application, configuration: Configuration) -> aiohttp_cors.CorsConfig:
"""
setup CORS for the web application
Args:
application(Application): web application instance
configuration(Configuration): configuration instance
Returns:
aiohttp_cors.CorsConfig: generated CORS configuration
"""
allow_headers = configuration.getlist("web", "cors_allow_headers", fallback=[]) or "*"
allow_methods = configuration.getlist("web", "cors_allow_methods", fallback=[]) or "*"
expose_headers = configuration.getlist("web", "cors_expose_headers", fallback=[]) or "*"
cors = aiohttp_cors.setup(application, defaults={
"*": aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
expose_headers="*",
allow_headers="*",
allow_methods="*",
origin: aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
expose_headers=expose_headers,
allow_headers=allow_headers,
allow_methods=allow_methods,
)
for origin in configuration.getlist("web", "cors_allow_origins", fallback=["*"])
})
for route in application.router.routes():
cors.add(route)

View File

@@ -0,0 +1,61 @@
#
# Copyright (c) 2021-2026 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/>.
#
import hashlib
from aiohttp import ETag
from aiohttp.typedefs import Middleware
from aiohttp.web import HTTPNotModified, Request, Response, StreamResponse, middleware
from ahriman.web.middlewares import HandlerType
__all__ = ["etag_handler"]
def etag_handler() -> Middleware:
"""
middleware to handle ETag header for conditional requests. It computes ETag from the response body
and returns 304 Not Modified if the client sends a matching ``If-None-Match`` header
Returns:
Middleware: built middleware
Raises:
HTTPNotModified: if content matches ``If-None-Match`` header sent
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
response = await handler(request)
if not isinstance(response, Response) or not isinstance(response.body, bytes):
return response
if request.method not in ("GET", "HEAD"):
return response
etag = ETag(value=hashlib.md5(response.body, usedforsecurity=False).hexdigest())
response.etag = etag
if request.if_none_match is not None and etag in request.if_none_match:
raise HTTPNotModified(headers={"ETag": response.headers["ETag"]})
return response
return handle

View File

@@ -209,8 +209,8 @@ class BaseView(View, CorsViewMixin):
HTTPBadRequest: if supplied parameters are invalid
"""
try:
limit = int(self.request.query.get("limit", default=-1))
offset = int(self.request.query.get("offset", default=0))
limit = int(self.request.query.get("limit", -1))
offset = int(self.request.query.get("offset", 0))
except ValueError as ex:
raise HTTPBadRequest(reason=str(ex))

View File

@@ -38,6 +38,7 @@ from ahriman.models.repository_id import RepositoryId
from ahriman.web.apispec.info import setup_apispec
from ahriman.web.cors import setup_cors
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
from ahriman.web.middlewares.etag_handler import etag_handler
from ahriman.web.middlewares.exception_handler import exception_handler
from ahriman.web.middlewares.metrics_handler import metrics_handler
from ahriman.web.middlewares.request_id_handler import request_id_handler
@@ -181,13 +182,14 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
application.middlewares.append(request_id_handler())
application.middlewares.append(exception_handler(application.logger))
application.middlewares.append(etag_handler())
application.middlewares.append(metrics_handler())
application.logger.info("setup routes")
setup_routes(application, configuration)
application.logger.info("setup CORS")
setup_cors(application)
setup_cors(application, configuration)
application.logger.info("setup templates")
loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates"))

View File

@@ -0,0 +1,85 @@
import hashlib
import pytest
from aiohttp import ETag
from aiohttp.web import HTTPNotModified, Response, StreamResponse
from unittest.mock import AsyncMock
from ahriman.web.middlewares.etag_handler import etag_handler
async def test_etag_handler() -> None:
"""
must set ETag header on GET responses
"""
request = pytest.helpers.request("", "", "GET")
request.if_none_match = None
request_handler = AsyncMock(return_value=Response(body=b"hello"))
handler = etag_handler()
result = await handler(request, request_handler)
assert result.etag is not None
async def test_etag_handler_not_modified() -> None:
"""
must raise NotModified when ETag matches If-None-Match
"""
body = b"hello"
request = pytest.helpers.request("", "", "GET")
request.if_none_match = (ETag(value=hashlib.md5(body, usedforsecurity=False).hexdigest()),)
request_handler = AsyncMock(return_value=Response(body=body))
handler = etag_handler()
with pytest.raises(HTTPNotModified):
await handler(request, request_handler)
async def test_etag_handler_no_match() -> None:
"""
must return full response when ETag does not match If-None-Match
"""
request = pytest.helpers.request("", "", "GET")
request.if_none_match = (ETag(value="outdated"),)
request_handler = AsyncMock(return_value=Response(body=b"hello"))
handler = etag_handler()
result = await handler(request, request_handler)
assert result.status == 200
assert result.etag is not None
async def test_etag_handler_skip_post() -> None:
"""
must skip ETag for non-GET/HEAD methods
"""
request = pytest.helpers.request("", "", "POST")
request_handler = AsyncMock(return_value=Response(body=b"hello"))
handler = etag_handler()
result = await handler(request, request_handler)
assert result.etag is None
async def test_etag_handler_skip_no_body() -> None:
"""
must skip ETag for responses without body
"""
request = pytest.helpers.request("", "", "GET")
request_handler = AsyncMock(return_value=Response())
handler = etag_handler()
result = await handler(request, request_handler)
assert result.etag is None
async def test_etag_handler_skip_stream() -> None:
"""
must skip ETag for streaming responses
"""
request = pytest.helpers.request("", "", "GET")
request_handler = AsyncMock(return_value=StreamResponse())
handler = etag_handler()
result = await handler(request, request_handler)
assert "ETag" not in result.headers

View File

@@ -2,13 +2,17 @@ import aiohttp_cors
import pytest
from aiohttp.web import Application
from pytest_mock import MockerFixture
from ahriman.web.cors import setup_cors
from ahriman.web.keys import ConfigurationKey
def test_setup_cors(application: Application) -> None:
"""
must setup CORS
"""
cors: aiohttp_cors.CorsConfig = application[aiohttp_cors.APP_CONFIG_KEY]
cors = application[aiohttp_cors.APP_CONFIG_KEY]
# let's test here that it is enabled for all requests
for route in application.router.routes():
# we don't want to deal with match info here though
@@ -18,3 +22,34 @@ def test_setup_cors(application: Application) -> None:
continue
request = pytest.helpers.request(application, url, route.method, resource=route.resource)
assert cors._cors_impl._router_adapter.is_cors_enabled_on_request(request)
def test_setup_cors_custom_origins(application: Application, mocker: MockerFixture) -> None:
"""
must setup CORS with custom origins
"""
configuration = application[ConfigurationKey]
configuration.set_option("web", "cors_allow_origins", "https://example.com https://httpbin.com")
setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock())
setup_cors(application, configuration)
defaults = setup_mock.call_args.kwargs["defaults"]
assert "https://example.com" in defaults
assert "https://httpbin.com" in defaults
assert "*" not in defaults
def test_setup_cors_custom_methods(application: Application, mocker: MockerFixture) -> None:
"""
must setup CORS with custom methods
"""
configuration = application[ConfigurationKey]
configuration.set_option("web", "cors_allow_methods", "GET POST")
setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock())
setup_cors(application, configuration)
defaults = setup_mock.call_args.kwargs["defaults"]
resource_options = next(iter(defaults.values()))
assert resource_options.allow_methods == {"GET", "POST"}