Compare commits
10 commits
api-rate-l
...
main
Author | SHA1 | Date | |
---|---|---|---|
65137b99ac | |||
7f36eafc11 | |||
fc77407379 | |||
0644d260de | |||
8641f7c2ba | |||
6804360352 | |||
f8a67e5fbd | |||
3fb4d18db8 | |||
e6bbd01ea9 | |||
6463f6f9a2 |
11 changed files with 99 additions and 10 deletions
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
The 3-Clause BSD License
|
||||
|
||||
Copyright 2022 Ivan Golikov
|
||||
Copyright 2025 Ivan Golikov
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# Pssecret server
|
||||
|
||||
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
|
||||
[![PyPI - Downloads](https://img.shields.io/pypi/dm/pssecret-server?label=PyPI%20downloads)](https://pypi.org/project/pssecret-server/)
|
||||
|
||||
Pssecret is self-hosted service to share secrets (like passwords) with somebody
|
||||
over the network, but don't want them to appear in chats, unencrypted e-mails, etc.
|
||||
|
@ -50,6 +51,7 @@ Available configuration options:
|
|||
--uds TEXT Bind to a UNIX domain socket.
|
||||
--workers INTEGER Number of worker processes. Defaults to the
|
||||
$WEB_CONCURRENCY environment variable if available, or 1.
|
||||
--version Show the version and exit.
|
||||
--help Show this message and exit.
|
||||
```
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from importlib.metadata import version
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
|
@ -21,5 +23,6 @@ import uvicorn
|
|||
),
|
||||
type=int,
|
||||
)
|
||||
@click.version_option(version("pssecret_server"))
|
||||
def cli(**kwargs) -> None:
|
||||
uvicorn.run("pssecret_server.main:app", **kwargs)
|
||||
|
|
|
@ -8,7 +8,7 @@ from redis.asyncio import Redis
|
|||
from pssecret_server.fernet import get_fernet
|
||||
from pssecret_server.models import Secret, SecretSaveResult
|
||||
from pssecret_server.redis_db import get_redis
|
||||
from pssecret_server.utils import decrypt_secret, encrypt_secret, save_secret
|
||||
from pssecret_server.utils import decrypt_secret, encrypt_secret, getdel, save_secret
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
@ -49,7 +49,7 @@ async def set_secret(
|
|||
async def get_secret(
|
||||
secret_key: str, redis: RedisDep, fernet: FernetDep
|
||||
) -> dict[str, bytes]:
|
||||
data: bytes | None = await redis.getdel(secret_key)
|
||||
data: bytes | None = await getdel(redis, secret_key)
|
||||
|
||||
if data is None:
|
||||
raise HTTPException(404)
|
||||
|
|
|
@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
|
||||
class Secret(BaseModel):
|
||||
data: str = Field(title="Secret", description="Some secret data")
|
||||
data: str = Field(title="Secret", description="Some secret data", min_length=1)
|
||||
|
||||
|
||||
class SecretSaveResult(BaseModel):
|
||||
|
|
|
@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
redis_url: RedisDsn = RedisDsn("redis://localhost")
|
||||
secrets_encryption_key: bytes
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
from functools import lru_cache
|
||||
from uuid import uuid4
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from redis.asyncio import Redis
|
||||
from redis.exceptions import ResponseError
|
||||
from redis.typing import ResponseT
|
||||
|
||||
from pssecret_server.models import Secret
|
||||
|
||||
|
@ -30,3 +33,35 @@ async def save_secret(data: Secret, redis: Redis) -> str:
|
|||
await redis.setex(new_key, 60 * 60 * 24, data.data)
|
||||
|
||||
return new_key
|
||||
|
||||
|
||||
@lru_cache
|
||||
async def _is_getdel_available(redis: Redis) -> bool:
|
||||
"""Checks the availability of GETDEL command on the Redis server instance
|
||||
|
||||
GETDEL is not available in Redis prior to version 6.2
|
||||
"""
|
||||
try:
|
||||
await redis.getdel("test:getdel:availability")
|
||||
except ResponseError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def getdel(redis: Redis, key: str) -> ResponseT:
|
||||
"""Gets the value of key and deletes the key
|
||||
|
||||
Depending on the capabilities of Redis server this function
|
||||
will either call GETDEL command, either first call GETSET with empty string
|
||||
and DEL right after that.
|
||||
"""
|
||||
result: ResponseT
|
||||
|
||||
if await _is_getdel_available(redis):
|
||||
result = await redis.getdel(key)
|
||||
else:
|
||||
result = await redis.getset(key, "")
|
||||
await redis.delete(key)
|
||||
|
||||
return result
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "pssecret-server"
|
||||
version = "0.0.1"
|
||||
version = "1.1.2"
|
||||
description = "API service for secrets sharing over network"
|
||||
authors = ["Ivan Golikov <root@ivnglkv.me>"]
|
||||
license = "BSD-3-Clause"
|
||||
|
@ -9,12 +9,14 @@ homepage = "https://git.ivnglkv.me/root/pssecret-server"
|
|||
repository = "https://git.ivnglkv.me/root/pssecret-server"
|
||||
documentation = "https://git.ivnglkv.me/root/pssecret-server/wiki"
|
||||
classifiers = [
|
||||
"Development Status :: 2 - Pre-Alpha",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: FastAPI",
|
||||
"Intended Audience :: Information Technology",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application"
|
||||
]
|
||||
|
||||
|
@ -51,3 +53,6 @@ reportUnusedCallResult = "none"
|
|||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
|
|
@ -3,6 +3,13 @@ from fastapi.testclient import TestClient
|
|||
from tests.factories import SecretFactory
|
||||
|
||||
|
||||
def test_empty_secret_is_not_accepted(client: TestClient):
|
||||
response = client.post("/secret", json={"data": ""})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "data" in response.text
|
||||
|
||||
|
||||
def test_store_secret_returns_key(client: TestClient):
|
||||
response = client.post("/secret", json=dict(SecretFactory().build()))
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from pssecret_server.utils import get_new_key, save_secret
|
||||
from pssecret_server.utils import get_new_key, getdel, save_secret
|
||||
|
||||
from ..factories import SecretFactory
|
||||
|
||||
|
@ -33,3 +34,22 @@ async def test_save_secret_data(redis_server: Redis) -> None:
|
|||
|
||||
assert redis_data is not None
|
||||
assert redis_data.decode() == secret.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("getdel_available", [True, False])
|
||||
@patch("pssecret_server.utils._is_getdel_available", new_callable=AsyncMock)
|
||||
async def test_getdel(
|
||||
mock_is_getdel_available: AsyncMock,
|
||||
getdel_available: bool,
|
||||
redis_server: Redis,
|
||||
) -> None:
|
||||
mock_is_getdel_available.return_value = getdel_available
|
||||
|
||||
test_value = "test_data"
|
||||
test_key = "test_key"
|
||||
await redis_server.set(test_key, test_value)
|
||||
|
||||
result = await getdel(redis_server, test_key)
|
||||
|
||||
assert result.decode() == test_value # pyright: ignore[reportAttributeAccessIssue]
|
||||
assert not await redis_server.exists(test_key)
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
from pssecret_server.utils import decrypt_secret, encrypt_secret
|
||||
from pssecret_server.utils import _is_getdel_available, decrypt_secret, encrypt_secret
|
||||
|
||||
from ..factories import SecretFactory
|
||||
|
||||
|
@ -29,3 +32,17 @@ def test_secret_is_not_decryptable_by_random_key(fernet: Fernet):
|
|||
|
||||
with pytest.raises(InvalidToken):
|
||||
decrypt_secret(encrypted_secret.data.encode(), random_fernet)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("getdel_effect", "expected_result"), [(None, True), (ResponseError, False)]
|
||||
)
|
||||
async def test_is_getdel_available(
|
||||
getdel_effect: ResponseError | None, expected_result: bool
|
||||
):
|
||||
redis = AsyncMock()
|
||||
redis.getdel.side_effect = getdel_effect # pyright: ignore[reportAny]
|
||||
|
||||
result = await _is_getdel_available(redis)
|
||||
|
||||
assert result is expected_result
|
||||
|
|
Loading…
Reference in a new issue