Compare commits

..

10 Commits

58 changed files with 480 additions and 100 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

@@ -235,6 +235,18 @@ Remove packages
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
Rollback packages
^^^^^^^^^^^^^^^^^
This flow restores a package to a previously built version:
#. Load the current package definition from the repository database.
#. Replace its version with the requested rollback target.
#. Search the archive directory for built artifacts (packages and signatures) matching the target version.
#. Add the found artifacts to the repository via the same path as ``package-add`` with ``PackageSource.Archive``.
#. Trigger an immediate update to process the added packages.
#. If ``--hold`` is enabled (the default), mark the package as held in the database to prevent automatic updates from overriding the rollback.
Check outdated packages
^^^^^^^^^^^^^^^^^^^^^^^

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

@@ -33,3 +33,28 @@ The service provides several commands aim to do easy repository backup and resto
.. code-block:: shell
sudo -u ahriman ahriman repo-rebuild --from-database
Package rollback
================
If the ``archive.keep_built_packages`` option is enabled, the service keeps previously built package files in the archive directory. These archives can be used to rollback a package to a previous successfully built version.
#.
List available archive versions for a package:
.. code-block:: shell
ahriman package-archives ahriman
#.
Rollback the package to the desired version:
.. code-block:: shell
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1
By default, the ``--hold`` flag is enabled, which prevents the package from being automatically updated on subsequent ``repo-update`` runs. To rollback without holding the package use:
.. code-block:: shell
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1 --no-hold

View File

@@ -1,5 +1,6 @@
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import simpleImportSort from "eslint-plugin-simple-import-sort";
@@ -8,7 +9,11 @@ import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
extends: [
js.configs.recommended,
react.configs.flat.recommended,
...tseslint.configs.recommendedTypeChecked,
],
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
@@ -17,13 +22,14 @@ export default tseslint.config(
},
},
plugins: {
"@stylistic": stylistic,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"simple-import-sort": simpleImportSort,
"@stylistic": stylistic,
},
rules: {
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports
@@ -33,7 +39,7 @@ export default tseslint.config(
// core
"curly": "error",
"eqeqeq": "error",
"no-console": "error",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-eval": "error",
// stylistic
@@ -68,6 +74,7 @@ export default tseslint.config(
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",

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": {
@@ -20,6 +21,7 @@
"@types/react-syntax-highlighter": ">=15.5.0 <15.6.0",
"@vitejs/plugin-react": ">=6.0.0 <6.1.0",
"eslint": ">=9.39.0 <9.40.0",
"eslint-plugin-react": ">=7.37.0 <7.38.0",
"eslint-plugin-react-hooks": ">=7.0.0 <7.1.0",
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",

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

@@ -4,18 +4,18 @@
"baseUrl": "src",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true
},
"include": ["src"]

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

@@ -104,7 +104,7 @@ class PkgbuildParser(shlex.shlex):
# ignore substitution and extend bash symbols
self.wordchars += "${}#:+-@!"
# in case of default behaviour, it will ignore, for example, segment part of url outside of quotes
# in case of default behavior, it will ignore, for example, segment part of url outside of quotes
self.commenters = ""
@staticmethod

View File

@@ -72,7 +72,7 @@ class AUR(Remote):
parse RPC response to package list
Args:
response(dict[str, Any]): RPC response json
response(dict[str, Any]): RPC response JSON
Returns:
list[AURPackage]: list of parsed packages

View File

@@ -74,7 +74,7 @@ class Official(Remote):
parse RPC response to package list
Args:
response(dict[str, Any]): RPC response json
response(dict[str, Any]): RPC response JSON
Returns:
list[AURPackage]: list of parsed packages

View File

@@ -32,7 +32,7 @@ class OfficialSyncdb(Official):
updates.
This approach also has limitations, because we don't require superuser rights (neither going to download database
separately), the database file might be outdated and must be handled manually (or kind of). This behaviour might be
separately), the database file might be outdated and must be handled manually (or kind of). This behavior might be
changed in the future.
Still we leave search function based on the official repositories RPC.

View File

@@ -50,13 +50,13 @@ class Auth(LazyLogging):
@property
def auth_control(self) -> str:
"""
This workaround is required to make different behaviour for login interface.
This workaround is required to make different behavior for login interface.
In case of internal authentication it must provide an interface (modal form) to log in with button sends POST
request. But for an external providers behaviour can be different: e.g. OAuth provider requires sending GET
request. But for an external providers behavior can be different: e.g. OAuth provider requires sending GET
request to external resource
Returns:
str: login control as html code to insert
str: login control as HTML code to insert
"""
return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"

View File

@@ -30,7 +30,13 @@ except ImportError:
from typing import Any
__all__ = ["authorized_userid", "check_authorized", "forget", "remember"]
__all__ = [
"authorized_userid",
"check_authorized",
"forget",
"get_session",
"remember",
]
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:

View File

@@ -62,10 +62,10 @@ class OAuth(Mapping):
@property
def auth_control(self) -> str:
"""
get authorization html control
get authorization HTML control
Returns:
str: login control as html code to insert
str: login control as HTML code to insert
"""
return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>"

View File

@@ -416,7 +416,7 @@ class Sources(LazyLogging):
else:
patch.write(sources_dir / "PKGBUILD")
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str | None:
"""
read file content from the specified commit
@@ -426,6 +426,10 @@ class Sources(LazyLogging):
path(Path): path to file inside the repository
Returns:
str: file content at specified commit
str | None: file content at specified commit if available
"""
try:
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
except CalledProcessError:
self.logger.exception("failed to read file %s at %s", path, commit_sha)
return None

View File

@@ -72,7 +72,7 @@ class Configuration(configparser.RawConfigParser):
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
"""
Args:
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behaviour. In case if it is set
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behavior. In case if it is set
to ``True``, the keys without values will be allowed (Default value = False)
allow_multi_key(bool, optional): if set to ``False``, then the default dictionary class will be used to
store keys internally. Otherwise, the special implementation will be used, which supports arrays
@@ -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

@@ -81,7 +81,7 @@ class ChangesOperations(Operations):
values
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
on conflict (package_base, repository) do update set
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = :pkgbuild
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = coalesce(:pkgbuild, pkgbuild)
""",
{
"package_base": package_base,

View File

@@ -171,7 +171,7 @@ class SyncHttpClient(LazyLogging):
headers(dict[str, str] | None, optional): request headers (Default value = None)
params(list[tuple[str, str]] | None, optional): request query parameters (Default value = None)
data(Any | None, optional): request raw data parameters (Default value = None)
json(dict[str, Any] | None, optional): request json parameters (Default value = None)
json(dict[str, Any] | None, optional): request JSON parameters (Default value = None)
files(dict[str, MultipartType] | None, optional): multipart upload (Default value = None)
stream(bool | None, optional): handle response as stream (Default value = None)
session(requests.Session | None, optional): session object if any (Default value = None)

View File

@@ -27,7 +27,7 @@ from ahriman.models.result import Result
class Console(Report):
"""
html report generator
HTML report generator
Attributes:
use_utf(bool): print utf8 symbols instead of ASCII

View File

@@ -27,10 +27,10 @@ from ahriman.models.result import Result
class HTML(Report, JinjaTemplate):
"""
html report generator
HTML report generator
Attributes:
report_path(Path): output path to html report
report_path(Path): output path to HTML report
template(Path | str): name or path to template for full package list
"""

View File

@@ -71,7 +71,7 @@ class Report(LazyLogging):
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
target(str): target to generate report aka section name (e.g. html)
target(str): target to generate report aka section name (e.g. HTML)
Returns:
Report: client according to current settings

View File

@@ -34,7 +34,7 @@ class Trigger(LazyLogging):
Attributes:
CONFIGURATION_SCHEMA(ConfigurationSchema): (class attribute) configuration schema template
REQUIRES_REPOSITORY(bool): (class attribute) either trigger requires loaded repository or not
REQUIRES_REPOSITORY(bool): (class attribute) either trigger requires a repository to be loaded or not
configuration(Configuration): configuration instance
repository_id(RepositoryId): repository unique identifier

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

@@ -231,13 +231,13 @@ def check_user(root: Path, *, unsafe: bool) -> None:
def dataclass_view(instance: Any) -> dict[str, Any]:
"""
convert dataclass instance to json object
convert dataclass instance to JSON object
Args:
instance(Any): dataclass instance
Returns:
dict[str, Any]: json representation of the dataclass with empty field removed
dict[str, Any]: JSON representation of the dataclass with empty field removed
"""
return asdict(instance, dict_factory=lambda fields: {key: value for key, value in fields if value is not None})
@@ -287,15 +287,15 @@ def filelock(path: Path) -> Iterator[FileLock]:
def filter_json(source: T, known_fields: Iterable[str] | None = None) -> T:
"""
recursively filter json object removing ``None`` values and optionally filtering by known fields
recursively filter JSON object removing ``None`` values and optionally filtering by known fields
Args:
source(T): raw json object (dict, list, or scalar)
source(T): raw JSON object (dict, list, or scalar)
known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
(Default value = None)
Returns:
T: json without ``None`` values
T: JSON without ``None`` values
Examples:
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::

View File

@@ -62,7 +62,7 @@ class AURPackage:
Examples:
Mainly this class must be used from class methods instead of default :func:`__init__()`::
>>> package = AURPackage.from_json(metadata) # load package from json dump
>>> package = AURPackage.from_json(metadata) # load package from JSON dump
>>> # ...or alternatively...
>>> package = AURPackage.from_repo(metadata) # load package from official repository RPC
>>> # properties of the class are built based on ones from AUR RPC, thus additional method is required
@@ -175,7 +175,7 @@ class AURPackage:
construct package descriptor from official repository RPC properties
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: AUR package descriptor

View File

@@ -89,7 +89,7 @@ class BuildStatus:
def view(self) -> dict[str, Any]:
"""
generate json status view
generate JSON status view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -41,10 +41,10 @@ class Changes:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct changes from the json dump
construct changes from the JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: changes object
@@ -55,7 +55,7 @@ class Changes:
def view(self) -> dict[str, Any]:
"""
generate json change view
generate JSON change view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -49,10 +49,10 @@ class Counters:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct counters from json dump
construct counters from JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: status counters

View File

@@ -72,10 +72,10 @@ class Event:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct event from the json dump
construct event from the JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: event object
@@ -102,7 +102,7 @@ class Event:
def view(self) -> dict[str, Any]:
"""
generate json event view
generate JSON event view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -50,10 +50,10 @@ class InternalStatus:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct internal status from json dump
construct internal status from JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: internal status
@@ -70,7 +70,7 @@ class InternalStatus:
def view(self) -> dict[str, Any]:
"""
generate json status view
generate JSON status view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -41,11 +41,11 @@ class LogRecord:
@classmethod
def from_json(cls, package_base: str, dump: dict[str, Any]) -> Self:
"""
construct log record from the json dump
construct log record from the JSON dump
Args:
package_base(str): package base for which log record belongs
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: log record object
@@ -63,7 +63,7 @@ class LogRecord:
def view(self) -> dict[str, Any]:
"""
generate json log record view
generate JSON log record view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -50,11 +50,11 @@ class Package(LazyLogging):
version(str): package full version
Examples:
Different usages of this class may generate different (incomplete) data, e.g. if instantiating class from json::
Different usages of this class may generate different (incomplete) data, e.g. if instantiating class from JSON::
>>> package = Package.from_json(dump)
it will contain every data available in the json body. Otherwise, if generate package from local archive::
it will contain every data available in the JSON body. Otherwise, if generate package from local archive::
>>> package = Package.from_archive(local_path, pacman)
@@ -273,10 +273,10 @@ class Package(LazyLogging):
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct package properties from json dump
construct package properties from JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: package properties
@@ -396,7 +396,7 @@ class Package(LazyLogging):
def view(self) -> dict[str, Any]:
"""
generate json package view
generate JSON package view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -49,7 +49,7 @@ class PackageDescription:
Examples:
Unlike the :class:`ahriman.models.package.Package` class, this implementation only holds properties.
The recommended way to deal with it is to read data based on the source type - either json or
The recommended way to deal with it is to read data based on the source type - either JSON or
:class:`pyalpm.Package` instance::
>>> description = PackageDescription.from_json(dump)
@@ -126,10 +126,10 @@ class PackageDescription:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct package properties from json dump
construct package properties from JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: package properties
@@ -169,7 +169,7 @@ class PackageDescription:
def view(self) -> dict[str, Any]:
"""
generate json package view
generate JSON package view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -87,10 +87,10 @@ class PkgbuildPatch:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct patch descriptor from the json dump
construct patch descriptor from the JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: patch object
@@ -125,7 +125,7 @@ class PkgbuildPatch:
# the source value looks like shell array, remove brackets and parse with shlex
return shlex.split(shell_array[1:-1])
case json_array if json_array.startswith("[") and json_array.endswith("]"):
# json (aka python) array, parse with json parser instead
# JSON (aka python) array, parse with JSON parser instead
parsed: list[str] = json.loads(json_array)
return parsed
case variable:
@@ -220,7 +220,7 @@ class PkgbuildPatch:
def view(self) -> dict[str, Any]:
"""
generate json patch view
generate JSON patch view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -74,10 +74,10 @@ class RemoteSource:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct remote source from the json dump (or database row)
construct remote source from the JSON dump (or database row)
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: remote source
@@ -102,7 +102,7 @@ class RemoteSource:
def view(self) -> dict[str, Any]:
"""
generate json package remote view
generate JSON package remote view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -28,10 +28,10 @@ class ReportSettings(StrEnum):
Attributes:
Disabled(ReportSettings): option which generates no report for testing purpose
HTML(ReportSettings): html report generation
HTML(ReportSettings): HTML report generation
Email(ReportSettings): email report generation
Console(ReportSettings): print result to console
Telegram(ReportSettings): markdown report to telegram channel
Telegram(ReportSettings): Markdown report to telegram channel
RSS(ReportSettings): RSS report generation
RemoteCall(ReportSettings): remote ahriman server call
"""

View File

@@ -70,7 +70,7 @@ class RepositoryId:
def view(self) -> dict[str, Any]:
"""
generate json package view
generate JSON package view
Returns:
dict[str, Any]: json-friendly dictionary

View File

@@ -38,10 +38,10 @@ class RepositoryStats:
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct counters from json dump
construct counters from JSON dump
Args:
dump(dict[str, Any]): json dump body
dump(dict[str, Any]): JSON dump body
Returns:
Self: status counters

View File

@@ -45,7 +45,7 @@ class Worker:
def view(self) -> dict[str, Any]:
"""
generate json patch view
generate JSON worker view
Returns:
dict[str, Any]: json-friendly dictionary

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

@@ -42,13 +42,13 @@ __all__ = ["exception_handler"]
def _is_templated_unauthorized(request: Request) -> bool:
"""
check if the request is eligible for rendering html template
check if the request is eligible for rendering HTML template
Args:
request(Request): source request to check
Returns:
bool: ``True`` in case if response should be rendered as html and ``False`` otherwise
bool: ``True`` in case if response should be rendered as HTML and ``False`` otherwise
"""
return request.path in ("/api/v1/login", "/api/v1/logout") \
and "application/json" not in request.headers.getall("accept", [])

View File

@@ -39,7 +39,7 @@ async def server_info(view: BaseView) -> dict[str, Any]:
view(BaseView): view of the request
Returns:
dict[str, Any]: server info as a json response
dict[str, Any]: server info as a JSON response
"""
autorefresh_intervals = [
{

View File

@@ -57,7 +57,7 @@ class DocsView(BaseView):
@aiohttp_jinja2.template("api.jinja2")
async def get(self) -> dict[str, Any]:
"""
return static docs html
return static docs HTML
Returns:
dict[str, Any]: parameters for jinja template

View File

@@ -60,7 +60,7 @@ class SwaggerView(BaseView):
get api specification
Returns:
Response: 200 with json api specification
Response: 200 with JSON api specification
"""
spec = self.request.app["swagger_dict"]
is_body_parameter: Callable[[dict[str, str]], bool] = lambda p: p["in"] == "body" or p["in"] == "formData"

View File

@@ -169,7 +169,7 @@ class BaseView(View, CorsViewMixin):
filter and convert data and return :class:`aiohttp.web.Response` object
Args:
data(dict[str, Any] | list[Any]): response in json format
data(dict[str, Any] | list[Any]): response in JSON format
**kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
Returns:
@@ -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

@@ -605,3 +605,12 @@ def test_read(sources: Sources, mocker: MockerFixture) -> None:
check_output_mock = mocker.patch("ahriman.core.build_tools.sources.check_output", return_value="content")
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) == "content"
check_output_mock.assert_called_once()
def test_read_failed(sources: Sources, mocker: MockerFixture) -> None:
"""
must return None in case if file cannot be read from commit
"""
mocker.patch("ahriman.core.build_tools.sources.check_output",
side_effect=CalledProcessError(1, ["command"], "error"))
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) is None

View File

@@ -53,3 +53,12 @@ def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base).changes is None
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
def test_changes_insert_pkgbuild_preserve(database: SQLite, package_ahriman: Package) -> None:
"""
must preserve existing pkgbuild when inserting changes without pkgbuild
"""
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2", None))
assert database.changes_get(package_ahriman.base) == Changes("sha2", "change2", "pkgbuild1")

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"}