Skip to content

Commit

Permalink
Merge pull request #40 from nebulabroadcast/develop
Browse files Browse the repository at this point in the history
Nebula 6.0.3
  • Loading branch information
martastain authored Jan 3, 2024
2 parents fa70b0f + 1cf91bd commit 4ec3987
Show file tree
Hide file tree
Showing 31 changed files with 276 additions and 77 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ NEBULA
======

![GitHub release (latest by date)](https://img.shields.io/github/v/release/nebulabroadcast/nebula?style=for-the-badge)
![Maintenance](https://img.shields.io/maintenance/yes/2023?style=for-the-badge)
![Maintenance](https://img.shields.io/maintenance/yes/2024?style=for-the-badge)
![Last commit](https://img.shields.io/github/last-commit/nebulabroadcast/nebula?style=for-the-badge)
![Python version](https://img.shields.io/badge/python-3.10-blue?style=for-the-badge)

Expand Down
51 changes: 50 additions & 1 deletion backend/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import time

from fastapi import Header, Request, Response
from pydantic import Field

import nebula
from server.clientinfo import get_real_ip
from server.dependencies import CurrentUser
from server.models import RequestModel, ResponseModel
from server.request import APIRequest
Expand Down Expand Up @@ -47,6 +50,39 @@ class PasswordRequestModel(RequestModel):
#


async def check_failed_login(ip_address: str) -> None:
banned_until = await nebula.redis.get("banned-ip-until", ip_address)
if banned_until is None:
return

if float(banned_until) > time.time():
nebula.log.warn(
f"Attempt to login from banned IP {ip_address}. "
f"Retry in {float(banned_until) - time.time():.2f} seconds."
)
await nebula.redis.delete("login-failed-ip", ip_address)
raise nebula.LoginFailedException("Too many failed login attempts")


async def set_failed_login(ip_address: str):
ns = "login-failed-ip"
failed_attempts = await nebula.redis.incr(ns, ip_address)
await nebula.redis.expire(
ns, ip_address, 600
) # this is just for the clean-up, it cannot be used to reset the counter

if failed_attempts > nebula.config.max_failed_login_attempts:
await nebula.redis.set(
"banned-ip-until",
ip_address,
time.time() + nebula.config.failed_login_ban_time,
)


async def clear_failed_login(ip_address: str):
await nebula.redis.delete("login-failed-ip", ip_address)


class LoginRequest(APIRequest):
"""Login using a username and password"""

Expand All @@ -58,7 +94,20 @@ async def handle(
request: Request,
payload: LoginRequestModel,
) -> LoginResponseModel:
user = await nebula.User.login(payload.username, payload.password)
if request is not None:
await check_failed_login(get_real_ip(request))

try:
user = await nebula.User.login(payload.username, payload.password)
except nebula.LoginFailedException as e:
if request is not None:
await set_failed_login(get_real_ip(request))
# re-raise the exception
raise e

if request is not None:
await clear_failed_login(get_real_ip(request))

session = await Session.create(user, request)
return LoginResponseModel(access_token=session.token)

Expand Down
13 changes: 6 additions & 7 deletions backend/api/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class BrowseResponseModel(ResponseModel):


def sanitize_value(value: Any) -> Any:
if type(value) is str:
if isinstance(value, str):
value = value.replace("'", "''")
return str(value)

Expand All @@ -116,7 +116,7 @@ def build_conditions(conditions: list[ConditionModel]) -> list[str]:
), f"Invalid meta key {condition.key}"
condition.value = normalize_meta(condition.key, condition.value)
if condition.operator in ["IN", "NOT IN"]:
assert type(condition.value) is list, "Value must be a list"
assert isinstance(condition.value, list), "Value must be a list"
values = sql_list([sanitize_value(v) for v in condition.value], t="str")
cond_list.append(f"meta->>'{condition.key}' {condition.operator} {values}")
elif condition.operator in ["IS NULL", "IS NOT NULL"]:
Expand Down Expand Up @@ -185,14 +185,14 @@ def build_query(

if request.view is None:
try:
request.view = nebula.settings.views[0]
request.view = nebula.settings.views[0].id
except IndexError as e:
raise NebulaException("No views defined") from e

# Process views

if request.view is not None and not request.ignore_view_conditions:
assert type(request.view) is int, "View must be an integer"
assert isinstance(request.view, int), "View must be an integer"
if (view := nebula.settings.get_view(request.view)) is not None:
if view.folders:
cond_list.append(f"id_folder IN {sql_list(view.folders)}")
Expand Down Expand Up @@ -222,7 +222,7 @@ def build_query(
c2 = f"meta->'assignees' @> '[{user.id}]'::JSONB"
cond_list.append(f"({c1} OR {c2})")

if (can_view := user["can/asset_view"]) and type(can_view) is list:
if (can_view := user["can/asset_view"]) and isinstance(can_view, list):
cond_list.append(f"id_folder IN {sql_list(can_view)}")

# Build conditions
Expand Down Expand Up @@ -260,10 +260,9 @@ async def handle(
request: BrowseRequestModel,
user: CurrentUser,
) -> BrowseResponseModel:

columns: list[str] = ["title", "duration"]
if request.view is not None and not request.columns:
assert type(request.view) is int, "View must be an integer"
assert isinstance(request.view, int), "View must be an integer"
if (view := nebula.settings.get_view(request.view)) is not None:
if view.columns is not None:
columns = view.columns
Expand Down
2 changes: 2 additions & 0 deletions backend/api/jobs/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ async def handle(

if allow_if_elm := action_settings.findall("allow_if"):
allow_if_cond = allow_if_elm[0].text
if not allow_if_cond:
continue

for id_asset in request.ids:
asset = await nebula.Asset.load(id_asset)
Expand Down
22 changes: 15 additions & 7 deletions backend/api/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ async def send_bytes_range_requests(file_name: str, start: int, end: int):
"""
CHUNK_SIZE = 1024 * 8

async with aiofiles.open(file_name, mode="rb") as f:
await f.seek(start)
chs = await f.tell()
while (pos := chs) <= end:
read_size = min(CHUNK_SIZE, end + 1 - pos)
data = await f.read(read_size)
yield data
sent_bytes = 0
try:
async with aiofiles.open(file_name, mode="rb") as f:
await f.seek(start)
pos = start
while pos < end:
read_size = min(CHUNK_SIZE, end - pos + 1)
data = await f.read(read_size)
yield data
pos += len(data)
sent_bytes += len(data)
finally:
nebula.log.trace(
f"Finished sending file {start}-{end}. Sent {sent_bytes} bytes. Expected {end-start+1} bytes"
)


def _get_range_header(range_header: str, file_size: int) -> tuple[int, int]:
Expand Down
2 changes: 1 addition & 1 deletion backend/api/scheduler/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async def create_new_event(
if event_data.items:
for item_data in event_data.items:
if item_data.get("id"):
assert type(item_data["id"]) == int, "Invalid item ID"
assert isinstance(item_data["id"], int), "Invalid item ID"
item = await nebula.Item.load(item_data["id"], connection=conn)
else:
item = nebula.Item(connection=conn)
Expand Down
4 changes: 2 additions & 2 deletions backend/api/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def can_modify_object(obj, user: nebula.User):
acl = user.get("can/asset_edit", False)
if not acl:
raise nebula.ForbiddenException("You are not allowed to edit assets")
elif type(acl) == list and obj["id_folder"] not in acl:
elif isinstance(acl, list) and obj["id_folder"] not in acl:
raise nebula.ForbiddenException(
"You are not allowed to edit assets in this folder"
)
Expand All @@ -130,7 +130,7 @@ async def can_modify_object(obj, user: nebula.User):
acl = user.get("can/scheduler_edit", False)
if not acl:
raise nebula.ForbiddenException("You are not allowed to edit schedule")
elif type(acl) == list and obj["id_channel"] not in acl:
elif isinstance(acl, list) and obj["id_channel"] not in acl:
raise nebula.ForbiddenException(
"You are not allowed to edit schedule for this channel"
)
Expand Down
4 changes: 2 additions & 2 deletions backend/api/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ async def handle(
else:
direct = True
storage = nebula.storages[asset["id_storage"]]
assert asset.local_path, f"{asset} does not have path set"
bname = os.path.splitext(asset.local_path)[0]
assert asset.path, f"{asset} does not have path set"
bname = os.path.splitext(asset.path)[0]
target_path = f"{bname}.{extension}"

nebula.log.debug(f"Uploading media file for {asset}", user=user.name)
Expand Down
8 changes: 6 additions & 2 deletions backend/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ async def handle(self, user: CurrentUser) -> UserListResponseModel:
async for row in nebula.db.iterate(query):
meta = {}
for key, value in row["meta"].items():
if key == "password":
if key == "api_key_preview":
continue
if key == "api_key":
meta[key] = row["meta"].get("api_key_preview", "*****")
elif key == "password":
continue
elif key.startswith("can/"):
meta[key.replace("can/", "can_")] = value
Expand All @@ -76,7 +80,7 @@ class SaveUserRequest(APIRequest):
title: str = "Save user data"
responses = [204, 201]

async def handle(self, user: CurrentUser, payload: UserModel) -> None:
async def handle(self, user: CurrentUser, payload: UserModel) -> Response:
new_user = payload.id is None

if not user.is_admin:
Expand Down
9 changes: 6 additions & 3 deletions backend/nebula/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
__version__ = "6.0.2"

__all__ = [
"config",
"settings",
Expand Down Expand Up @@ -33,6 +31,8 @@

import sys

from nebula.version import __version__

if "--version" in sys.argv:
print(__version__)
sys.exit(0)
Expand All @@ -53,7 +53,7 @@
UnauthorizedException,
ValidationException,
)
from .log import log
from .log import LogLevel, log
from .messaging import msg
from .objects.asset import Asset
from .objects.bin import Bin
Expand All @@ -65,6 +65,9 @@
from .settings import load_settings, settings
from .storages import Storage, storages

log.user = "nebula"
log.level = LogLevel[config.log_level.upper()]


def run(entrypoint):
"""Run a coroutine in the event loop.
Expand Down
18 changes: 18 additions & 0 deletions backend/nebula/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ class NebulaConfig(BaseModel):
description="Password hashing method",
)

max_failed_login_attempts: int = Field(
10,
description="Maximum number of failed login attempts before the IP is banned",
)

failed_login_ban_time: int = Field(
1800,
description="Time in seconds for which the IP is banned "
"after too many failed login attempts",
)

log_level: Literal[
"trace", "debug", "info", "success", "warning", "error", "critical"
] = Field(
"debug",
description="Logging level",
)


def load_config() -> NebulaConfig:
prefix = "NEBULA_"
Expand Down
2 changes: 1 addition & 1 deletion backend/nebula/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(

if log is True or self.log:
logger.error(f"EXCEPTION: {self.status} {self.detail}", user=user_name)
elif type(log) is str:
elif isinstance(log, str):
logger.error(f"EXCEPTION: {self.status} {log}", user=user_name)

super().__init__(self.detail)
Expand Down
11 changes: 7 additions & 4 deletions backend/nebula/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ class LogLevel(enum.IntEnum):

class Logger:
user: str = "nebula"
level = LogLevel.TRACE
level = LogLevel.DEBUG

def __call__(self, level: LogLevel, *args, **kwargs):
if level < self.level:
return

lvl = level.name.upper()
usr = kwargs.get("user") or self.user
msg = " ".join([str(arg) for arg in args])

print(
f"{level.name.upper():<8}",
f"{kwargs.get('user') or self.user:<10}",
" ".join([str(arg) for arg in args]),
f"{lvl:<8} {usr:<12} {msg}",
file=sys.stderr,
flush=True,
)
Expand Down
6 changes: 3 additions & 3 deletions backend/nebula/metadata/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def is_serializable(value: Any) -> bool:
This is used to check if a value can be stored in the database.
"""
if type(value) in (str, int, float, bool, dict, list, tuple):
if isinstance(value, (str, int, float, bool, dict, list, tuple)):
return True
return False

Expand Down Expand Up @@ -85,11 +85,11 @@ def normalize_meta(key: str, value: Any) -> Any:
return value

case MetaClass.FRACTION:
assert type(value) is str, f"{key} must be a string. is {type(value)}"
assert isinstance(value, str), f"{key} must be a string. is {type(value)}"
return value

case MetaClass.SELECT:
assert type(value) is str, f"{key} must be a string. is {type(value)}"
assert isinstance(value, str), f"{key} must be a string. is {type(value)}"
return str(value)

case MetaClass.LIST:
Expand Down
22 changes: 20 additions & 2 deletions backend/nebula/objects/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,35 @@ class Asset(BaseObject):

@property
def base_name(self) -> str | None:
"""Return base name of the asset.
"""Return base name of the asset file
Base name is a file name without extension and path.
In case of virtual assets, returns None.
"""
if path := self.meta.get("path"):
return get_base_name(path)
return None

@property
def path(self) -> str | None:
"""Return a local path to the file asset.
Returns None if asset is virtual.
"""
id_storage = self["id_storage"]
path = self["path"]
if not (id_storage and path):
return None
storage_path = storages[id_storage].local_path
full_path = os.path.join(storage_path, path)
return full_path

@property
def local_path(self) -> str | None:
"""Return a local path to the file asset."""
"""Return a local path to the file asset.
Returns None if asset is virtual or file does not exist.
"""
id_storage = self["id_storage"]
path = self["path"]
if not (id_storage and path):
Expand Down
1 change: 1 addition & 0 deletions backend/nebula/objects/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def set_password(self, password: str) -> None:

def set_api_key(self, api_key: str) -> None:
self.meta["api_key"] = hash_password(api_key)
self.meta["api_key_preview"] = api_key[:4] + "*******" + api_key[-4:]

def can(
self,
Expand Down
Loading

0 comments on commit 4ec3987

Please sign in to comment.