Skip to content

Commit

Permalink
Merge pull request #53 from nebulabroadcast/develop
Browse files Browse the repository at this point in the history
6.0.4
  • Loading branch information
martastain authored Mar 12, 2024
2 parents 4ec3987 + 675b049 commit 519dc7d
Show file tree
Hide file tree
Showing 98 changed files with 2,001 additions and 1,283 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Publish a new version

on:
push:
branches: [ "develop" ]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Get the version
id: get_version
uses: SebRollen/[email protected]
with:
file: 'backend/pyproject.toml'
field: 'tool.poetry.version'

- name: Build docker image
uses: docker/build-push-action@v4
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: |
nebulabroadcast/nebula-server:dev
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ COPY ./frontend/public /frontend/public
WORKDIR /frontend
RUN yarn install && yarn build

FROM python:3.11-bullseye
FROM python:3.12-bullseye
ENV PYTHONBUFFERED=1

RUN \
Expand All @@ -28,7 +28,7 @@ RUN \
pip install -U pip && \
pip install poetry && \
poetry config virtualenvs.create false && \
poetry install --no-interaction --no-ansi
poetry install --no-interaction --no-ansi --only main

COPY ./backend /backend
COPY --from=build /frontend/dist/ /frontend
Expand Down
9 changes: 4 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
IMAGE_NAME=nebulabroadcast/nebula-server:latest
IMAGE_NAME=nebulabroadcast/nebula-server:dev
VERSION=$(shell cd backend && poetry run python -c 'import nebula' --version)

check: check_version
cd frontend && yarn format

cd backend && \
poetry run black . && \
poetry run ruff --fix . && \
poetry run ruff format . && \
poetry run ruff check --fix . && \
poetry run mypy .

check_version:
echo $(VERSION)
sed -i "s/^version = \".*\"/version = \"$(VERSION)\"/" backend/pyproject.toml
cd backend && poetry version $(VERSION)

build:
docker build -t $(IMAGE_NAME) .
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,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/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)
![Python version](https://img.shields.io/badge/python-3.11-blue?style=for-the-badge)

Nebula is an open source broadcast automation and media asset management system for television, radio and VOD platforms.
Since 2012 Nebula has proven stable and reliable software in 24/7 broadcast environment
Expand Down
47 changes: 32 additions & 15 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ class LoginRequestModel(RequestModel):
username: str = Field(
...,
title="Username",
example="admin",
regex=r"^[a-zA-Z0-9_\-\.]{2,}$",
examples=["admin"],
pattern=r"^[a-zA-Z0-9_\-\.]{2,}$",
)
password: str = Field(
...,
title="Password",
description="Password in plain text",
example="Password.123",
examples=["Password.123"],
)


Expand All @@ -41,8 +41,8 @@ class LoginResponseModel(ResponseModel):


class PasswordRequestModel(RequestModel):
login: str | None = Field(None, title="Login", example="admin")
password: str = Field(..., title="Password", example="Password.123")
login: str | None = Field(None, title="Login", examples=["admin"])
password: str = Field(..., title="Password", examples=["Password.123"])


#
Expand All @@ -64,27 +64,38 @@ async def check_failed_login(ip_address: str) -> None:
raise nebula.LoginFailedException("Too many failed login attempts")


async def set_failed_login(ip_address: str):
async def set_failed_login(ip_address: str) -> None:
ns = "login-failed-ip"
failed_attempts = await nebula.redis.incr(ns, ip_address)
failed_attempts_str = await nebula.redis.incr(ns, ip_address)
failed_attempts = int(failed_attempts_str) if failed_attempts_str else 0

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:
ban_time = nebula.config.failed_login_ban_time or 0
await nebula.redis.set(
"banned-ip-until",
ip_address,
time.time() + nebula.config.failed_login_ban_time,
str(time.time() + ban_time),
)


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


class LoginRequest(APIRequest):
"""Login using a username and password"""
"""Login using a username and password
This request will return an access token that can be used in the
Authorization header for the subsequent requests.
If the login fails, request will return 401 Unauthorized.
If the login fails too many (configurable) times,
the IP address will be banned for a certain amount of time (configurable).
"""

name: str = "login"
response_model = LoginResponseModel
Expand Down Expand Up @@ -113,12 +124,15 @@ async def handle(


class LogoutRequest(APIRequest):
"""Log out the current user"""
"""Log out the current user.
This request will invalidate the access token used in the Authorization header.
"""

name: str = "logout"
title: str = "Logout"

async def handle(self, authorization: str | None = Header(None)):
async def handle(self, authorization: str | None = Header(None)) -> None:
if not authorization:
raise nebula.UnauthorizedException("No authorization header provided")

Expand All @@ -134,7 +148,10 @@ async def handle(self, authorization: str | None = Header(None)):
class SetPassword(APIRequest):
"""Set a new password for the current (or a given) user.
In order to set a password for another user, the current user must be an admin.
Normal users can only change their own password.
In order to set a password for another user,
the current user must be an admin, otherwise a 403 error is returned.
"""

name: str = "password"
Expand All @@ -144,10 +161,10 @@ async def handle(
self,
request: PasswordRequestModel,
user: CurrentUser,
):
) -> Response:
if request.login:
if not user.is_admin:
raise nebula.UnauthorizedException(
raise nebula.ForbiddenException(
"Only admin can change other user's password"
)
query = "SELECT meta FROM users WHERE login = $1"
Expand Down
58 changes: 31 additions & 27 deletions backend/api/browse.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from typing import Any, Literal
from typing import Literal

from nxtools import slugify
from pydantic import Field

import nebula
from nebula.common import sql_list
from nebula.common import SerializableValue, sql_list
from nebula.enum import MetaClass
from nebula.exceptions import NebulaException
from nebula.metadata.normalize import normalize_meta
Expand Down Expand Up @@ -40,36 +40,38 @@


class ConditionModel(RequestModel):
key: str = Field(..., example="status")
value: Any = Field(None, example=1)
operator: str = Field("=", example="=")
key: str = Field(..., examples=["status"])
value: SerializableValue = Field(None, examples=[1])
operator: ConditionOperator = Field("=", examples=["="])


class BrowseRequestModel(RequestModel):
view: int | None = Field(
None,
title="View ID",
example=1,
examples=[1],
)
query: str | None = Field(
None,
title="Search query",
example="star trek",
examples=["star trek"],
)
conditions: list[ConditionModel] | None = Field(
default_factory=list,
title="Conditions",
description="List of additional conditions",
example=[
{"key": "id_folder", "value": 1, "operator": "="},
examples=[
[
{"key": "id_folder", "value": 1, "operator": "="},
]
],
)
columns: list[str] | None = Field(
None,
title="Columns",
description="Override the view columns."
"Note that several columns are always included.",
example=["title", "subtitle", "id_folder"],
examples=[["title", "subtitle", "id_folder"]],
)
ignore_view_conditions: bool = Field(False, title="Ignore view conditions")
limit: int = Field(500, title="Limit", description="Maximum number of items")
Expand All @@ -80,29 +82,31 @@ class BrowseRequestModel(RequestModel):

class BrowseResponseModel(ResponseModel):
columns: list[str] = Field(default_factory=list)
data: list[dict[str, Any]] = Field(
data: list[dict[str, SerializableValue]] = Field(
default_factory=list,
example=[
{
"id": 1,
"title": "Star Trek IV",
"subtitle": "The Voyage Home",
"id_folder": 1,
"status": 1,
"duration": 6124.3,
}
examples=[
[
{
"id": 1,
"title": "Star Trek IV",
"subtitle": "The Voyage Home",
"id_folder": 1,
"status": 1,
"duration": 6124.3,
}
]
],
)
order_by: str | None = Field(None)
order_dir: OrderDirection = Field(None)
order_dir: OrderDirection = Field(...)


#
# Request
#


def sanitize_value(value: Any) -> Any:
def sanitize_value(value: SerializableValue) -> str:
if isinstance(value, str):
value = value.replace("'", "''")
return str(value)
Expand All @@ -129,7 +133,7 @@ def build_conditions(conditions: list[ConditionModel]) -> list[str]:
return cond_list


def process_inline_conditions(request: BrowseRequestModel):
def process_inline_conditions(request: BrowseRequestModel) -> None:
if request.query:
query_elements = request.query.split(" ")
reduced_query = []
Expand Down Expand Up @@ -242,7 +246,7 @@ def build_query(

query = f"""
SELECT meta FROM assets {conds}
ORDER BY {order_by} {request.order_dir}
ORDER BY {order_by} {request.order_dir}, id DESC
LIMIT {request.limit}
OFFSET {request.offset}
"""
Expand All @@ -263,9 +267,9 @@ async def handle(
columns: list[str] = ["title", "duration"]
if request.view is not None and not request.columns:
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
view = nebula.settings.get_view(request.view)
if (view is not None) and (view.columns is not None):
columns = view.columns
elif request.columns:
columns = request.columns

Expand Down
5 changes: 2 additions & 3 deletions backend/api/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class DeleteRequestModel(RequestModel):
...,
title="Object IDs",
description="A list of object IDs to delete",
example=[1, 2, 3],
examples=[[1, 2, 3]],
)


Expand All @@ -34,7 +34,6 @@ async def handle(
initiator: RequestInitiator,
) -> Response:
"""Delete given objects."""

match request.object_type:
case ObjectType.ITEM:
# TODO: refactor events
Expand Down Expand Up @@ -69,7 +68,7 @@ async def handle(
case _:
# do not delete bins directly
raise nebula.NotImplementedException(
f"Deleting {request.obejct_type} is not implemented"
f"Deleting {request.object_type} is not implemented"
)

# Delete simple objects
Expand Down
Loading

0 comments on commit 519dc7d

Please sign in to comment.