Compare commits

..

4 Commits

20 changed files with 116 additions and 45 deletions

View File

@@ -27,21 +27,26 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports // imports
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error", "simple-import-sort/exports": "error",
"simple-import-sort/imports": "error",
// brackets // core
"curly": "error", "curly": "error",
"@stylistic/brace-style": ["error", "1tbs"], "eqeqeq": "error",
"no-console": "error",
"no-eval": "error",
// stylistic // stylistic
"@stylistic/array-bracket-spacing": ["error", "never"], "@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "as-needed"], "@stylistic/arrow-parens": ["error", "as-needed"],
"@stylistic/brace-style": ["error", "1tbs"],
"@stylistic/comma-dangle": ["error", "always-multiline"], "@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/comma-spacing": ["error", { before: false, after: true }], "@stylistic/comma-spacing": ["error", { before: false, after: true }],
"@stylistic/eol-last": ["error", "always"], "@stylistic/eol-last": ["error", "always"],
"@stylistic/indent": ["error", 4], "@stylistic/indent": ["error", 4],
"@stylistic/jsx-curly-brace-presence": ["error", { props: "never", children: "never" }],
"@stylistic/jsx-quotes": ["error", "prefer-double"], "@stylistic/jsx-quotes": ["error", "prefer-double"],
"@stylistic/jsx-self-closing-comp": ["error", { component: true, html: true }],
"@stylistic/max-len": ["error", { "@stylistic/max-len": ["error", {
code: 120, code: 120,
ignoreComments: true, ignoreComments: true,
@@ -49,6 +54,7 @@ export default tseslint.config(
ignoreTemplateLiterals: true, ignoreTemplateLiterals: true,
ignoreUrls: true, ignoreUrls: true,
}], }],
"@stylistic/member-delimiter-style": ["error", { multiline: { delimiter: "semi" }, singleline: { delimiter: "semi" } }],
"@stylistic/no-extra-parens": ["error", "all"], "@stylistic/no-extra-parens": ["error", "all"],
"@stylistic/no-multi-spaces": "error", "@stylistic/no-multi-spaces": "error",
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }], "@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
@@ -58,10 +64,14 @@ export default tseslint.config(
"@stylistic/semi": ["error", "always"], "@stylistic/semi": ["error", "always"],
// typescript // typescript
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@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-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
); );

View File

@@ -11,30 +11,30 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.9",
"@mui/x-data-grid": "^8.27.3", "@mui/x-data-grid": "^8.27.4",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.90.21",
"chart.js": "^4.5.0", "chart.js": "^4.5.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-syntax-highlighter": "^16.1.1" "react-syntax-highlighter": "^16.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.3.0", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1" "vite-tsconfig-paths": "^6.1.1"

View File

@@ -49,7 +49,7 @@ export class Client {
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = { const requestInit: RequestInit = {
method: method || (json ? "POST" : "GET"), method: method ?? (json ? "POST" : "GET"),
headers, headers,
signal: controller.signal, signal: controller.signal,
}; };

View File

@@ -31,16 +31,16 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
data={{ data={{
labels: ["packages"], labels: ["packages"],
datasets: [ datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
{ {
label: "bases", label: "bases",
data: [stats.bases ?? 0], data: [stats.bases ?? 0],
backgroundColor: indigo[300], backgroundColor: indigo[300],
}, },
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
], ],
}} }}
options={{ options={{
@@ -48,7 +48,7 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
responsive: true, responsive: true,
scales: { scales: {
x: { stacked: true }, x: { stacked: true },
y: { stacked: true }, y: { stacked: false },
}, },
}} }}
/>; />;

View File

@@ -86,12 +86,12 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
<Grid container spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300 }}> <Box sx={{ height: 300 }}>
<PackageCountBarChart stats={status.stats} /> <PackageCountBarChart stats={status.stats} />
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300, display: "flex", justifyContent: "center", alignItems: "center" }}> <Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<StatusPieChart counters={status.packages} /> <StatusPieChart counters={status.packages} />
</Box> </Box>
</Grid> </Grid>

View File

@@ -52,7 +52,7 @@ export default function AppLayout(): React.JSX.Element {
return <Container maxWidth="xl"> return <Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<a href="https://github.com/arcan1s/ahriman" title="logo"> <a href="https://ahriman.readthedocs.io/" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" /> <img src="/static/logo.svg" width={30} height={30} alt="" />
</a> </a>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>

View File

@@ -1,22 +1,22 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path"; import path from "path";
import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function renameHtml(newName: string): Plugin { function rename(oldName: string, newName: string): Plugin {
return { return {
name: "rename-html", name: "rename",
enforce: "post", enforce: "post",
generateBundle(_, bundle) { generateBundle(_, bundle) {
if (bundle["index.html"]) { if (bundle[oldName]) {
bundle["index.html"].fileName = newName; bundle[oldName].fileName = newName;
} }
}, },
}; };
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")], plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")],
base: "/", base: "/",
build: { build: {
chunkSizeWarningLimit: 10000, chunkSizeWarningLimit: 10000,

View File

@@ -138,8 +138,14 @@ class PacmanDatabase(SyncHttpClient):
Args: Args:
force(bool): force database synchronization (same as ``pacman -Syy``) force(bool): force database synchronization (same as ``pacman -Syy``)
Raises:
PacmanError: on operation error (invalid scheme or incomplete configuration)
""" """
try:
server = next(iter(self.database.servers)) server = next(iter(self.database.servers))
except StopIteration:
raise PacmanError("No configured servers available for database") from None
filename = f"{self.database.name}.files.tar.gz" filename = f"{self.database.name}.files.tar.gz"
url = f"{server}/{filename}" url = f"{server}/{filename}"

View File

@@ -58,7 +58,7 @@ class Auth(LazyLogging):
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>"
@property @property
def is_external(self) -> bool: def is_external(self) -> bool:

View File

@@ -116,6 +116,19 @@ class GitRemoteError(RuntimeError):
RuntimeError.__init__(self, "Git remote failed") RuntimeError.__init__(self, "Git remote failed")
class GPGError(RuntimeError):
"""
PGP/GPG related exception
"""
def __init__(self, details: str) -> None:
"""
Args:
details(str): details of the exception
"""
RuntimeError.__init__(self, f"GPG operation failed: {details}")
class InitializeError(RuntimeError): class InitializeError(RuntimeError):
""" """
base service initialization exception base service initialization exception

View File

@@ -86,6 +86,11 @@ class ArchiveRotationTrigger(Trigger):
package(Package): package which has been updated to check for older versions package(Package): package which has been updated to check for older versions
pacman(Pacman): alpm wrapper instance pacman(Pacman): alpm wrapper instance
""" """
# explicit guard to skip process in case if rotation is disabled
# this guard is supposed to speedup process
if self.keep_built_packages == 0:
return
packages: dict[tuple[str, str], Package] = {} packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions # we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()): for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()):
@@ -94,7 +99,7 @@ class ArchiveRotationTrigger(Trigger):
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version) comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
to_remove = sorted(packages.values(), key=cmp_to_key(comparator)) to_remove = sorted(packages.values(), key=cmp_to_key(comparator))
# 0 will implicitly be translated into [:0], meaning we keep all packages
for single in to_remove[:-self.keep_built_packages]: for single in to_remove[:-self.keep_built_packages]:
self.logger.info("removing version %s of package %s", single.version, single.base) self.logger.info("removing version %s of package %s", single.version, single.base)
for archive in single.packages.values(): for archive in single.packages.values():

View File

@@ -20,7 +20,7 @@
from pathlib import Path from pathlib import Path
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildError from ahriman.core.exceptions import BuildError, GPGError
from ahriman.core.http import SyncHttpClient from ahriman.core.http import SyncHttpClient
from ahriman.core.utils import check_output from ahriman.core.utils import check_output
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@@ -147,12 +147,19 @@ class GPG(SyncHttpClient):
Returns: Returns:
str: full PGP key fingerprint str: full PGP key fingerprint
Raises:
GPGError: if key is in wrong format
""" """
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
# fingerprint line will be like # fingerprint line will be like
# fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2: # fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
try:
fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines())) fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines()))
return fingerprint.split(":")[-2] return fingerprint.split(":")[-2]
except (IndexError, StopIteration):
raise GPGError(f"key {key} has invalid metadata") from None
def key_import(self, server: str, key: str) -> None: def key_import(self, server: str, key: str) -> None:
""" """

View File

@@ -88,8 +88,12 @@ class User:
""" """
if not self.password: if not self.password:
return None return None
try:
algo = next(segment for segment in self.password.split("$") if segment) algo = next(segment for segment in self.password.split("$") if segment)
return f"${algo}$" return f"${algo}$"
except StopIteration:
return None
@staticmethod @staticmethod
def generate_password(length: int) -> str: def generate_password(length: int) -> str:

View File

@@ -25,8 +25,10 @@ class AuthInfoSchema(Schema):
authorization information schema authorization information schema
""" """
control = fields.String(required=True, metadata={ control = fields.String(
metadata={
"description": "HTML control for login interface", "description": "HTML control for login interface",
"example": "<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>",
}) })
enabled = fields.Boolean(required=True, metadata={ enabled = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not", "description": "Whether authentication is enabled or not",
@@ -35,5 +37,5 @@ class AuthInfoSchema(Schema):
"description": "Whether authorization provider is external (e.g. OAuth)", "description": "Whether authorization provider is external (e.g. OAuth)",
}) })
username = fields.String(metadata={ username = fields.String(metadata={
"description": "Currently authenticated username if any", "description": "Currently authenticated username if available",
}) })

View File

@@ -27,10 +27,12 @@ class AutoRefreshIntervalSchema(Schema):
interval = fields.Integer(required=True, metadata={ interval = fields.Integer(required=True, metadata={
"description": "Auto refresh interval in milliseconds", "description": "Auto refresh interval in milliseconds",
"example": "60000",
}) })
is_active = fields.Boolean(required=True, metadata={ is_active = fields.Boolean(required=True, metadata={
"description": "Whether this interval is the default active one", "description": "Whether this interval is the default active one",
}) })
text = fields.String(required=True, metadata={ text = fields.String(required=True, metadata={
"description": "Human readable interval description", "description": "Human readable interval description",
"example": "1 minute",
}) })

View File

@@ -40,6 +40,7 @@ class InfoV2Schema(Schema):
}) })
index_url = fields.String(metadata={ index_url = fields.String(metadata={
"description": "URL to the repository index page", "description": "URL to the repository index page",
"example": "https://ahriman.readthedocs.io/",
}) })
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={ repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
"description": "List of loaded repositories", "description": "List of loaded repositories",

View File

@@ -31,7 +31,7 @@ class RepositoryIdSchema(Schema):
}) })
id = fields.String(metadata={ id = fields.String(metadata={
"description": "Unique repository identifier", "description": "Unique repository identifier",
"example": "aur-x86_64", "example": "x86_64-aur",
}) })
repository = fields.String(metadata={ repository = fields.String(metadata={
"description": "Repository name", "description": "Repository name",

View File

@@ -183,6 +183,15 @@ def test_sync_files_local(pacman_database: PacmanDatabase, mocker: MockerFixture
copy_mock.assert_called_once_with(Path("/var/core.files.tar.gz"), pytest.helpers.anyvar(int)) copy_mock.assert_called_once_with(Path("/var/core.files.tar.gz"), pytest.helpers.anyvar(int))
def test_sync_files_no_servers(pacman_database: PacmanDatabase) -> None:
"""
must raise PacmanError if no servers are configured
"""
pacman_database.database.servers = []
with pytest.raises(PacmanError):
pacman_database.sync_files(force=False)
def test_sync_files_unknown_source(pacman_database: PacmanDatabase) -> None: def test_sync_files_unknown_source(pacman_database: PacmanDatabase) -> None:
""" """
must raise an exception in case if server scheme is unsupported must raise an exception in case if server scheme is unsupported

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import GPGError
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@@ -113,6 +114,15 @@ fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:""")
check_output_mock.assert_called_once_with("gpg", "--with-colons", "--fingerprint", key, logger=gpg.logger) check_output_mock.assert_called_once_with("gpg", "--with-colons", "--fingerprint", key, logger=gpg.logger)
def test_key_fingerprint_invalid(gpg: GPG, mocker: MockerFixture) -> None:
"""
must raise GPGError if no fingerprint found in output
"""
mocker.patch("ahriman.core.sign.gpg.check_output", return_value="no fingerprint here")
with pytest.raises(GPGError):
gpg.key_fingerprint("0xCE3C45C2")
def test_key_import(gpg: GPG, mocker: MockerFixture) -> None: def test_key_import(gpg: GPG, mocker: MockerFixture) -> None:
""" """
must import PGP key from the server must import PGP key from the server

View File

@@ -12,6 +12,8 @@ def test_algo() -> None:
""" """
assert User(username="user", password=None, access=UserAccess.Read).algo is None assert User(username="user", password=None, access=UserAccess.Read).algo is None
assert User(username="user", password="", access=UserAccess.Read).algo is None assert User(username="user", password="", access=UserAccess.Read).algo is None
assert User(username="user", password="$$$", access=UserAccess.Read).algo is None
assert User( assert User(
username="user", username="user",
password="$6$rounds=656000$mWBiecMPrHAL1VgX$oU4Y5HH8HzlvMaxwkNEJjK13ozElyU1wAHBoO/WW5dAaE4YEfnB0X3FxbynKMl4FBdC3Ovap0jINz4LPkNADg0", password="$6$rounds=656000$mWBiecMPrHAL1VgX$oU4Y5HH8HzlvMaxwkNEJjK13ozElyU1wAHBoO/WW5dAaE4YEfnB0X3FxbynKMl4FBdC3Ovap0jINz4LPkNADg0",