Compare commits

..

13 Commits

Author SHA1 Message Date
c924699163 feat: raise OptionError on missing OAuth provider class instead of generic AttributeError 2026-02-19 02:04:35 +02:00
d7cee68cb7 fix: use context manager for selector and smtp session 2026-02-19 02:01:56 +02:00
d6865dc363 fix: force data filter for tar archive extraction
(python3.14 default anyway)
2026-02-19 01:13:50 +02:00
1e3850464c feat: (more) secure cookies 2026-02-18 23:48:28 +02:00
59205b990c fix: speedup table reload by updating only changed statuses
it has been found that on big (>100) repos it starts lagging on reload.
This commit adds guard to avoid updating rows whose package statuses
were not changed
2026-02-18 22:57:39 +02:00
bbf9e38fda Release 2.20.0rc1 2026-02-18 13:34:08 +02:00
ba80a91d95 feat: implement CSRF protection 2026-02-18 13:34:08 +02:00
536d040a6a feat: handle only unknownpackageerror on aur load 2026-02-18 13:34:08 +02:00
bed8752f3a fix: filter logs by repository (twice) before rotation 2026-02-18 13:34:05 +02:00
4093ca8986 fix: do not clear queue on queue fetch failures 2026-02-18 13:34:03 +02:00
f027155885 docs: correct docstring for list_flatmap method 2026-02-18 13:34:03 +02:00
443d4ae667 fix: correct vcs definition for cvs packages 2026-02-18 13:34:00 +02:00
c8f7fa8c51 fix: load gitremote triggers configuration schema from non-standard
paths
2026-02-18 13:33:57 +02:00
14 changed files with 97 additions and 49 deletions

View File

@@ -198,13 +198,16 @@
function updateTable(table, rows) {
// instead of using load method here, we just update rows manually to avoid table reinitialization
const currentData = table.bootstrapTable("getData").reduce((accumulator, row) => {
accumulator[row.id] = row["0"];
accumulator[row.id] = {state: row["0"], status: row.status};
return accumulator;
}, {});
// insert or update rows
// insert or update rows, skipping ones whose status hasn't changed
rows.forEach(row => {
if (Object.hasOwn(currentData, row.id)) {
row["0"] = currentData[row.id]; // copy checkbox state
if (row.status === currentData[row.id].status) {
return;
}
row["0"] = currentData[row.id].state; // copy checkbox state
table.bootstrapTable("updateByUniqueId", {
id: row.id,
row: row,

View File

@@ -81,11 +81,13 @@ class Backup(Handler):
Returns:
set[Path]: map of the filesystem paths
"""
paths = set(configuration.include.glob("*.ini"))
# configuration files
root, _ = configuration.check_loaded()
paths.add(root) # the configuration itself
paths.add(SQLite.database_path(configuration)) # database
paths = set(configuration.includes)
paths.add(root)
# database
paths.add(SQLite.database_path(configuration))
# local caches
repository_paths = configuration.repository_paths

View File

@@ -47,7 +47,7 @@ class Restore(Handler):
report(bool): force enable or disable reporting
"""
with tarfile.open(args.path) as archive:
archive.extractall(path=args.output) # nosec
archive.extractall(path=args.output, filter="data")
@staticmethod
def _set_repo_restore_parser(root: SubParserAction) -> argparse.ArgumentParser:

View File

@@ -86,7 +86,7 @@ class OAuth(Mapping):
Raises:
OptionError: in case if invalid OAuth provider name supplied
"""
provider: type[aioauth_client.OAuth2Client] = getattr(aioauth_client, name)
provider: type = getattr(aioauth_client, name, type(None))
try:
is_oauth2_client = issubclass(provider, aioauth_client.OAuth2Client)
except TypeError: # what if it is random string?

View File

@@ -74,6 +74,18 @@ class Email(Report, JinjaTemplate):
self.ssl = SmtpSSLSettings.from_option(configuration.get(section, "ssl", fallback="disabled"))
self.user = configuration.get(section, "user", fallback=None)
@property
def _smtp_session(self) -> type[smtplib.SMTP]:
"""
build SMTP session based on configuration settings
Returns:
type[smtplib.SMTP]: SMTP or SMTP_SSL session depending on whether SSL is enabled or not
"""
if self.ssl == SmtpSSLSettings.SSL:
return smtplib.SMTP_SSL
return smtplib.SMTP
def _send(self, text: str, attachment: dict[str, str]) -> None:
"""
send email callback
@@ -93,16 +105,13 @@ class Email(Report, JinjaTemplate):
attach.add_header("Content-Disposition", "attachment", filename=filename)
message.attach(attach)
if self.ssl != SmtpSSLSettings.SSL:
session = smtplib.SMTP(self.host, self.port)
with self._smtp_session(self.host, self.port) as session:
if self.ssl == SmtpSSLSettings.STARTTLS:
session.starttls()
else:
session = smtplib.SMTP_SSL(self.host, self.port)
if self.user is not None and self.password is not None:
session.login(self.user, self.password)
session.sendmail(self.sender, self.receivers, message.as_string())
session.quit()
if self.user is not None and self.password is not None:
session.login(self.user, self.password)
session.sendmail(self.sender, self.receivers, message.as_string())
def generate(self, packages: list[Package], result: Result) -> None:
"""

View File

@@ -164,6 +164,11 @@ def check_output(*args: str, exception: Exception | Callable[[int, list[str], st
if key in ("PATH",) # whitelisted variables only
} | environment
result: dict[str, list[str]] = {
"stdout": [],
"stderr": [],
}
with subprocess.Popen(args, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
user=user, env=full_environment, text=True, encoding="utf8", errors="backslashreplace",
bufsize=1) as process:
@@ -172,30 +177,27 @@ def check_output(*args: str, exception: Exception | Callable[[int, list[str], st
input_channel.write(input_data)
input_channel.close()
selector = selectors.DefaultSelector()
selector.register(get_io(process, "stdout"), selectors.EVENT_READ, data="stdout")
selector.register(get_io(process, "stderr"), selectors.EVENT_READ, data="stderr")
with selectors.DefaultSelector() as selector:
selector.register(get_io(process, "stdout"), selectors.EVENT_READ, data="stdout")
selector.register(get_io(process, "stderr"), selectors.EVENT_READ, data="stderr")
result: dict[str, list[str]] = {
"stdout": [],
"stderr": [],
}
while selector.get_map(): # while there are unread selectors, keep reading
for key_data, output in poll(selector):
result[key_data].append(output)
stdout = "\n".join(result["stdout"]).rstrip("\n") # remove newline at the end of any
stderr = "\n".join(result["stderr"]).rstrip("\n")
while selector.get_map(): # while there are unread selectors, keep reading
for key_data, output in poll(selector):
result[key_data].append(output)
status_code = process.wait()
if status_code != 0:
if isinstance(exception, Exception):
raise exception
if callable(exception):
raise exception(status_code, list(args), stdout, stderr)
raise CalledProcessError(status_code, list(args), stderr)
return stdout
stdout = "\n".join(result["stdout"]).rstrip("\n") # remove newline at the end of any
stderr = "\n".join(result["stderr"]).rstrip("\n")
if status_code != 0:
if isinstance(exception, Exception):
raise exception
if callable(exception):
raise exception(status_code, list(args), stdout, stderr)
raise CalledProcessError(status_code, list(args), stderr)
return stdout
def check_user(root: Path, *, unsafe: bool) -> None:

View File

@@ -72,7 +72,7 @@ def _security() -> list[dict[str, Any]]:
return [{
"token": {
"type": "apiKey", # as per specification we are using api key
"name": "API_SESSION",
"name": "AHRIMAN",
"in": "cookie",
}
}]

View File

@@ -149,11 +149,17 @@ def setup_auth(application: Application, configuration: Configuration, validator
Application: configured web application
"""
secret_key = _cookie_secret_key(configuration)
storage = EncryptedCookieStorage(secret_key, cookie_name="API_SESSION", max_age=validator.max_age)
storage = EncryptedCookieStorage(
secret_key,
cookie_name="AHRIMAN",
max_age=validator.max_age,
httponly=True,
samesite="Strict",
)
setup_session(application, storage)
authorization_policy = _AuthorizationPolicy(validator)
identity_policy = aiohttp_security.SessionIdentityPolicy()
identity_policy = aiohttp_security.SessionIdentityPolicy("SESSION")
aiohttp_security.setup(application, identity_policy, authorization_policy)
application.middlewares.append(_auth_handler(validator.allow_read_only))

View File

@@ -25,6 +25,6 @@ class AuthSchema(Schema):
request cookie authorization schema
"""
API_SESSION = fields.String(required=True, metadata={
AHRIMAN = fields.String(required=True, metadata={
"description": "API session key as returned from authorization",
})

View File

@@ -34,7 +34,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
_, repository_id = configuration.check_loaded()
Restore.run(args, repository_id, configuration, report=False)
extract_mock.extractall.assert_called_once_with(path=args.output)
extract_mock.extractall.assert_called_once_with(path=args.output, filter="data")
def test_disallow_multi_architecture_run() -> None:

View File

@@ -1,3 +1,5 @@
import smtplib
import pytest
from pytest_mock import MockerFixture
@@ -6,6 +8,7 @@ from ahriman.core.configuration import Configuration
from ahriman.core.report.email import Email
from ahriman.models.package import Package
from ahriman.models.result import Result
from ahriman.models.smtp_ssl_settings import SmtpSSLSettings
def test_template(configuration: Configuration) -> None:
@@ -37,17 +40,36 @@ def test_template_full(configuration: Configuration) -> None:
assert Email(repository_id, configuration, "email").template_full == root.parent / template
def test_smtp_session(email: Email) -> None:
"""
must build normal SMTP session if SSL is disabled
"""
email.ssl = SmtpSSLSettings.Disabled
assert email._smtp_session == smtplib.SMTP
email.ssl = SmtpSSLSettings.STARTTLS
assert email._smtp_session == smtplib.SMTP
def test_smtp_session_ssl(email: Email) -> None:
"""
must build SMTP_SSL session if SSL is enabled
"""
email.ssl = SmtpSSLSettings.SSL
assert email._smtp_session == smtplib.SMTP_SSL
def test_send(email: Email, mocker: MockerFixture) -> None:
"""
must send an email with attachment
"""
smtp_mock = mocker.patch("smtplib.SMTP")
smtp_mock.return_value.__enter__.return_value = smtp_mock.return_value
email._send("a text", {"attachment.html": "an attachment"})
smtp_mock.return_value.starttls.assert_not_called()
smtp_mock.return_value.login.assert_not_called()
smtp_mock.return_value.sendmail.assert_called_once_with(email.sender, email.receivers, pytest.helpers.anyvar(int))
smtp_mock.return_value.quit.assert_called_once_with()
def test_send_auth(configuration: Configuration, mocker: MockerFixture) -> None:
@@ -57,6 +79,7 @@ def test_send_auth(configuration: Configuration, mocker: MockerFixture) -> None:
configuration.set_option("email", "user", "username")
configuration.set_option("email", "password", "password")
smtp_mock = mocker.patch("smtplib.SMTP")
smtp_mock.return_value.__enter__.return_value = smtp_mock.return_value
_, repository_id = configuration.check_loaded()
email = Email(repository_id, configuration, "email")
@@ -70,6 +93,7 @@ def test_send_auth_no_password(configuration: Configuration, mocker: MockerFixtu
"""
configuration.set_option("email", "user", "username")
smtp_mock = mocker.patch("smtplib.SMTP")
smtp_mock.return_value.__enter__.return_value = smtp_mock.return_value
_, repository_id = configuration.check_loaded()
email = Email(repository_id, configuration, "email")
@@ -83,6 +107,7 @@ def test_send_auth_no_user(configuration: Configuration, mocker: MockerFixture)
"""
configuration.set_option("email", "password", "password")
smtp_mock = mocker.patch("smtplib.SMTP")
smtp_mock.return_value.__enter__.return_value = smtp_mock.return_value
_, repository_id = configuration.check_loaded()
email = Email(repository_id, configuration, "email")
@@ -96,6 +121,7 @@ def test_send_ssl_tls(configuration: Configuration, mocker: MockerFixture) -> No
"""
configuration.set_option("email", "ssl", "ssl")
smtp_mock = mocker.patch("smtplib.SMTP_SSL")
smtp_mock.return_value.__enter__.return_value = smtp_mock.return_value
_, repository_id = configuration.check_loaded()
email = Email(repository_id, configuration, "email")
@@ -103,7 +129,6 @@ def test_send_ssl_tls(configuration: Configuration, mocker: MockerFixture) -> No
smtp_mock.return_value.starttls.assert_not_called()
smtp_mock.return_value.login.assert_not_called()
smtp_mock.return_value.sendmail.assert_called_once_with(email.sender, email.receivers, pytest.helpers.anyvar(int))
smtp_mock.return_value.quit.assert_called_once_with()
def test_send_starttls(configuration: Configuration, mocker: MockerFixture) -> None:
@@ -112,6 +137,7 @@ def test_send_starttls(configuration: Configuration, mocker: MockerFixture) -> N
"""
configuration.set_option("email", "ssl", "starttls")
smtp_mock = mocker.patch("smtplib.SMTP")
smtp_mock.return_value.__enter__.return_value = smtp_mock.return_value
_, repository_id = configuration.check_loaded()
email = Email(repository_id, configuration, "email")

View File

@@ -23,7 +23,7 @@ def test_security() -> None:
must generate security definitions for swagger
"""
token = next(iter(_security()))["token"]
assert token == {"type": "apiKey", "name": "API_SESSION", "in": "cookie"}
assert token == {"type": "apiKey", "name": "AHRIMAN", "in": "cookie"}
def test_servers(application: Application) -> None:

View File

@@ -6,4 +6,4 @@ def test_schema() -> None:
must return valid schema
"""
schema = AuthSchema()
assert not schema.validate({"API_SESSION": "key"})
assert not schema.validate({"AHRIMAN": "key"})

View File

@@ -27,7 +27,7 @@ def _client(client: TestClient, mocker: MockerFixture) -> TestClient:
"parameters": [
{
"in": "cookie",
"name": "API_SESSION",
"name": "AHRIMAN",
"schema": {
"type": "string",
},
@@ -39,7 +39,7 @@ def _client(client: TestClient, mocker: MockerFixture) -> TestClient:
"parameters": [
{
"in": "cookie",
"name": "API_SESSION",
"name": "AHRIMAN",
"schema": {
"type": "string",
},
@@ -60,7 +60,7 @@ def _client(client: TestClient, mocker: MockerFixture) -> TestClient:
{
"token": {
"type": "apiKey",
"name": "API_SESSION",
"name": "AHRIMAN",
"in": "cookie",
},
},