mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 02:53:38 +00:00
Compare commits
10 Commits
5e090cebdb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fce49f22c9 | |||
| af8e2c9e9b | |||
| 1c312bb528 | |||
| e39194e9f6 | |||
| 21cc029c18 | |||
| 40671b99d5 | |||
| 3ad2c494af | |||
| 34014d1cdd | |||
| 93ed2b864b | |||
| cca931ccd0 |
@@ -1,7 +1,7 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-20.04
|
os: ubuntu-lts-latest
|
||||||
tools:
|
tools:
|
||||||
python: "3.12"
|
python: "3.12"
|
||||||
apt_packages:
|
apt_packages:
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ ahriman.web.middlewares.auth\_handler module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.middlewares.exception\_handler module
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,18 @@ Remove packages
|
|||||||
|
|
||||||
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
|
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
|
Check outdated packages
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* ``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.
|
* ``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``.
|
* ``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.
|
* ``host`` - host to bind, string, optional.
|
||||||
* ``index_url`` - full URL of the repository index page, string, optional.
|
* ``index_url`` - full URL of the repository index page, string, optional.
|
||||||
|
|||||||
@@ -33,3 +33,28 @@ The service provides several commands aim to do easy repository backup and resto
|
|||||||
.. code-block:: shell
|
.. code-block:: shell
|
||||||
|
|
||||||
sudo -u ahriman ahriman repo-rebuild --from-database
|
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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import stylistic from "@stylistic/eslint-plugin";
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||||
@@ -8,7 +9,11 @@ import tseslint from "typescript-eslint";
|
|||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
react.configs.flat.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
],
|
||||||
files: ["src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
@@ -17,13 +22,14 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
"@stylistic": stylistic,
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
"simple-import-sort": simpleImportSort,
|
"simple-import-sort": simpleImportSort,
|
||||||
"@stylistic": stylistic,
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
|
||||||
// imports
|
// imports
|
||||||
@@ -33,7 +39,7 @@ export default tseslint.config(
|
|||||||
// core
|
// core
|
||||||
"curly": "error",
|
"curly": "error",
|
||||||
"eqeqeq": "error",
|
"eqeqeq": "error",
|
||||||
"no-console": "error",
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
"no-eval": "error",
|
"no-eval": "error",
|
||||||
|
|
||||||
// stylistic
|
// stylistic
|
||||||
@@ -68,6 +74,7 @@ export default tseslint.config(
|
|||||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||||
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
||||||
"@typescript-eslint/no-deprecated": "error",
|
"@typescript-eslint/no-deprecated": "error",
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||||
"@typescript-eslint/prefer-optional-chain": "error",
|
"@typescript-eslint/prefer-optional-chain": "error",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"react": ">=19.2.0 <19.3.0",
|
"react": ">=19.2.0 <19.3.0",
|
||||||
"react-chartjs-2": ">=5.3.0 <5.4.0",
|
"react-chartjs-2": ">=5.3.0 <5.4.0",
|
||||||
"react-dom": ">=19.2.0 <19.3.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"
|
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react-syntax-highlighter": ">=15.5.0 <15.6.0",
|
"@types/react-syntax-highlighter": ">=15.5.0 <15.6.0",
|
||||||
"@vitejs/plugin-react": ">=6.0.0 <6.1.0",
|
"@vitejs/plugin-react": ">=6.0.0 <6.1.0",
|
||||||
"eslint": ">=9.39.0 <9.40.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-hooks": ">=7.0.0 <7.1.0",
|
||||||
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
|
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
|
||||||
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",
|
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",
|
||||||
|
|||||||
55
frontend/src/components/common/ErrorBoundary.tsx
Normal file
55
frontend/src/components/common/ErrorBoundary.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -21,11 +21,18 @@ import "chartSetup";
|
|||||||
import "utils";
|
import "utils";
|
||||||
|
|
||||||
import App from "App";
|
import App from "App";
|
||||||
|
import ErrorFallback from "components/common/ErrorBoundary";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ErrorBoundary
|
||||||
|
FallbackComponent={ErrorFallback}
|
||||||
|
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
"baseUrl": "src",
|
"baseUrl": "src",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noImplicitOverride": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2020",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true
|
"useDefineForClassFields": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -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
|
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
|
||||||
; by default.
|
; by default.
|
||||||
autorefresh_intervals = 5 1 10 30 60
|
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 file upload endpoint used by some triggers.
|
||||||
;enable_archive_upload = no
|
;enable_archive_upload = no
|
||||||
; Address to bind the server.
|
; Address to bind the server.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class PkgbuildParser(shlex.shlex):
|
|||||||
|
|
||||||
# ignore substitution and extend bash symbols
|
# ignore substitution and extend bash symbols
|
||||||
self.wordchars += "${}#:+-@!"
|
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 = ""
|
self.commenters = ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class AUR(Remote):
|
|||||||
parse RPC response to package list
|
parse RPC response to package list
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response(dict[str, Any]): RPC response json
|
response(dict[str, Any]): RPC response JSON
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[AURPackage]: list of parsed packages
|
list[AURPackage]: list of parsed packages
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class Official(Remote):
|
|||||||
parse RPC response to package list
|
parse RPC response to package list
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response(dict[str, Any]): RPC response json
|
response(dict[str, Any]): RPC response JSON
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[AURPackage]: list of parsed packages
|
list[AURPackage]: list of parsed packages
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class OfficialSyncdb(Official):
|
|||||||
updates.
|
updates.
|
||||||
|
|
||||||
This approach also has limitations, because we don't require superuser rights (neither going to download database
|
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.
|
changed in the future.
|
||||||
|
|
||||||
Still we leave search function based on the official repositories RPC.
|
Still we leave search function based on the official repositories RPC.
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ class Auth(LazyLogging):
|
|||||||
@property
|
@property
|
||||||
def auth_control(self) -> str:
|
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
|
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
|
request to external resource
|
||||||
|
|
||||||
Returns:
|
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>"
|
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>"
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ except ImportError:
|
|||||||
from typing import Any
|
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:
|
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ class OAuth(Mapping):
|
|||||||
@property
|
@property
|
||||||
def auth_control(self) -> str:
|
def auth_control(self) -> str:
|
||||||
"""
|
"""
|
||||||
get authorization html control
|
get authorization HTML control
|
||||||
|
|
||||||
Returns:
|
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>"
|
return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>"
|
||||||
|
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ class Sources(LazyLogging):
|
|||||||
else:
|
else:
|
||||||
patch.write(sources_dir / "PKGBUILD")
|
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
|
read file content from the specified commit
|
||||||
|
|
||||||
@@ -426,6 +426,10 @@ class Sources(LazyLogging):
|
|||||||
path(Path): path to file inside the repository
|
path(Path): path to file inside the repository
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: file content at specified commit
|
str | None: file content at specified commit if available
|
||||||
"""
|
"""
|
||||||
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
|
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
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
|
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
Args:
|
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)
|
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
|
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
|
store keys internally. Otherwise, the special implementation will be used, which supports arrays
|
||||||
@@ -80,7 +80,7 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
"""
|
"""
|
||||||
configparser.RawConfigParser.__init__(
|
configparser.RawConfigParser.__init__(
|
||||||
self,
|
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,
|
allow_no_value=allow_no_value,
|
||||||
strict=False,
|
strict=False,
|
||||||
empty_lines_in_values=not allow_multi_key,
|
empty_lines_in_values=not allow_multi_key,
|
||||||
|
|||||||
@@ -358,6 +358,38 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
|||||||
"min": 0,
|
"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": {
|
"enable_archive_upload": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"coerce": "boolean",
|
"coerce": "boolean",
|
||||||
|
|||||||
@@ -150,6 +150,6 @@ class ShellTemplate(Template):
|
|||||||
break
|
break
|
||||||
|
|
||||||
kwargs.update(mapping)
|
kwargs.update(mapping)
|
||||||
substituted = dict(generator(kwargs))
|
kwargs.update(dict(generator(kwargs)))
|
||||||
|
|
||||||
return self.safe_substitute(kwargs | substituted)
|
return self.safe_substitute(kwargs)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ChangesOperations(Operations):
|
|||||||
values
|
values
|
||||||
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
|
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
|
||||||
on conflict (package_base, repository) do update set
|
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,
|
"package_base": package_base,
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class SyncHttpClient(LazyLogging):
|
|||||||
headers(dict[str, str] | None, optional): request headers (Default value = None)
|
headers(dict[str, str] | None, optional): request headers (Default value = None)
|
||||||
params(list[tuple[str, str]] | None, optional): request query parameters (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)
|
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)
|
files(dict[str, MultipartType] | None, optional): multipart upload (Default value = None)
|
||||||
stream(bool | None, optional): handle response as stream (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)
|
session(requests.Session | None, optional): session object if any (Default value = None)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from ahriman.models.result import Result
|
|||||||
|
|
||||||
class Console(Report):
|
class Console(Report):
|
||||||
"""
|
"""
|
||||||
html report generator
|
HTML report generator
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
use_utf(bool): print utf8 symbols instead of ASCII
|
use_utf(bool): print utf8 symbols instead of ASCII
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ from ahriman.models.result import Result
|
|||||||
|
|
||||||
class HTML(Report, JinjaTemplate):
|
class HTML(Report, JinjaTemplate):
|
||||||
"""
|
"""
|
||||||
html report generator
|
HTML report generator
|
||||||
|
|
||||||
Attributes:
|
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
|
template(Path | str): name or path to template for full package list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class Report(LazyLogging):
|
|||||||
Args:
|
Args:
|
||||||
repository_id(RepositoryId): repository unique identifier
|
repository_id(RepositoryId): repository unique identifier
|
||||||
configuration(Configuration): configuration instance
|
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:
|
Returns:
|
||||||
Report: client according to current settings
|
Report: client according to current settings
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class Trigger(LazyLogging):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
CONFIGURATION_SCHEMA(ConfigurationSchema): (class attribute) configuration schema template
|
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
|
configuration(Configuration): configuration instance
|
||||||
repository_id(RepositoryId): repository unique identifier
|
repository_id(RepositoryId): repository unique identifier
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class HttpUpload(SyncHttpClient):
|
|||||||
str: calculated checksum of the file
|
str: calculated checksum of the file
|
||||||
"""
|
"""
|
||||||
with path.open("rb") as local_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()
|
return md5.hexdigest()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ class S3(Upload):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_etag(path: Path, chunk_size: int) -> str:
|
def calculate_etag(path: Path, chunk_size: int) -> str:
|
||||||
"""
|
"""
|
||||||
calculate amazon s3 etag
|
calculate amazon s3 etag. Credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
|
||||||
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
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path(Path): path to local file
|
path(Path): path to local file
|
||||||
@@ -76,14 +74,17 @@ class S3(Upload):
|
|||||||
md5s = []
|
md5s = []
|
||||||
with path.open("rb") as local_file:
|
with path.open("rb") as local_file:
|
||||||
for chunk in iter(lambda: local_file.read(chunk_size), b""):
|
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
|
# in case if there is only one chunk it must be just this checksum
|
||||||
# and checksum of joined digest otherwise (including empty list)
|
if len(md5s) == 1:
|
||||||
checksum = md5s[0] if len(md5s) == 1 else hashlib.md5(b"".join(md5.digest() for md5 in md5s)) # nosec
|
return md5s[0].hexdigest()
|
||||||
# in case if there are more than one chunk it should be appended with amount of chunks
|
|
||||||
|
# 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 ""
|
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
|
||||||
return f"{checksum.hexdigest()}{suffix}"
|
return f"{md5.hexdigest()}{suffix}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def files_remove(local_files: dict[Path, str], remote_objects: dict[Path, Any]) -> None:
|
def files_remove(local_files: dict[Path, str], remote_objects: dict[Path, Any]) -> None:
|
||||||
|
|||||||
@@ -231,13 +231,13 @@ def check_user(root: Path, *, unsafe: bool) -> None:
|
|||||||
|
|
||||||
def dataclass_view(instance: Any) -> dict[str, Any]:
|
def dataclass_view(instance: Any) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
convert dataclass instance to json object
|
convert dataclass instance to JSON object
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance(Any): dataclass instance
|
instance(Any): dataclass instance
|
||||||
|
|
||||||
Returns:
|
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})
|
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:
|
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:
|
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
|
known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
|
||||||
(Default value = None)
|
(Default value = None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
T: json without ``None`` values
|
T: JSON without ``None`` values
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
|
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class AURPackage:
|
|||||||
Examples:
|
Examples:
|
||||||
Mainly this class must be used from class methods instead of default :func:`__init__()`::
|
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...
|
>>> # ...or alternatively...
|
||||||
>>> package = AURPackage.from_repo(metadata) # load package from official repository RPC
|
>>> 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
|
>>> # 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
|
construct package descriptor from official repository RPC properties
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: AUR package descriptor
|
Self: AUR package descriptor
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class BuildStatus:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json status view
|
generate JSON status view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ class Changes:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct changes from the json dump
|
construct changes from the JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: changes object
|
Self: changes object
|
||||||
@@ -55,7 +55,7 @@ class Changes:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json change view
|
generate JSON change view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ class Counters:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct counters from json dump
|
construct counters from JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: status counters
|
Self: status counters
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ class Event:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct event from the json dump
|
construct event from the JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: event object
|
Self: event object
|
||||||
@@ -102,7 +102,7 @@ class Event:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json event view
|
generate JSON event view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ class InternalStatus:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct internal status from json dump
|
construct internal status from JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: internal status
|
Self: internal status
|
||||||
@@ -70,7 +70,7 @@ class InternalStatus:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json status view
|
generate JSON status view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ class LogRecord:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, package_base: str, dump: dict[str, Any]) -> Self:
|
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:
|
Args:
|
||||||
package_base(str): package base for which log record belongs
|
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:
|
Returns:
|
||||||
Self: log record object
|
Self: log record object
|
||||||
@@ -63,7 +63,7 @@ class LogRecord:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json log record view
|
generate JSON log record view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ class Package(LazyLogging):
|
|||||||
version(str): package full version
|
version(str): package full version
|
||||||
|
|
||||||
Examples:
|
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)
|
>>> 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)
|
>>> package = Package.from_archive(local_path, pacman)
|
||||||
|
|
||||||
@@ -273,10 +273,10 @@ class Package(LazyLogging):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct package properties from json dump
|
construct package properties from JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: package properties
|
Self: package properties
|
||||||
@@ -396,7 +396,7 @@ class Package(LazyLogging):
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json package view
|
generate JSON package view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class PackageDescription:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
Unlike the :class:`ahriman.models.package.Package` class, this implementation only holds properties.
|
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::
|
:class:`pyalpm.Package` instance::
|
||||||
|
|
||||||
>>> description = PackageDescription.from_json(dump)
|
>>> description = PackageDescription.from_json(dump)
|
||||||
@@ -126,10 +126,10 @@ class PackageDescription:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct package properties from json dump
|
construct package properties from JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: package properties
|
Self: package properties
|
||||||
@@ -169,7 +169,7 @@ class PackageDescription:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json package view
|
generate JSON package view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -87,10 +87,10 @@ class PkgbuildPatch:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct patch descriptor from the json dump
|
construct patch descriptor from the JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: patch object
|
Self: patch object
|
||||||
@@ -125,7 +125,7 @@ class PkgbuildPatch:
|
|||||||
# the source value looks like shell array, remove brackets and parse with shlex
|
# the source value looks like shell array, remove brackets and parse with shlex
|
||||||
return shlex.split(shell_array[1:-1])
|
return shlex.split(shell_array[1:-1])
|
||||||
case json_array if json_array.startswith("[") and json_array.endswith("]"):
|
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)
|
parsed: list[str] = json.loads(json_array)
|
||||||
return parsed
|
return parsed
|
||||||
case variable:
|
case variable:
|
||||||
@@ -220,7 +220,7 @@ class PkgbuildPatch:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json patch view
|
generate JSON patch view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ class RemoteSource:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
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:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: remote source
|
Self: remote source
|
||||||
@@ -102,7 +102,7 @@ class RemoteSource:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json package remote view
|
generate JSON package remote view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ class ReportSettings(StrEnum):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
Disabled(ReportSettings): option which generates no report for testing purpose
|
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
|
Email(ReportSettings): email report generation
|
||||||
Console(ReportSettings): print result to console
|
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
|
RSS(ReportSettings): RSS report generation
|
||||||
RemoteCall(ReportSettings): remote ahriman server call
|
RemoteCall(ReportSettings): remote ahriman server call
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class RepositoryId:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json package view
|
generate JSON package view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ class RepositoryStats:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||||
"""
|
"""
|
||||||
construct counters from json dump
|
construct counters from JSON dump
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dump(dict[str, Any]): json dump body
|
dump(dict[str, Any]): JSON dump body
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Self: status counters
|
Self: status counters
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class Worker:
|
|||||||
|
|
||||||
def view(self) -> dict[str, Any]:
|
def view(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
generate json patch view
|
generate JSON worker view
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json-friendly dictionary
|
dict[str, Any]: json-friendly dictionary
|
||||||
|
|||||||
@@ -21,26 +21,34 @@ import aiohttp_cors
|
|||||||
|
|
||||||
from aiohttp.web import Application
|
from aiohttp.web import Application
|
||||||
|
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["setup_cors"]
|
__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
|
setup CORS for the web application
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
application(Application): web application instance
|
application(Application): web application instance
|
||||||
|
configuration(Configuration): configuration instance
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
aiohttp_cors.CorsConfig: generated CORS configuration
|
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={
|
cors = aiohttp_cors.setup(application, defaults={
|
||||||
"*": aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
|
origin: aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
|
||||||
expose_headers="*",
|
expose_headers=expose_headers,
|
||||||
allow_headers="*",
|
allow_headers=allow_headers,
|
||||||
allow_methods="*",
|
allow_methods=allow_methods,
|
||||||
)
|
)
|
||||||
|
for origin in configuration.getlist("web", "cors_allow_origins", fallback=["*"])
|
||||||
})
|
})
|
||||||
for route in application.router.routes():
|
for route in application.router.routes():
|
||||||
cors.add(route)
|
cors.add(route)
|
||||||
|
|||||||
61
src/ahriman/web/middlewares/etag_handler.py
Normal file
61
src/ahriman/web/middlewares/etag_handler.py
Normal 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
|
||||||
@@ -42,13 +42,13 @@ __all__ = ["exception_handler"]
|
|||||||
|
|
||||||
def _is_templated_unauthorized(request: Request) -> bool:
|
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:
|
Args:
|
||||||
request(Request): source request to check
|
request(Request): source request to check
|
||||||
|
|
||||||
Returns:
|
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") \
|
return request.path in ("/api/v1/login", "/api/v1/logout") \
|
||||||
and "application/json" not in request.headers.getall("accept", [])
|
and "application/json" not in request.headers.getall("accept", [])
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def server_info(view: BaseView) -> dict[str, Any]:
|
|||||||
view(BaseView): view of the request
|
view(BaseView): view of the request
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: server info as a json response
|
dict[str, Any]: server info as a JSON response
|
||||||
"""
|
"""
|
||||||
autorefresh_intervals = [
|
autorefresh_intervals = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class DocsView(BaseView):
|
|||||||
@aiohttp_jinja2.template("api.jinja2")
|
@aiohttp_jinja2.template("api.jinja2")
|
||||||
async def get(self) -> dict[str, Any]:
|
async def get(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
return static docs html
|
return static docs HTML
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: parameters for jinja template
|
dict[str, Any]: parameters for jinja template
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class SwaggerView(BaseView):
|
|||||||
get api specification
|
get api specification
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: 200 with json api specification
|
Response: 200 with JSON api specification
|
||||||
"""
|
"""
|
||||||
spec = self.request.app["swagger_dict"]
|
spec = self.request.app["swagger_dict"]
|
||||||
is_body_parameter: Callable[[dict[str, str]], bool] = lambda p: p["in"] == "body" or p["in"] == "formData"
|
is_body_parameter: Callable[[dict[str, str]], bool] = lambda p: p["in"] == "body" or p["in"] == "formData"
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class BaseView(View, CorsViewMixin):
|
|||||||
filter and convert data and return :class:`aiohttp.web.Response` object
|
filter and convert data and return :class:`aiohttp.web.Response` object
|
||||||
|
|
||||||
Args:
|
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
|
**kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -209,8 +209,8 @@ class BaseView(View, CorsViewMixin):
|
|||||||
HTTPBadRequest: if supplied parameters are invalid
|
HTTPBadRequest: if supplied parameters are invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
limit = int(self.request.query.get("limit", default=-1))
|
limit = int(self.request.query.get("limit", -1))
|
||||||
offset = int(self.request.query.get("offset", default=0))
|
offset = int(self.request.query.get("offset", 0))
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from ahriman.models.repository_id import RepositoryId
|
|||||||
from ahriman.web.apispec.info import setup_apispec
|
from ahriman.web.apispec.info import setup_apispec
|
||||||
from ahriman.web.cors import setup_cors
|
from ahriman.web.cors import setup_cors
|
||||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
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.exception_handler import exception_handler
|
||||||
from ahriman.web.middlewares.metrics_handler import metrics_handler
|
from ahriman.web.middlewares.metrics_handler import metrics_handler
|
||||||
from ahriman.web.middlewares.request_id_handler import request_id_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(normalize_path_middleware(append_slash=False, remove_slash=True))
|
||||||
application.middlewares.append(request_id_handler())
|
application.middlewares.append(request_id_handler())
|
||||||
application.middlewares.append(exception_handler(application.logger))
|
application.middlewares.append(exception_handler(application.logger))
|
||||||
|
application.middlewares.append(etag_handler())
|
||||||
application.middlewares.append(metrics_handler())
|
application.middlewares.append(metrics_handler())
|
||||||
|
|
||||||
application.logger.info("setup routes")
|
application.logger.info("setup routes")
|
||||||
setup_routes(application, configuration)
|
setup_routes(application, configuration)
|
||||||
|
|
||||||
application.logger.info("setup CORS")
|
application.logger.info("setup CORS")
|
||||||
setup_cors(application)
|
setup_cors(application, configuration)
|
||||||
|
|
||||||
application.logger.info("setup templates")
|
application.logger.info("setup templates")
|
||||||
loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates"))
|
loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates"))
|
||||||
|
|||||||
@@ -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")
|
check_output_mock = mocker.patch("ahriman.core.build_tools.sources.check_output", return_value="content")
|
||||||
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) == "content"
|
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) == "content"
|
||||||
check_output_mock.assert_called_once()
|
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
|
||||||
|
|||||||
@@ -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_ahriman.base).changes is None
|
||||||
assert database.changes_get(package_python_schedule.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
|
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")
|
||||||
|
|||||||
85
tests/ahriman/web/middlewares/test_etag_handler.py
Normal file
85
tests/ahriman/web/middlewares/test_etag_handler.py
Normal 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
|
||||||
@@ -2,13 +2,17 @@ import aiohttp_cors
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aiohttp.web import Application
|
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:
|
def test_setup_cors(application: Application) -> None:
|
||||||
"""
|
"""
|
||||||
must setup CORS
|
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
|
# let's test here that it is enabled for all requests
|
||||||
for route in application.router.routes():
|
for route in application.router.routes():
|
||||||
# we don't want to deal with match info here though
|
# we don't want to deal with match info here though
|
||||||
@@ -18,3 +22,34 @@ def test_setup_cors(application: Application) -> None:
|
|||||||
continue
|
continue
|
||||||
request = pytest.helpers.request(application, url, route.method, resource=route.resource)
|
request = pytest.helpers.request(application, url, route.method, resource=route.resource)
|
||||||
assert cors._cors_impl._router_adapter.is_cors_enabled_on_request(request)
|
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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user