Add project scaffolding and documentation

Add .env.example with configuration for database, Redis, security, SMTP, workers, and plugins. Add .gitignore for Python, Node.js, Next.js, Docker volumes, and IDE files. Add MIT License. Update README.md with feature overview, quick start guide, architecture description, plugin system documentation, security details, backup/restore instructions, and developer setup. Add Alembic configuration files and placeholder directories for API, web, worker, and plugin components
This commit is contained in:
2026-06-21 09:31:47 +02:00
parent cfeeccbf53
commit d694c8b8e3
197 changed files with 8583 additions and 56 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+86
View File
@@ -0,0 +1,86 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from apps.api.src.config import settings
from apps.api.src.models.base import Base
# Import all models so Alembic can detect them
from apps.api.src.models import ( # noqa: F401
audit_log,
background_job,
dashboard,
notification,
plugin,
plugin_instance,
plugin_log,
plugin_version,
role,
secret,
service_connection,
session,
system_setting,
user,
widget,
api_token,
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
return settings.DATABASE_URL
def run_migrations_offline() -> None:
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,371 @@
"""initial
Revision ID: 20240620_0001
Revises:
Create Date: 2024-06-20 00:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20240620_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"roles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_system", sa.Boolean(), nullable=False),
sa.Column("permissions", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"system_settings",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("key", sa.String(length=100), nullable=False),
sa.Column("value", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_sensitive", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("first_name", sa.String(length=100), nullable=True),
sa.Column("last_name", sa.String(length=100), nullable=True),
sa.Column("avatar_url", sa.String(length=500), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column("is_owner", sa.Boolean(), nullable=False),
sa.Column("email_verified", sa.Boolean(), nullable=False),
sa.Column("locale", sa.String(length=10), nullable=False),
sa.Column("theme", sa.String(length=20), nullable=False),
sa.Column("timezone", sa.String(length=50), nullable=False),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_login_ip", sa.String(length=45), nullable=True),
sa.Column("login_attempts", sa.Integer(), nullable=False),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("invite_token", sa.String(length=255), nullable=True),
sa.Column("invite_token_expires", sa.DateTime(timezone=True), nullable=True),
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
sa.Column("password_reset_expires", sa.DateTime(timezone=True), nullable=True),
sa.Column("preferences", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("role_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("ix_users_email", "users", ["email"], unique=False)
op.create_index("ix_users_invite_token", "users", ["invite_token"], unique=False)
op.create_index("ix_users_password_reset_token", "users", ["password_reset_token"], unique=False)
op.create_index("ix_users_role_id", "users", ["role_id"], unique=False)
op.create_table(
"api_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("token_hash", sa.String(length=255), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_ip", sa.String(length=45), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("scopes", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"], unique=False)
op.create_table(
"audit_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("action", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=False),
sa.Column("resource_id", sa.String(length=100), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("details", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("severity", sa.String(length=20), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_audit_logs_action", "audit_logs", ["action"], unique=False)
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"], unique=False)
op.create_table(
"encrypted_secrets",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("encrypted_value", sa.Text(), nullable=False),
sa.Column("secret_type", sa.String(length=50), nullable=False),
sa.Column("scope", sa.String(length=100), nullable=False),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_encrypted_secrets_owner_id", "encrypted_secrets", ["owner_id"], unique=False)
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("type", sa.String(length=50), nullable=False),
sa.Column("is_read", sa.Boolean(), nullable=False),
sa.Column("link", sa.String(length=500), nullable=True),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_notifications_user_id", "notifications", ["user_id"], unique=False)
op.create_table(
"sessions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("refresh_token_hash", sa.String(length=255), nullable=False),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_valid", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("refresh_token_hash"),
)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"], unique=False)
op.create_table(
"plugins",
sa.Column("id", sa.String(length=100), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("version", sa.String(length=50), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("author", sa.String(length=200), nullable=True),
sa.Column("category", sa.String(length=100), nullable=False),
sa.Column("icon", sa.String(length=100), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_builtin", sa.Boolean(), nullable=False),
sa.Column("is_installed", sa.Boolean(), nullable=False),
sa.Column("path", sa.String(length=500), nullable=False),
sa.Column("permissions", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("settings_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("credentials_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("widget_types", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("healthcheck_definition", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("api_routes", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("has_frontend", sa.Boolean(), nullable=False),
sa.Column("manifest", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"dashboards",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("icon", sa.String(length=50), nullable=True),
sa.Column("folder", sa.String(length=100), nullable=False),
sa.Column("is_favorite", sa.Boolean(), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("layout", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("layouts_by_breakpoint", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("refresh_interval_seconds", sa.Integer(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("tags", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_dashboards_folder", "dashboards", ["folder"], unique=False)
op.create_index("ix_dashboards_owner_id", "dashboards", ["owner_id"], unique=False)
op.create_table(
"plugin_versions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("plugin_id", sa.String(length=100), nullable=False),
sa.Column("version", sa.String(length=50), nullable=False),
sa.Column("changelog", sa.String(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("package_path", sa.String(length=500), nullable=True),
sa.Column("manifest", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_plugin_versions_plugin_id", "plugin_versions", ["plugin_id"], unique=False)
op.create_table(
"service_connections",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("plugin_id", sa.String(length=100), nullable=False),
sa.Column("base_url", sa.String(length=500), nullable=False),
sa.Column("verify_tls", sa.Boolean(), nullable=False),
sa.Column("timeout_seconds", sa.Integer(), nullable=False),
sa.Column("credentials_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("is_enabled", sa.Boolean(), nullable=False),
sa.Column("health_status", sa.String(length=20), nullable=False),
sa.Column("health_message", sa.Text(), nullable=True),
sa.Column("extra_headers", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.ForeignKeyConstraint(["credentials_id"], ["encrypted_secrets.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_service_connections_credentials_id", "service_connections", ["credentials_id"], unique=False)
op.create_index("ix_service_connections_plugin_id", "service_connections", ["plugin_id"], unique=False)
op.create_table(
"plugin_instances",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("plugin_id", sa.String(length=100), nullable=False),
sa.Column("service_connection_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_enabled", sa.Boolean(), nullable=False),
sa.Column("health_status", sa.String(length=20), nullable=False),
sa.Column("health_message", sa.Text(), nullable=True),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("error_count", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_connection_id"], ["service_connections.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_plugin_instances_plugin_id", "plugin_instances", ["plugin_id"], unique=False)
op.create_index("ix_plugin_instances_service_connection_id", "plugin_instances", ["service_connection_id"], unique=False)
op.create_table(
"background_jobs",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("task_name", sa.String(length=100), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("worker_id", sa.String(length=100), nullable=True),
sa.Column("plugin_id", sa.String(length=100), nullable=True),
sa.Column("instance_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(["instance_id"], ["plugin_instances.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_background_jobs_instance_id", "background_jobs", ["instance_id"], unique=False)
op.create_index("ix_background_jobs_plugin_id", "background_jobs", ["plugin_id"], unique=False)
op.create_table(
"dashboard_widgets",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("dashboard_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("plugin_id", sa.String(length=100), nullable=False),
sa.Column("widget_type", sa.String(length=100), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("position_x", sa.Integer(), nullable=False),
sa.Column("position_y", sa.Integer(), nullable=False),
sa.Column("width", sa.Integer(), nullable=False),
sa.Column("height", sa.Integer(), nullable=False),
sa.Column("min_width", sa.Integer(), nullable=True),
sa.Column("min_height", sa.Integer(), nullable=True),
sa.Column("max_width", sa.Integer(), nullable=True),
sa.Column("max_height", sa.Integer(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("instance_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("is_visible", sa.Boolean(), nullable=False),
sa.Column("is_static", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["dashboard_id"], ["dashboards.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["instance_id"], ["plugin_instances.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_dashboard_widgets_dashboard_id", "dashboard_widgets", ["dashboard_id"], unique=False)
op.create_index("ix_dashboard_widgets_instance_id", "dashboard_widgets", ["instance_id"], unique=False)
op.create_index("ix_dashboard_widgets_plugin_id", "dashboard_widgets", ["plugin_id"], unique=False)
op.create_table(
"plugin_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("plugin_id", sa.String(length=100), nullable=False),
sa.Column("instance_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("level", sa.String(length=20), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("context", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["instance_id"], ["plugin_instances.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_plugin_logs_instance_id", "plugin_logs", ["instance_id"], unique=False)
op.create_index("ix_plugin_logs_plugin_id", "plugin_logs", ["plugin_id"], unique=False)
def downgrade() -> None:
op.drop_table("plugin_logs")
op.drop_table("dashboard_widgets")
op.drop_table("background_jobs")
op.drop_table("plugin_instances")
op.drop_table("service_connections")
op.drop_table("plugin_versions")
op.drop_table("dashboards")
op.drop_table("plugins")
op.drop_table("sessions")
op.drop_table("notifications")
op.drop_table("encrypted_secrets")
op.drop_table("audit_logs")
op.drop_table("api_tokens")
op.drop_table("users")
op.drop_table("system_settings")
op.drop_table("roles")
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@nexadash/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "python -m uvicorn apps.api.src.main:app --reload",
"start": "python -m uvicorn apps.api.src.main:app",
"db:upgrade": "alembic upgrade head",
"db:seed": "python -m apps.api.src.seed",
"test": "pytest"
}
}
View File
View File
View File
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import audit_log as schemas
from apps.api.src.security.dependencies import PermissionRequired
from apps.api.src.services import audit_service
router = APIRouter()
@router.get("", response_model=list[schemas.AuditLogResponse])
async def list_audit_logs(
user: User = Depends(PermissionRequired("audit:read")),
db: AsyncSession = Depends(get_db),
limit: int = 100,
):
return await audit_service.list_audit_logs(db, limit=limit)
+143
View File
@@ -0,0 +1,143 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import auth as schemas
from apps.api.src.security import encryption, tokens
from apps.api.src.security.audit import log_audit
from apps.api.src.security.dependencies import get_current_user
from apps.api.src.security.password import verify_password
from apps.api.src.security.permissions import Permission
from apps.api.src.services import user_service
router = APIRouter()
@router.get("/setup-status", response_model=schemas.SetupStatusResponse)
async def setup_status(db: AsyncSession = Depends(get_db)):
return {"setup_required": await user_service.setup_required(db)}
@router.post("/setup", response_model=schemas.TokenResponse)
async def setup(
data: schemas.SetupRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
if not await user_service.setup_required(db):
raise HTTPException(status_code=400, detail="Setup already completed")
user = await user_service.create_owner(db, data)
return await _create_token_response(user, response)
@router.post("/login", response_model=schemas.TokenResponse)
async def login(
data: schemas.LoginRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_user_by_email(db, data.email)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.locked_until and user.locked_until > datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Account locked due to failed attempts")
if not verify_password(data.password, user.hashed_password):
await user_service.record_failed_login(db, user)
raise HTTPException(status_code=401, detail="Invalid credentials")
await user_service.update_last_login(db, user, request.client.host if request.client else None)
await log_audit(
db,
action="login",
resource_type="user",
user_id=user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return await _create_token_response(user, response)
@router.post("/refresh", response_model=schemas.TokenResponse)
async def refresh(
data: schemas.RefreshRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
payload = tokens.decode_token_safe(data.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
import uuid
user_id = uuid.UUID(payload["sub"])
user = await user_service.get_user_by_id(db, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="Invalid refresh token")
return await _create_token_response(user, response)
@router.post("/logout")
async def logout(
response: Response,
user: User = Depends(get_current_user),
):
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
return {"message": "Logged out"}
@router.post("/change-password")
async def change_password(
data: schemas.PasswordChangeRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await user_service.change_password(db, user, data.current_password, data.new_password)
return {"message": "Password changed"}
@router.post("/password-reset-request")
async def password_reset_request(
data: schemas.PasswordResetRequest,
db: AsyncSession = Depends(get_db),
):
token = await user_service.create_password_reset(db, data.email)
# TODO: send email with token
return {"message": "If the email exists, a reset link was sent"}
@router.post("/password-reset-confirm")
async def password_reset_confirm(
data: schemas.PasswordResetConfirm,
db: AsyncSession = Depends(get_db),
):
await user_service.reset_password(db, data.token, data.new_password)
return {"message": "Password reset successfully"}
async def _create_token_response(user: User, response: Response) -> schemas.TokenResponse:
access = tokens.create_access_token(str(user.id))
refresh, expires = tokens.create_refresh_token(str(user.id))
response.set_cookie(
key="access_token",
value=access,
httponly=True,
secure=True,
samesite="lax",
max_age=60 * 15,
)
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
max_age=60 * 60 * 24 * 7,
)
return schemas.TokenResponse(
access_token=access,
refresh_token=refresh,
expires_at=expires,
)
@@ -0,0 +1,80 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import dashboard as schemas
from apps.api.src.security.dependencies import PermissionRequired, get_current_user
from apps.api.src.services import dashboard_service
router = APIRouter()
@router.get("", response_model=list[schemas.DashboardResponse])
async def list_dashboards(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.list_dashboards(db, user)
@router.post("", response_model=schemas.DashboardResponse, status_code=status.HTTP_201_CREATED)
async def create_dashboard(
data: schemas.DashboardCreate,
user: User = Depends(PermissionRequired("dashboard:write")),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.create_dashboard(db, data, user)
@router.get("/{dashboard_id}", response_model=schemas.DashboardResponse)
async def get_dashboard(
dashboard_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
dashboard = await dashboard_service.get_dashboard(db, dashboard_id)
if not dashboard or dashboard.owner_id != user.id:
raise HTTPException(status_code=404, detail="Dashboard not found")
return dashboard
@router.put("/{dashboard_id}", response_model=schemas.DashboardResponse)
async def update_dashboard(
dashboard_id: uuid.UUID,
data: schemas.DashboardUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.update_dashboard(db, dashboard_id, data, user)
@router.delete("/{dashboard_id}")
async def delete_dashboard(
dashboard_id: uuid.UUID,
user: User = Depends(PermissionRequired("dashboard:delete")),
db: AsyncSession = Depends(get_db),
):
await dashboard_service.delete_dashboard(db, dashboard_id, user)
return {"message": "Dashboard deleted"}
@router.post("/{dashboard_id}/duplicate", response_model=schemas.DashboardResponse)
async def duplicate_dashboard(
dashboard_id: uuid.UUID,
user: User = Depends(PermissionRequired("dashboard:write")),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.duplicate_dashboard(db, dashboard_id, user)
@router.put("/{dashboard_id}/layout", response_model=schemas.DashboardResponse)
async def update_layout(
dashboard_id: uuid.UUID,
data: schemas.DashboardLayoutUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.update_layout(db, dashboard_id, data, user)
@@ -0,0 +1,41 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import notification as schemas
from apps.api.src.security.dependencies import get_current_user
from apps.api.src.services import notification_service
router = APIRouter()
@router.get("", response_model=list[schemas.NotificationResponse])
async def list_notifications(
unread_only: bool = False,
limit: int = 50,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await notification_service.list_notifications(db, user, unread_only=unread_only, limit=limit)
@router.post("/{notification_id}/read")
async def mark_read(
notification_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await notification_service.mark_as_read(db, user, notification_id)
return {"message": "Marked as read"}
@router.post("/read-all")
async def mark_all_read(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await notification_service.mark_as_read(db, user)
return {"message": "All marked as read"}
+114
View File
@@ -0,0 +1,114 @@
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.config import settings
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import plugin as schemas
from apps.api.src.security.dependencies import PermissionRequired
from apps.api.src.services import plugin_service
router = APIRouter()
@router.get("", response_model=list[schemas.PluginResponse])
async def list_plugins(
active_only: bool = False,
user: User = Depends(PermissionRequired("plugin:read")),
db: AsyncSession = Depends(get_db),
):
return await plugin_service.list_plugins(db, active_only=active_only)
@router.get("/{plugin_id}", response_model=schemas.PluginResponse)
async def get_plugin(
plugin_id: str,
user: User = Depends(PermissionRequired("plugin:read")),
db: AsyncSession = Depends(get_db),
):
plugin = await plugin_service.get_plugin_by_id(db, plugin_id)
if not plugin:
raise HTTPException(status_code=404, detail="Plugin not found")
return plugin
@router.put("/{plugin_id}", response_model=schemas.PluginResponse)
async def update_plugin(
plugin_id: str,
data: schemas.PluginUpdate,
user: User = Depends(PermissionRequired("plugin:write")),
db: AsyncSession = Depends(get_db),
):
return await plugin_service.update_plugin(db, plugin_id, data)
@router.delete("/{plugin_id}")
async def delete_plugin(
plugin_id: str,
user: User = Depends(PermissionRequired("plugin:delete")),
db: AsyncSession = Depends(get_db),
):
await plugin_service.delete_plugin(db, plugin_id)
return {"message": "Plugin deleted"}
@router.post("/{plugin_id}/instances", response_model=schemas.PluginInstanceResponse, status_code=status.HTTP_201_CREATED)
async def create_instance(
plugin_id: str,
data: schemas.PluginInstanceCreate,
user: User = Depends(PermissionRequired("plugin:write")),
db: AsyncSession = Depends(get_db),
):
return await plugin_service.create_instance(db, plugin_id, data)
@router.get("/{plugin_id}/instances", response_model=list[schemas.PluginInstanceResponse])
async def list_instances(
plugin_id: str,
user: User = Depends(PermissionRequired("plugin:read")),
db: AsyncSession = Depends(get_db),
):
return await plugin_service.list_instances(db, plugin_id=plugin_id)
@router.put("/instances/{instance_id}", response_model=schemas.PluginInstanceResponse)
async def update_instance(
instance_id: uuid.UUID,
data: schemas.PluginInstanceUpdate,
user: User = Depends(PermissionRequired("plugin:write")),
db: AsyncSession = Depends(get_db),
):
return await plugin_service.update_instance(db, instance_id, data)
@router.delete("/instances/{instance_id}")
async def delete_instance(
instance_id: uuid.UUID,
user: User = Depends(PermissionRequired("plugin:write")),
db: AsyncSession = Depends(get_db),
):
await plugin_service.delete_instance(db, instance_id)
return {"message": "Instance deleted"}
@router.post("/upload")
async def upload_plugin(
file: UploadFile = File(...),
user: User = Depends(PermissionRequired("plugin:install")),
db: AsyncSession = Depends(get_db),
):
plugin = await plugin_service.install_plugin_from_zip(db, file, settings.PLUGIN_DIR)
return {"plugin_id": plugin.id, "name": plugin.name, "version": plugin.version}
@router.get("/{plugin_id}/logs")
async def list_logs(
plugin_id: str,
limit: int = 100,
user: User = Depends(PermissionRequired("plugin:read")),
db: AsyncSession = Depends(get_db),
):
return await plugin_service.list_plugin_logs(db, plugin_id=plugin_id, limit=limit)
+61
View File
@@ -0,0 +1,61 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import role as schemas
from apps.api.src.security.dependencies import PermissionRequired
from apps.api.src.services import role_service
router = APIRouter()
@router.get("", response_model=list[schemas.RoleResponse])
async def list_roles(
user: User = Depends(PermissionRequired("role:read")),
db: AsyncSession = Depends(get_db),
):
return await role_service.list_roles(db)
@router.post("", response_model=schemas.RoleResponse, status_code=status.HTTP_201_CREATED)
async def create_role(
data: schemas.RoleCreate,
user: User = Depends(PermissionRequired("role:write")),
db: AsyncSession = Depends(get_db),
):
return await role_service.create_role(db, data)
@router.get("/{role_id}", response_model=schemas.RoleResponse)
async def get_role(
role_id: uuid.UUID,
user: User = Depends(PermissionRequired("role:read")),
db: AsyncSession = Depends(get_db),
):
role = await role_service.get_role_by_id(db, role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
return role
@router.put("/{role_id}", response_model=schemas.RoleResponse)
async def update_role(
role_id: uuid.UUID,
data: schemas.RoleUpdate,
user: User = Depends(PermissionRequired("role:write")),
db: AsyncSession = Depends(get_db),
):
return await role_service.update_role(db, role_id, data)
@router.delete("/{role_id}")
async def delete_role(
role_id: uuid.UUID,
user: User = Depends(PermissionRequired("role:write")),
db: AsyncSession = Depends(get_db),
):
await role_service.delete_role(db, role_id)
return {"message": "Role deleted"}
@@ -0,0 +1,70 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import service_connection as schemas
from apps.api.src.security.dependencies import PermissionRequired, get_current_user
from apps.api.src.services import service_connection_service
router = APIRouter()
@router.get("", response_model=list[schemas.ServiceConnectionResponse])
async def list_connections(
plugin_id: str | None = None,
user: User = Depends(PermissionRequired("connection:read")),
db: AsyncSession = Depends(get_db),
):
return await service_connection_service.list_connections(db, plugin_id=plugin_id)
@router.post("", response_model=schemas.ServiceConnectionResponse, status_code=status.HTTP_201_CREATED)
async def create_connection(
data: schemas.ServiceConnectionCreate,
user: User = Depends(PermissionRequired("connection:write")),
db: AsyncSession = Depends(get_db),
):
return await service_connection_service.create_connection(db, data, user.id)
@router.get("/{connection_id}", response_model=schemas.ServiceConnectionResponse)
async def get_connection(
connection_id: uuid.UUID,
user: User = Depends(PermissionRequired("connection:read")),
db: AsyncSession = Depends(get_db),
):
conn = await service_connection_service.get_connection(db, connection_id)
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
return conn
@router.put("/{connection_id}", response_model=schemas.ServiceConnectionResponse)
async def update_connection(
connection_id: uuid.UUID,
data: schemas.ServiceConnectionUpdate,
user: User = Depends(PermissionRequired("connection:write")),
db: AsyncSession = Depends(get_db),
):
return await service_connection_service.update_connection(db, connection_id, data)
@router.delete("/{connection_id}")
async def delete_connection(
connection_id: uuid.UUID,
user: User = Depends(PermissionRequired("connection:delete")),
db: AsyncSession = Depends(get_db),
):
await service_connection_service.delete_connection(db, connection_id)
return {"message": "Connection deleted"}
@router.post("/test")
async def test_connection(
data: schemas.ServiceConnectionTestRequest,
user: User = Depends(PermissionRequired("connection:test")),
):
return await service_connection_service.test_connection(data)
+40
View File
@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import system_setting as schemas
from apps.api.src.security.dependencies import PermissionRequired
from apps.api.src.services import system_setting_service
router = APIRouter()
@router.get("/{key}", response_model=schemas.SystemSettingResponse)
async def get_setting(
key: str,
user: User = Depends(PermissionRequired("system:read")),
db: AsyncSession = Depends(get_db),
):
setting = await system_setting_service.get_setting(db, key)
if not setting:
raise HTTPException(status_code=404, detail="Setting not found")
return setting
@router.put("/{key}", response_model=schemas.SystemSettingResponse)
async def update_setting(
key: str,
data: schemas.SystemSettingUpdate,
user: User = Depends(PermissionRequired("system:write")),
db: AsyncSession = Depends(get_db),
):
return await system_setting_service.set_setting(db, key, data)
@router.get("", response_model=list[schemas.SystemSettingResponse])
async def list_settings(
user: User = Depends(PermissionRequired("system:read")),
db: AsyncSession = Depends(get_db),
):
return await system_setting_service.list_settings(db)
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from apps.api.src.models.user import User
from apps.api.src.security.dependencies import PermissionRequired
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}
@router.get("/metrics")
async def metrics(user: User = Depends(PermissionRequired("system:read"))):
return {"metrics": "placeholder"}
@router.get("/stats")
async def stats(user: User = Depends(PermissionRequired("system:read"))):
return {"dashboards": 0, "plugins": 0, "users": 0}
+137
View File
@@ -0,0 +1,137 @@
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import user as schemas
from apps.api.src.security.audit import log_audit
from apps.api.src.security.dependencies import (
PermissionRequired,
get_current_user,
get_user_permissions,
)
from apps.api.src.services import user_service
router = APIRouter()
@router.get("/me", response_model=schemas.UserResponse)
async def me(user: User = Depends(get_current_user)):
return user
@router.get("/me/permissions")
async def my_permissions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await get_user_permissions(user)
@router.put("/me", response_model=schemas.UserResponse)
async def update_profile(
data: schemas.ProfileUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
await db.commit()
await db.refresh(user)
return user
@router.post("/me/change-password")
async def change_password(
data: schemas.PasswordChangeRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await user_service.change_password(db, user, data.current_password, data.new_password)
return {"message": "Password changed"}
@router.get("", response_model=list[schemas.UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
user: User = Depends(PermissionRequired("user:read")),
db: AsyncSession = Depends(get_db),
):
return await user_service.list_users(db, skip=skip, limit=limit)
@router.get("/{user_id}", response_model=schemas.UserResponse)
async def get_user(
user_id: uuid.UUID,
user: User = Depends(PermissionRequired("user:read")),
db: AsyncSession = Depends(get_db),
):
db_user = await user_service.get_user_by_id(db, user_id)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
return db_user
@router.post("", response_model=schemas.UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: schemas.UserCreate,
request: Request,
user: User = Depends(PermissionRequired("user:write")),
db: AsyncSession = Depends(get_db),
):
new_user = await user_service.create_user(db, data, user)
await log_audit(
db,
action="create_user",
resource_type="user",
user_id=user.id,
resource_id=str(new_user.id),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return new_user
@router.put("/{user_id}", response_model=schemas.UserResponse)
async def update_user(
user_id: uuid.UUID,
data: schemas.UserUpdate,
user: User = Depends(PermissionRequired("user:write")),
db: AsyncSession = Depends(get_db),
):
return await user_service.update_user(db, user_id, data, user)
@router.delete("/{user_id}")
async def delete_user(
user_id: uuid.UUID,
user: User = Depends(PermissionRequired("user:delete")),
db: AsyncSession = Depends(get_db),
):
await user_service.delete_user(db, user_id, user)
return {"message": "User deleted"}
@router.post("/invite")
async def invite_user(
data: schemas.UserInviteRequest,
user: User = Depends(PermissionRequired("user:invite")),
db: AsyncSession = Depends(get_db),
):
token = await user_service.create_invite(db, data.email, data.role_id)
# TODO: send email
return {"message": "Invitation sent", "token": token}
@router.post("/invite/accept")
async def accept_invite(
data: schemas.UserInviteAccept,
db: AsyncSession = Depends(get_db),
):
await user_service.accept_invite(db, data.token, data.password)
return {"message": "Invitation accepted"}
@@ -0,0 +1,37 @@
import uuid
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.plugins import loader
from apps.api.src.security.dependencies import get_current_user
router = APIRouter()
class WidgetDataRequest(BaseModel):
instance_id: uuid.UUID
widget_type: str
settings: dict[str, Any] = Field(default_factory=dict)
@router.post("/widget-data")
async def widget_data(
data: WidgetDataRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await loader.fetch_widget_data(db, data.instance_id, data.widget_type, data.settings)
@router.post("/widget-data/{instance_id}/healthcheck")
async def healthcheck(
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await loader.run_healthcheck(db, instance_id)
+42
View File
@@ -0,0 +1,42 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.user import User
from apps.api.src.schemas import dashboard as schemas
from apps.api.src.security.dependencies import get_current_user
from apps.api.src.services import dashboard_service
router = APIRouter()
@router.post("/{dashboard_id}", response_model=schemas.DashboardWidgetResponse, status_code=status.HTTP_201_CREATED)
async def create_widget(
dashboard_id: uuid.UUID,
data: schemas.DashboardWidgetCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.create_widget(db, dashboard_id, data, user)
@router.put("/{widget_id}", response_model=schemas.DashboardWidgetResponse)
async def update_widget(
widget_id: uuid.UUID,
data: schemas.DashboardWidgetUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await dashboard_service.update_widget(db, widget_id, data, user)
@router.delete("/{widget_id}")
async def delete_widget(
widget_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await dashboard_service.delete_widget(db, widget_id, user)
return {"message": "Widget deleted"}
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter
from apps.api.src.api.v1.endpoints import (
audit,
auth,
dashboards,
notifications,
plugins,
roles,
service_connections,
settings,
system,
users,
widget_data,
widgets,
)
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(roles.router, prefix="/roles", tags=["Roles"])
api_router.include_router(dashboards.router, prefix="/dashboards", tags=["Dashboards"])
api_router.include_router(widgets.router, prefix="/widgets", tags=["Widgets"])
api_router.include_router(plugins.router, prefix="/plugins", tags=["Plugins"])
api_router.include_router(service_connections.router, prefix="/connections", tags=["Connections"])
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
api_router.include_router(audit.router, prefix="/audit", tags=["Audit"])
api_router.include_router(settings.router, prefix="/settings", tags=["Settings"])
api_router.include_router(system.router, prefix="/system", tags=["System"])
api_router.include_router(widget_data.router, prefix="/widget-data", tags=["Widget Data"])
+58
View File
@@ -0,0 +1,58 @@
from pathlib import Path
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# General
NODE_ENV: str = "production"
NEXADASH_API_URL: str = "http://localhost:8000"
NEXADASH_WEB_URL: str = "http://localhost:3000"
NEXADASH_SECRET_KEY: str = "change-this-to-a-random-32-byte-secret-key!!!!"
NEXADASH_ENCRYPTION_KEY: str = "change-this-to-a-random-32-byte-fernet-key!!"
# Database
DATABASE_URL: str = "postgresql+asyncpg://nexadash:change-this-strong-password@localhost:5432/nexadash"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
# Security
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
ARGON2_TIME_COST: int = 2
ARGON2_MEMORY_COST: int = 65536
ARGON2_PARALLELISM: int = 1
MAX_LOGIN_ATTEMPTS: int = 5
LOGIN_LOCKOUT_MINUTES: int = 15
RATE_LIMIT_DEFAULT: int = 100
RATE_LIMIT_LOGIN: int = 10
# Mail
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_TLS: bool = True
SMTP_STARTTLS: bool = True
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "nexadash@localhost"
# Worker
WORKER_CONCURRENCY: int = 4
# Plugins
PLUGIN_DIR: Path = Path("/app/plugins")
PLUGIN_SANDBOX_ENABLED: bool = True
# Monitoring
SENTRY_DSN: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from apps.api.src.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.NODE_ENV == "development",
future=True,
)
AsyncSessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
+81
View File
@@ -0,0 +1,81 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from apps.api.src.api.v1.router import api_router
from apps.api.src.config import settings
from apps.api.src.database import engine
from apps.api.src.models.base import Base
from apps.api.src.security.rate_limit import limiter
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "0"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
response.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none';"
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
async with engine.begin() as conn:
# Ensure tables exist (for dev convenience; migrations preferred in production)
await conn.run_sync(Base.metadata.create_all)
from apps.api.src.seed import seed
await seed()
yield
# Shutdown
await engine.dispose()
def create_app() -> FastAPI:
app = FastAPI(
title="NexaDash API",
description="Plugin-based dashboard API",
version="0.1.0",
lifespan=lifespan,
docs_url="/api/docs" if settings.NODE_ENV == "development" else None,
redoc_url="/api/redoc" if settings.NODE_ENV == "development" else None,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.NEXADASH_WEB_URL],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["*"],
)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"])
app.include_router(api_router)
@app.get("/health")
async def health():
return {"status": "ok", "version": "0.1.0"}
return app
async def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=429,
content={"detail": "Too many requests"},
)
app = create_app()
+35
View File
@@ -0,0 +1,35 @@
from apps.api.src.models.api_token import APIToken
from apps.api.src.models.audit_log import AuditLog
from apps.api.src.models.background_job import BackgroundJob
from apps.api.src.models.dashboard import Dashboard
from apps.api.src.models.notification import Notification
from apps.api.src.models.plugin import Plugin
from apps.api.src.models.plugin_instance import PluginInstance
from apps.api.src.models.plugin_log import PluginLog
from apps.api.src.models.plugin_version import PluginVersion
from apps.api.src.models.role import Role
from apps.api.src.models.secret import Secret
from apps.api.src.models.service_connection import ServiceConnection
from apps.api.src.models.session import Session
from apps.api.src.models.system_setting import SystemSetting
from apps.api.src.models.user import User
from apps.api.src.models.widget import Widget
__all__ = [
"APIToken",
"AuditLog",
"BackgroundJob",
"Dashboard",
"Notification",
"Plugin",
"PluginInstance",
"PluginLog",
"PluginVersion",
"Role",
"Secret",
"ServiceConnection",
"Session",
"SystemSetting",
"User",
"Widget",
]
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, Boolean
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class ApiToken(Base):
__tablename__ = "api_tokens"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_ip: Mapped[str | None] = mapped_column(String(45))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
scopes: Mapped[str | None] = mapped_column(Text)
user: Mapped["User"] = relationship(back_populates="api_tokens")
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
resource_id: Mapped[str | None] = mapped_column(String(100))
ip_address: Mapped[str | None] = mapped_column(String(45))
user_agent: Mapped[str | None] = mapped_column(Text)
details: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
severity: Mapped[str] = mapped_column(String(20), default="info", nullable=False)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user: Mapped["User"] = relationship(back_populates="audit_logs")
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class BackgroundJob(Base):
__tablename__ = "background_jobs"
task_name: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
result: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
error: Mapped[str | None] = mapped_column(Text)
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
worker_id: Mapped[str | None] = mapped_column(String(100))
plugin_id: Mapped[str | None] = mapped_column(String(100), index=True)
instance_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("plugin_instances.id", ondelete="SET NULL"), index=True
)
instance: Mapped["PluginInstance"] = relationship()
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
__abstract__ = True
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
unique=True,
index=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+35
View File
@@ -0,0 +1,35 @@
import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Dashboard(Base):
__tablename__ = "dashboards"
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
icon: Mapped[str | None] = mapped_column(String(50))
folder: Mapped[str] = mapped_column(String(100), default="default", nullable=False, index=True)
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_public: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
layout: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
layouts_by_breakpoint: Mapped[dict] = mapped_column(
JSONB,
default=lambda: {"lg": [], "md": [], "sm": [], "xs": [], "xxs": []},
nullable=False,
)
refresh_interval_seconds: Mapped[int | None] = mapped_column(Integer, default=60)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
tags: Mapped[list[str]] = mapped_column(JSONB, default=list)
owner: Mapped["User"] = relationship(back_populates="dashboards")
widgets: Mapped[list["Widget"]] = relationship(
back_populates="dashboard", cascade="all, delete-orphan", order_by="Widget.order_index"
)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Notification(Base):
__tablename__ = "notifications"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
message: Mapped[str | None] = mapped_column(Text)
type: Mapped[str] = mapped_column(String(50), nullable=False) # info, success, warning, error
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
link: Mapped[str | None] = mapped_column(String(500))
metadata: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped["User"] = relationship()
+42
View File
@@ -0,0 +1,42 @@
from sqlalchemy import Boolean, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Plugin(Base):
__tablename__ = "plugins"
id: Mapped[str] = mapped_column(String(100), primary_key=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
version: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
author: Mapped[str | None] = mapped_column(String(200))
category: Mapped[str] = mapped_column(String(100), default="Generic", nullable=False)
icon: Mapped[str | None] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_installed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
path: Mapped[str] = mapped_column(String(500), nullable=False)
permissions: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
settings_schema: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
credentials_schema: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
widget_types: Mapped[list[dict]] = mapped_column(JSONB, default=list, nullable=False)
healthcheck_definition: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
api_routes: Mapped[list[str]] = mapped_column(JSONB, default=list)
has_frontend: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
manifest: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
instances: Mapped[list["PluginInstance"]] = relationship(
back_populates="plugin", cascade="all, delete-orphan"
)
service_connections: Mapped[list["ServiceConnection"]] = relationship(
back_populates="plugin", cascade="all, delete-orphan"
)
versions: Mapped[list["PluginVersion"]] = relationship(
back_populates="plugin", cascade="all, delete-orphan"
)
logs: Mapped[list["PluginLog"]] = relationship(
back_populates="plugin", cascade="all, delete-orphan"
)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class PluginInstance(Base):
__tablename__ = "plugin_instances"
plugin_id: Mapped[str] = mapped_column(
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
)
service_connection_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("service_connections.id", ondelete="SET NULL"), index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
settings: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
health_status: Mapped[str] = mapped_column(String(20), default="unknown", nullable=False)
health_message: Mapped[str | None] = mapped_column(Text)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # type: ignore[name-defined]
last_error: Mapped[str | None] = mapped_column(Text)
error_count: Mapped[int] = mapped_column(default=0, nullable=False)
plugin: Mapped["Plugin"] = relationship(back_populates="instances")
service_connection: Mapped["ServiceConnection"] = relationship(back_populates="instances")
widgets: Mapped[list["Widget"]] = relationship(back_populates="instance")
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class PluginLog(Base):
__tablename__ = "plugin_logs"
plugin_id: Mapped[str] = mapped_column(
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
)
instance_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("plugin_instances.id", ondelete="SET NULL"), index=True
)
level: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
context: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
plugin: Mapped["Plugin"] = relationship(back_populates="logs")
instance: Mapped["PluginInstance"] = relationship()
+22
View File
@@ -0,0 +1,22 @@
import uuid
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class PluginVersion(Base):
__tablename__ = "plugin_versions"
plugin_id: Mapped[str] = mapped_column(
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
)
version: Mapped[str] = mapped_column(String(50), nullable=False)
changelog: Mapped[str | None] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
package_path: Mapped[str | None] = mapped_column(String(500))
manifest: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
plugin: Mapped["Plugin"] = relationship(back_populates="versions")
+19
View File
@@ -0,0 +1,19 @@
import uuid
from sqlalchemy import String, Text, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Role(Base):
__tablename__ = "roles"
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Permission list: e.g. ["user:read", "dashboard:write", "plugin:admin"]
permissions: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
users: Mapped[list["User"]] = relationship(back_populates="role")
+22
View File
@@ -0,0 +1,22 @@
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Secret(Base):
__tablename__ = "encrypted_secrets"
name: Mapped[str] = mapped_column(String(200), nullable=False)
owner_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
encrypted_value: Mapped[str] = mapped_column(Text, nullable=False)
secret_type: Mapped[str] = mapped_column(String(50), nullable=False) # api_token, password, etc.
scope: Mapped[str] = mapped_column(String(100), nullable=False) # plugin, system, user
metadata: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
service_connections: Mapped[list["ServiceConnection"]] = relationship(back_populates="credentials")
+30
View File
@@ -0,0 +1,30 @@
import uuid
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class ServiceConnection(Base):
__tablename__ = "service_connections"
name: Mapped[str] = mapped_column(String(200), nullable=False)
plugin_id: Mapped[str] = mapped_column(
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
)
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
timeout_seconds: Mapped[int] = mapped_column(default=30, nullable=False)
credentials_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("encrypted_secrets.id", ondelete="SET NULL"), index=True
)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
health_status: Mapped[str] = mapped_column(String(20), default="unknown", nullable=False)
health_message: Mapped[str | None] = mapped_column(Text)
extra_headers: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
plugin: Mapped["Plugin"] = relationship(back_populates="service_connections")
credentials: Mapped["Secret"] = relationship(back_populates="service_connections")
instances: Mapped[list["PluginInstance"]] = relationship(back_populates="service_connection")
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, Boolean
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Session(Base):
__tablename__ = "sessions"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
refresh_token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
user_agent: Mapped[str | None] = mapped_column(Text)
ip_address: Mapped[str | None] = mapped_column(String(45))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
is_valid: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship(back_populates="sessions")
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from apps.api.src.models.base import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_sensitive: Mapped[bool] = mapped_column(default=False, nullable=False)
+43
View File
@@ -0,0 +1,43 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class User(Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(100))
last_name: Mapped[str | None] = mapped_column(String(100))
avatar_url: Mapped[str | None] = mapped_column(String(500))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
locale: Mapped[str] = mapped_column(String(10), default="en", nullable=False)
theme: Mapped[str] = mapped_column(String(20), default="system", nullable=False)
timezone: Mapped[str] = mapped_column(String(50), default="UTC", nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_login_ip: Mapped[str | None] = mapped_column(String(45))
login_attempts: Mapped[int] = mapped_column(default=0, nullable=False)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
invite_token: Mapped[str | None] = mapped_column(String(255), index=True)
invite_token_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
password_reset_token: Mapped[str | None] = mapped_column(String(255), index=True)
password_reset_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
preferences: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
role_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("roles.id", ondelete="SET NULL"), index=True
)
role: Mapped["Role"] = relationship(back_populates="users")
sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan")
dashboards: Mapped[list["Dashboard"]] = relationship(back_populates="owner")
api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
+37
View File
@@ -0,0 +1,37 @@
import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from apps.api.src.models.base import Base
class Widget(Base):
__tablename__ = "dashboard_widgets"
dashboard_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("dashboards.id", ondelete="CASCADE"), index=True, nullable=False
)
plugin_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
widget_type: Mapped[str] = mapped_column(String(100), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
position_x: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
position_y: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
width: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
height: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
min_width: Mapped[int | None] = mapped_column(Integer)
min_height: Mapped[int | None] = mapped_column(Integer)
max_width: Mapped[int | None] = mapped_column(Integer)
max_height: Mapped[int | None] = mapped_column(Integer)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
settings: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
instance_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("plugin_instances.id", ondelete="SET NULL"), index=True
)
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_static: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
dashboard: Mapped["Dashboard"] = relationship(back_populates="widgets")
instance: Mapped["PluginInstance"] = relationship(back_populates="widgets")
View File
+68
View File
@@ -0,0 +1,68 @@
import uuid
from abc import ABC, abstractmethod
from typing import Any
import httpx
from apps.api.src.models.plugin_instance import PluginInstance
from apps.api.src.models.service_connection import ServiceConnection
from apps.api.src.security.encryption import decrypt_dict
from apps.api.src.services import secret_service
class PluginContext:
def __init__(
self,
instance: PluginInstance,
connection: ServiceConnection | None,
credentials: dict[str, Any] | None,
http_client: httpx.AsyncClient,
):
self.instance = instance
self.connection = connection
self.credentials = credentials or {}
self.http_client = http_client
class PluginConnector(ABC):
id: str = ""
name: str = ""
default_timeout: float = 30.0
def __init__(self, context: PluginContext):
self.context = context
async def get_credentials(self) -> dict[str, Any]:
return self.context.credentials
def get_client(self) -> httpx.AsyncClient:
return self.context.http_client
@abstractmethod
async def healthcheck(self) -> dict[str, Any]:
pass
@abstractmethod
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
pass
async def close(self) -> None:
pass
async def build_context(
db,
instance: PluginInstance,
) -> PluginContext:
connection = instance.service_connection
credentials = None
if connection and connection.credentials_id:
secret_value = await secret_service.decrypt_secret_value(db, connection.credentials_id)
credentials = decrypt_dict(secret_value)
client = httpx.AsyncClient(
verify=connection.verify_tls if connection else True,
timeout=connection.timeout_seconds if connection else 30,
follow_redirects=False,
)
return PluginContext(instance, connection, credentials, client)
@@ -0,0 +1,21 @@
from typing import Any
import httpx
async def safe_json(response: httpx.Response) -> dict[str, Any]:
try:
return response.json()
except Exception:
return {"status": response.status_code, "text": response.text[:500]}
def build_auth_headers(credentials: dict[str, Any]) -> dict[str, str]:
headers = {}
if credentials.get("api_token"):
headers["Authorization"] = f"PVEAPIToken={credentials['api_token']}"
elif credentials.get("token"):
headers["Authorization"] = f"Bearer {credentials['token']}"
elif credentials.get("api_key"):
headers["Authorization"] = credentials["api_key"]
return headers
@@ -0,0 +1,57 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class AdGuardHomeConnector(PluginConnector):
id = "adguard-home"
name = "AdGuard Home"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/control{path}"
def _headers(self) -> dict[str, str]:
headers = {}
creds = self.context.credentials
if creds.get("username") and creds.get("password"):
from base64 import b64encode
token = b64encode(f"{creds['username']}:{creds['password']}".encode()).decode()
headers["Authorization"] = f"Basic {token}"
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
ok = resp.status_code == 200
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "dns-overview":
return await self._get_stats()
if widget_type == "block-rate-chart":
return await self._get_stats()
if widget_type == "top-clients":
return await self._get_top_clients()
if widget_type == "top-blocked-domains":
return await self._get_top_blocked()
return {"error": "Unsupported widget type"}
async def _get_stats(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/stats"), headers=self._headers())
return await safe_json(resp)
async def _get_top_clients(self) -> dict[str, Any]:
resp = await self.get_client().get(
self._url("/stats/top_clients?limit=10"), headers=self._headers()
)
return await safe_json(resp)
async def _get_top_blocked(self) -> dict[str, Any]:
resp = await self.get_client().get(
self._url("/stats/top_blocked_domains?limit=10"), headers=self._headers()
)
return await safe_json(resp)
@@ -0,0 +1,51 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class AuthentikConnector(PluginConnector):
id = "authentik"
name = "authentik"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/api/v3{path}"
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
creds = self.context.credentials
if creds.get("api_token"):
headers["Authorization"] = f"Bearer {creds['api_token']}"
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/core/users/"), headers=self._headers())
ok = resp.status_code == 200
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "auth-overview":
return await self._get_users()
if widget_type == "recent-logins":
return await self._get_events()
if widget_type == "app-status":
return await self._get_applications()
return {"error": "Unsupported widget type"}
async def _get_users(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/core/users/"), headers=self._headers())
return await safe_json(resp)
async def _get_events(self) -> dict[str, Any]:
resp = await self.get_client().get(
self._url("/events/events/?ordering=-created&page_size=20"), headers=self._headers()
)
return await safe_json(resp)
async def _get_applications(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/core/applications/"), headers=self._headers())
return await safe_json(resp)
+49
View File
@@ -0,0 +1,49 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class BeszelConnector(PluginConnector):
id = "beszel"
name = "Beszel"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/api{path}"
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
creds = self.context.credentials
if creds.get("api_token"):
headers["Authorization"] = f"Bearer {creds['api_token']}"
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/systems"), headers=self._headers())
ok = resp.status_code == 200
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "system-overview":
return await self._get_systems()
if widget_type == "resource-chart":
return await self._get_system_stats(settings.get("system_id"))
if widget_type == "host-cards":
return await self._get_systems()
return {"error": "Unsupported widget type"}
async def _get_systems(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/systems"), headers=self._headers())
return await safe_json(resp)
async def _get_system_stats(self, system_id: str | None) -> dict[str, Any]:
if not system_id:
return {"error": "system_id required"}
resp = await self.get_client().get(
self._url(f"/systems/{system_id}/stats"), headers=self._headers()
)
return await safe_json(resp)
@@ -0,0 +1,72 @@
import json
import re
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class GenericHTTPConnector(PluginConnector):
id = "generic-http"
name = "Generic HTTP"
async def healthcheck(self) -> dict[str, Any]:
return await self._check(self.context.instance.settings)
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
return await self._check(settings)
async def _check(self, settings: dict[str, Any]) -> dict[str, Any]:
url = settings.get("url")
if not url:
return {"error": "url required"}
method = settings.get("method", "GET").upper()
headers = settings.get("headers", {})
body = settings.get("body")
expected_status = settings.get("expected_status", 200)
json_path = settings.get("json_path")
regex = settings.get("regex")
try:
if method == "GET":
resp = await self.get_client().get(url, headers=headers)
elif method == "POST":
resp = await self.get_client().post(url, headers=headers, json=body)
else:
return {"error": "Unsupported method"}
result: dict[str, Any] = {
"status": "ok" if resp.status_code == expected_status else "error",
"status_code": resp.status_code,
"latency_ms": int(resp.elapsed.total_seconds() * 1000),
}
if resp.status_code == expected_status:
text = resp.text
if json_path:
try:
data = resp.json()
result["extracted"] = self._extract_json_path(data, json_path)
except Exception:
result["extracted"] = None
if regex:
match = re.search(regex, text)
result["regex_match"] = bool(match)
if match:
result["regex_groups"] = match.groups()
if not json_path and not regex:
result["body_preview"] = text[:500]
return result
except Exception as e:
return {"status": "error", "message": str(e)}
def _extract_json_path(self, data: Any, path: str) -> Any:
parts = path.replace("[", ".").replace("]", "").split(".")
for part in parts:
if isinstance(data, dict):
data = data.get(part)
elif isinstance(data, list) and part.isdigit():
data = data[int(part)] if int(part) < len(data) else None
else:
return None
return data
@@ -0,0 +1,65 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class HomeAssistantConnector(PluginConnector):
id = "home-assistant"
name = "Home Assistant"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/api{path}"
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
creds = self.context.credentials
if creds.get("long_lived_token"):
headers["Authorization"] = f"Bearer {creds['long_lived_token']}"
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/"), headers=self._headers())
ok = resp.status_code == 200
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "entity-card":
return await self._get_entity(settings.get("entity_id"))
if widget_type == "sensor-chart":
return await self._get_history(settings.get("entity_id"))
if widget_type == "home-status":
return await self._get_states()
if widget_type == "automation-status":
return await self._get_automations()
return {"error": "Unsupported widget type"}
async def _get_entity(self, entity_id: str | None) -> dict[str, Any]:
if not entity_id:
return {"error": "entity_id required"}
resp = await self.get_client().get(self._url(f"/states/{entity_id}"), headers=self._headers())
return await safe_json(resp)
async def _get_history(self, entity_id: str | None) -> dict[str, Any]:
if not entity_id:
return {"error": "entity_id required"}
resp = await self.get_client().get(
self._url(f"/history/period?filter_entity_id={entity_id}&minimal_response"),
headers=self._headers(),
)
return await safe_json(resp)
async def _get_states(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/states"), headers=self._headers())
return await safe_json(resp)
async def _get_automations(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/states"), headers=self._headers())
data = await safe_json(resp)
if isinstance(data, dict) and "data" in data:
data["data"] = [s for s in data["data"] if s.get("entity_id", "").startswith("automation.")]
return data
@@ -0,0 +1,51 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class JellyfinConnector(PluginConnector):
id = "jellyfin"
name = "Jellyfin"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}{path}"
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
creds = self.context.credentials
if creds.get("api_key"):
headers["X-Emby-Token"] = creds["api_key"]
elif creds.get("token"):
headers["Authorization"] = f"MediaBrowser Token={creds['token']}"
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/System/Info/Public"), headers=self._headers())
ok = resp.status_code == 200
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "jellyfin-overview":
return await self._get_system_info()
if widget_type == "active-streams":
return await self._get_sessions()
if widget_type == "library-card":
return await self._get_libraries()
return {"error": "Unsupported widget type"}
async def _get_system_info(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/System/Info"), headers=self._headers())
return await safe_json(resp)
async def _get_sessions(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/Sessions"), headers=self._headers())
return await safe_json(resp)
async def _get_libraries(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/Library/VirtualFolders"), headers=self._headers())
return await safe_json(resp)
@@ -0,0 +1,55 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import build_auth_headers, safe_json
class ProxmoxBackupServerConnector(PluginConnector):
id = "proxmox-backup-server"
name = "Proxmox Backup Server"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/api2/json{path}"
def _headers(self) -> dict[str, str]:
headers = build_auth_headers(self.context.credentials)
headers.setdefault("Accept", "application/json")
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
ok = resp.status_code == 200
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "pbs-overview":
return await self._get_status()
if widget_type == "backup-job-status":
return await self._get_jobs()
if widget_type == "datastore-usage":
return await self._get_datastores()
if widget_type == "failed-backups":
return await self._get_failed_jobs()
return {"error": "Unsupported widget type"}
async def _get_status(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
return await safe_json(resp)
async def _get_jobs(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/admin/datastore"), headers=self._headers())
return await safe_json(resp)
async def _get_datastores(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/admin/datastore"), headers=self._headers())
return await safe_json(resp)
async def _get_failed_jobs(self) -> dict[str, Any]:
resp = await self.get_client().get(
self._url("/admin/datastore?failed-only=true"), headers=self._headers()
)
return await safe_json(resp)
@@ -0,0 +1,65 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import build_auth_headers, safe_json
class ProxmoxVEConnector(PluginConnector):
id = "proxmox-ve"
name = "Proxmox VE"
def _client(self) -> Any:
return self.get_client()
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/api2/json{path}"
def _headers(self) -> dict[str, str]:
headers = build_auth_headers(self.context.credentials)
headers.setdefault("Accept", "application/json")
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self._client().get(self._url("/cluster/status"), headers=self._headers())
data = await safe_json(resp)
ok = resp.status_code == 200 and "data" in data
return {"status": "ok" if ok else "error", "status_code": resp.status_code, "data": data}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "cluster-overview":
return await self._get_cluster_status()
if widget_type == "node-card":
return await self._get_node(settings.get("node"))
if widget_type == "vm-lxc-list":
return await self._get_vms_and_lxc()
if widget_type == "storage-usage":
return await self._get_storage()
if widget_type == "resource-graph":
return await self._get_resources()
return {"error": "Unsupported widget type"}
async def _get_cluster_status(self) -> dict[str, Any]:
resp = await self._client().get(self._url("/cluster/status"), headers=self._headers())
return await safe_json(resp)
async def _get_node(self, node: str | None) -> dict[str, Any]:
if not node:
return {"error": "node required"}
resp = await self._client().get(self._url(f"/nodes/{node}/status"), headers=self._headers())
return await safe_json(resp)
async def _get_vms_and_lxc(self) -> dict[str, Any]:
resp = await self._client().get(self._url("/cluster/resources?type=vm"), headers=self._headers())
return await safe_json(resp)
async def _get_storage(self) -> dict[str, Any]:
resp = await self._client().get(self._url("/cluster/resources?type=storage"), headers=self._headers())
return await safe_json(resp)
async def _get_resources(self) -> dict[str, Any]:
resp = await self._client().get(self._url("/cluster/resources"), headers=self._headers())
return await safe_json(resp)
+49
View File
@@ -0,0 +1,49 @@
from typing import Any
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors._shared import safe_json
class ZoraxyConnector(PluginConnector):
id = "zoraxy"
name = "Zoraxy"
def _url(self, path: str) -> str:
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
return f"{base}/api{path}"
def _headers(self) -> dict[str, str]:
headers = {}
creds = self.context.credentials
if creds.get("api_key"):
headers["Authorization"] = f"Bearer {creds['api_key']}"
return headers
async def healthcheck(self) -> dict[str, Any]:
try:
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
ok = resp.status_code in {200, 204}
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
except Exception as e:
return {"status": "error", "message": str(e)}
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
if widget_type == "proxy-overview":
return await self._get_proxy_hosts()
if widget_type == "tls-expiry":
return await self._get_tls_status()
if widget_type == "backend-health":
return await self._get_backend_health()
return {"error": "Unsupported widget type"}
async def _get_proxy_hosts(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/hosts"), headers=self._headers())
return await safe_json(resp)
async def _get_tls_status(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/tls"), headers=self._headers())
return await safe_json(resp)
async def _get_backend_health(self) -> dict[str, Any]:
resp = await self.get_client().get(self._url("/health"), headers=self._headers())
return await safe_json(resp)
+113
View File
@@ -0,0 +1,113 @@
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.plugin import Plugin
from apps.api.src.plugins import registry
from apps.api.src.plugins.base import build_context
from apps.api.src.services import plugin_service
async def load_builtin_plugins(db: AsyncSession) -> None:
"""Register built-in connectors in the database."""
for plugin_id, connector_class in registry.list_connectors().items():
existing = await plugin_service.get_plugin_by_id(db, plugin_id)
if existing:
existing.is_builtin = True
existing.is_installed = True
existing.name = connector_class.name
await db.commit()
continue
manifest = {
"id": plugin_id,
"name": connector_class.name,
"version": "1.0.0",
"nexadashPluginApi": "1.0",
"description": f"Built-in connector for {connector_class.name}",
"author": "NexaDash",
"category": "Infrastructure",
"permissions": ["network:outbound", "secrets:read"],
"settingsSchema": {},
"credentialsSchema": {},
"widgets": [],
"hasFrontend": True,
}
plugin = Plugin(
id=plugin_id,
name=connector_class.name,
version="1.0.0",
description=f"Built-in connector for {connector_class.name}",
author="NexaDash",
category="Infrastructure",
is_builtin=True,
is_installed=True,
is_active=True,
path="",
permissions=["network:outbound", "secrets:read"],
settings_schema={},
credentials_schema={},
widget_types=[],
healthcheck_definition={},
api_routes=[],
has_frontend=True,
manifest=manifest,
)
db.add(plugin)
await db.commit()
async def fetch_widget_data(
db: AsyncSession,
instance_id: uuid.UUID,
widget_type: str,
settings: dict[str, Any],
) -> dict[str, Any]:
instance = await plugin_service.get_instance(db, instance_id)
if not instance:
return {"error": "Instance not found"}
connector_class = registry.get_connector(instance.plugin_id)
if not connector_class:
return {"error": f"Connector not found for plugin {instance.plugin_id}"}
ctx = await build_context(db, instance)
connector = connector_class(ctx)
try:
data = await connector.fetch_widget_data(widget_type, settings)
return {"status": "ok", "data": data}
except Exception as e:
await plugin_service.add_plugin_log(
db, instance.plugin_id, "error", str(e), instance_id=instance.id
)
return {"status": "error", "message": str(e)}
finally:
await connector.close()
async def run_healthcheck(
db: AsyncSession,
instance_id: uuid.UUID,
) -> dict[str, Any]:
instance = await plugin_service.get_instance(db, instance_id)
if not instance:
return {"error": "Instance not found"}
connector_class = registry.get_connector(instance.plugin_id)
if not connector_class:
return {"error": f"Connector not found for plugin {instance.plugin_id}"}
ctx = await build_context(db, instance)
connector = connector_class(ctx)
try:
result = await connector.healthcheck()
instance.health_status = result.get("status", "unknown")
instance.health_message = result.get("message")
await db.commit()
return result
except Exception as e:
instance.health_status = "error"
instance.health_message = str(e)
await db.commit()
return {"status": "error", "message": str(e)}
finally:
await connector.close()
+38
View File
@@ -0,0 +1,38 @@
from typing import Any, Type
from apps.api.src.plugins.base import PluginConnector
from apps.api.src.plugins.connectors import (
adguard_home,
authentik,
beszel,
generic_http,
home_assistant,
jellyfin,
proxmox_backup_server,
proxmox_ve,
zoraxy,
)
_CONNECTORS: dict[str, Type[PluginConnector]] = {
"proxmox-ve": proxmox_ve.ProxmoxVEConnector,
"proxmox-backup-server": proxmox_backup_server.ProxmoxBackupServerConnector,
"adguard-home": adguard_home.AdGuardHomeConnector,
"zoraxy": zoraxy.ZoraxyConnector,
"jellyfin": jellyfin.JellyfinConnector,
"home-assistant": home_assistant.HomeAssistantConnector,
"authentik": authentik.AuthentikConnector,
"beszel": beszel.BeszelConnector,
"generic-http": generic_http.GenericHTTPConnector,
}
def get_connector(plugin_id: str) -> Type[PluginConnector] | None:
return _CONNECTORS.get(plugin_id)
def register_connector(plugin_id: str, connector: Type[PluginConnector]) -> None:
_CONNECTORS[plugin_id] = connector
def list_connectors() -> dict[str, Type[PluginConnector]]:
return _CONNECTORS.copy()
View File
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class AuditLogResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID | None
action: str
resource_type: str
resource_id: str | None
ip_address: str | None
user_agent: str | None
details: dict[str, Any]
severity: str
timestamp: datetime
created_at: datetime
class Config:
from_attributes = True
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_at: datetime
class RefreshRequest(BaseModel):
refresh_token: str
class SetupRequest(BaseModel):
email: EmailStr
password: str
first_name: str | None = None
last_name: str | None = None
locale: str = "en"
class SetupStatusResponse(BaseModel):
setup_required: bool
+22
View File
@@ -0,0 +1,22 @@
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
pages: int
class MessageResponse(BaseModel):
message: str
class ErrorResponse(BaseModel):
detail: str
errors: dict[str, Any] | None = None
+105
View File
@@ -0,0 +1,105 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class DashboardWidgetBase(BaseModel):
plugin_id: str
widget_type: str
title: str
description: str | None = None
position_x: int = 0
position_y: int = 0
width: int = 2
height: int = 2
min_width: int | None = None
min_height: int | None = None
max_width: int | None = None
max_height: int | None = None
order_index: int = 0
settings: dict = Field(default_factory=dict)
instance_id: uuid.UUID | None = None
is_visible: bool = True
is_static: bool = False
class DashboardWidgetCreate(DashboardWidgetBase):
pass
class DashboardWidgetUpdate(BaseModel):
title: str | None = None
description: str | None = None
position_x: int | None = None
position_y: int | None = None
width: int | None = None
height: int | None = None
min_width: int | None = None
min_height: int | None = None
max_width: int | None = None
max_height: int | None = None
order_index: int | None = None
settings: dict | None = None
instance_id: uuid.UUID | None = None
is_visible: bool | None = None
is_static: bool | None = None
class DashboardWidgetResponse(DashboardWidgetBase):
id: uuid.UUID
dashboard_id: uuid.UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DashboardBase(BaseModel):
title: str
description: str | None = None
icon: str | None = None
folder: str = "default"
is_favorite: bool = False
is_public: bool = False
refresh_interval_seconds: int | None = 60
order_index: int = 0
tags: list[str] = Field(default_factory=list)
class DashboardCreate(DashboardBase):
layout: dict = Field(default_factory=dict)
layouts_by_breakpoint: dict = Field(default_factory=lambda: {"lg": [], "md": [], "sm": [], "xs": [], "xxs": []})
class DashboardUpdate(BaseModel):
title: str | None = None
description: str | None = None
icon: str | None = None
folder: str | None = None
is_favorite: bool | None = None
is_public: bool | None = None
refresh_interval_seconds: int | None = None
order_index: int | None = None
tags: list[str] | None = None
layout: dict | None = None
layouts_by_breakpoint: dict | None = None
class DashboardResponse(DashboardBase):
id: uuid.UUID
owner_id: uuid.UUID
layout: dict
layouts_by_breakpoint: dict
widgets: list[DashboardWidgetResponse]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DashboardLayoutUpdate(BaseModel):
layout: dict
layouts_by_breakpoint: dict
+32
View File
@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class NotificationBase(BaseModel):
title: str
message: str | None = None
type: str = "info"
link: str | None = None
metadata: dict[str, Any] = {}
class NotificationCreate(NotificationBase):
pass
class NotificationUpdate(BaseModel):
is_read: bool
class NotificationResponse(NotificationBase):
id: uuid.UUID
user_id: uuid.UUID
is_read: bool
read_at: datetime | None
created_at: datetime
class Config:
from_attributes = True
+117
View File
@@ -0,0 +1,117 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class PluginManifest(BaseModel):
id: str
name: str
version: str
nexadashPluginApi: str
description: str | None = None
author: str | None = None
category: str = "Generic"
permissions: list[str] = Field(default_factory=list)
settingsSchema: dict = Field(default_factory=dict)
credentialsSchema: dict = Field(default_factory=dict)
widgets: list[dict] = Field(default_factory=list)
class PluginBase(BaseModel):
id: str
name: str
version: str
description: str | None = None
author: str | None = None
category: str = "Generic"
icon: str | None = None
is_active: bool = False
is_builtin: bool = False
is_installed: bool = False
permissions: list[str] = Field(default_factory=list)
settings_schema: dict = Field(default_factory=dict)
credentials_schema: dict = Field(default_factory=dict)
widget_types: list[dict] = Field(default_factory=list)
healthcheck_definition: dict = Field(default_factory=dict)
api_routes: list[str] = Field(default_factory=list)
has_frontend: bool = True
class PluginResponse(PluginBase):
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PluginCreate(BaseModel):
id: str
name: str
version: str
description: str | None = None
author: str | None = None
category: str = "Generic"
icon: str | None = None
is_builtin: bool = False
permissions: list[str] = Field(default_factory=list)
settings_schema: dict = Field(default_factory=dict)
credentials_schema: dict = Field(default_factory=dict)
widget_types: list[dict] = Field(default_factory=list)
healthcheck_definition: dict = Field(default_factory=dict)
api_routes: list[str] = Field(default_factory=list)
has_frontend: bool = True
manifest: dict = Field(default_factory=dict)
path: str
class PluginUpdate(BaseModel):
is_active: bool | None = None
is_installed: bool | None = None
class PluginInstanceBase(BaseModel):
plugin_id: str
service_connection_id: uuid.UUID | None = None
name: str
settings: dict = Field(default_factory=dict)
is_enabled: bool = True
class PluginInstanceCreate(PluginInstanceBase):
pass
class PluginInstanceUpdate(BaseModel):
name: str | None = None
service_connection_id: uuid.UUID | None = None
settings: dict | None = None
is_enabled: bool | None = None
class PluginInstanceResponse(PluginInstanceBase):
id: uuid.UUID
health_status: str
health_message: str | None = None
last_sync_at: datetime | None
last_error: str | None
error_count: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PluginLogResponse(BaseModel):
id: uuid.UUID
plugin_id: str
instance_id: uuid.UUID | None
level: str
message: str
context: dict
timestamp: datetime
class Config:
from_attributes = True
+27
View File
@@ -0,0 +1,27 @@
import uuid
from pydantic import BaseModel
class RoleBase(BaseModel):
name: str
description: str | None = None
permissions: list[str]
class RoleCreate(RoleBase):
pass
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
permissions: list[str] | None = None
class RoleResponse(RoleBase):
id: uuid.UUID
is_system: bool
class Config:
from_attributes = True
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class SecretBase(BaseModel):
name: str
secret_type: str
scope: str
metadata: dict[str, Any] = {}
class SecretCreate(SecretBase):
value: str
class SecretUpdate(BaseModel):
name: str | None = None
value: str | None = None
metadata: dict[str, Any] | None = None
class SecretResponse(SecretBase):
id: uuid.UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@@ -0,0 +1,49 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class ServiceConnectionBase(BaseModel):
name: str
plugin_id: str
base_url: str
verify_tls: bool = True
timeout_seconds: int = 30
extra_headers: dict = Field(default_factory=dict)
is_enabled: bool = True
class ServiceConnectionCreate(ServiceConnectionBase):
credentials: dict = Field(default_factory=dict)
class ServiceConnectionUpdate(BaseModel):
name: str | None = None
base_url: str | None = None
verify_tls: bool | None = None
timeout_seconds: int | None = None
extra_headers: dict | None = None
is_enabled: bool | None = None
credentials: dict | None = None
class ServiceConnectionResponse(ServiceConnectionBase):
id: uuid.UUID
credentials_id: uuid.UUID | None
health_status: str
health_message: str | None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ServiceConnectionTestRequest(BaseModel):
plugin_id: str
base_url: str
verify_tls: bool = True
timeout_seconds: int = 30
credentials: dict = Field(default_factory=dict)
extra_headers: dict = Field(default_factory=dict)
+19
View File
@@ -0,0 +1,19 @@
from typing import Any
from pydantic import BaseModel
class SystemSettingBase(BaseModel):
key: str
value: dict[str, Any]
description: str | None = None
is_sensitive: bool = False
class SystemSettingUpdate(BaseModel):
value: dict[str, Any]
class SystemSettingResponse(SystemSettingBase):
class Config:
from_attributes = True
+79
View File
@@ -0,0 +1,79 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, EmailStr, Field
class UserBase(BaseModel):
email: EmailStr
first_name: str | None = None
last_name: str | None = None
locale: str = "en"
theme: str = "system"
timezone: str = "UTC"
class UserCreate(UserBase):
password: str = Field(..., min_length=12, max_length=128)
role_id: uuid.UUID | None = None
class UserUpdate(BaseModel):
email: EmailStr | None = None
first_name: str | None = None
last_name: str | None = None
locale: str | None = None
theme: str | None = None
timezone: str | None = None
role_id: uuid.UUID | None = None
is_active: bool | None = None
class UserResponse(UserBase):
id: uuid.UUID
is_active: bool
is_superuser: bool
is_owner: bool
email_verified: bool
last_login_at: datetime | None
avatar_url: str | None
role: "RoleResponse" | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserInviteRequest(BaseModel):
email: EmailStr
role_id: uuid.UUID
class UserInviteAccept(BaseModel):
token: str
password: str = Field(..., min_length=12, max_length=128)
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=12, max_length=128)
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(..., min_length=12, max_length=128)
class ProfileUpdate(BaseModel):
first_name: str | None = None
last_name: str | None = None
locale: str | None = None
theme: str | None = None
timezone: str | None = None
preferences: dict[str, Any] | None = None
View File
+33
View File
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.audit_log import AuditLog
async def log_audit(
db: AsyncSession,
action: str,
resource_type: str,
user_id: uuid.UUID | None = None,
resource_id: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
details: dict[str, Any] | None = None,
severity: str = "info",
) -> None:
entry = AuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
details=details or {},
severity=severity,
timestamp=datetime.now(timezone.utc),
)
db.add(entry)
await db.commit()
+94
View File
@@ -0,0 +1,94 @@
import uuid
from typing import Any
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.database import get_db
from apps.api.src.models.role import Role
from apps.api.src.models.user import User
from apps.api.src.security import tokens
from apps.api.src.security.permissions import has_permission
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
http_bearer = HTTPBearer(auto_error=False)
async def get_current_user_from_token(
token: str | None,
db: AsyncSession,
) -> User | None:
if not token:
return None
payload = tokens.decode_token_safe(token)
if not payload or payload.get("type") != "access":
return None
try:
user_id = uuid.UUID(payload["sub"])
except (KeyError, ValueError):
return None
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return None
return user
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
credentials: HTTPAuthorizationCredentials | None = Security(http_bearer),
) -> User:
token = None
if credentials:
token = credentials.credentials
if not token:
token = request.cookies.get("access_token")
if not token:
token = await oauth2_scheme(request)
user = await get_current_user_from_token(token, db)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return user
async def get_optional_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User | None:
token = request.cookies.get("access_token")
if not token:
auth = request.headers.get("authorization")
if auth and auth.startswith("Bearer "):
token = auth[7:]
return await get_current_user_from_token(token, db)
class PermissionRequired:
def __init__(self, permission: str) -> None:
self.permission = permission
async def __call__(self, user: User = Depends(get_current_user)) -> User:
perms = await get_user_permissions(user)
if not has_permission(perms, self.permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {self.permission}",
)
return user
async def get_user_permissions(user: User) -> list[str]:
if user.is_owner or user.is_superuser:
return ["*"]
if user.role and user.role.permissions:
return user.role.permissions
return []
async def load_user_role(user: User, db: AsyncSession) -> None:
if user.role_id and not hasattr(user, "role"):
result = await db.execute(select(Role).where(Role.id == user.role_id))
user.role = result.scalar_one_or_none()
+45
View File
@@ -0,0 +1,45 @@
import base64
import hashlib
import json
import secrets
from cryptography.fernet import Fernet
from apps.api.src.config import settings
def _get_fernet() -> Fernet:
key = base64.urlsafe_b64encode(
hashlib.sha256(settings.NEXADASH_ENCRYPTION_KEY.encode()).digest()[:32]
)
return Fernet(key)
def encrypt_secret(plaintext: str) -> str:
return _get_fernet().encrypt(plaintext.encode()).decode()
def decrypt_secret(ciphertext: str) -> str:
return _get_fernet().decrypt(ciphertext.encode()).decode()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def generate_random_token(length: int = 32) -> str:
return secrets.token_urlsafe(length)
def mask_secret(value: str, visible: int = 4) -> str:
if len(value) <= visible:
return "*" * len(value)
return "*" * (len(value) - visible) + value[-visible:]
def encrypt_dict(data: dict) -> str:
return encrypt_secret(json.dumps(data))
def decrypt_dict(ciphertext: str) -> dict:
return json.loads(decrypt_secret(ciphertext))
+27
View File
@@ -0,0 +1,27 @@
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from apps.api.src.config import settings
ph = PasswordHasher(
time_cost=settings.ARGON2_TIME_COST,
memory_cost=settings.ARGON2_MEMORY_COST,
parallelism=settings.ARGON2_PARALLELISM,
hash_len=32,
salt_len=16,
)
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
return ph.verify(hashed, password)
except VerifyMismatchError:
return False
def check_password_needs_rehash(hashed: str) -> bool:
return ph.check_needs_rehash(hashed)
+99
View File
@@ -0,0 +1,99 @@
from enum import Enum
class Permission(str, Enum):
# User management
USER_READ = "user:read"
USER_WRITE = "user:write"
USER_DELETE = "user:delete"
USER_INVITE = "user:invite"
# Roles
ROLE_READ = "role:read"
ROLE_WRITE = "role:write"
# Dashboards
DASHBOARD_READ = "dashboard:read"
DASHBOARD_WRITE = "dashboard:write"
DASHBOARD_DELETE = "dashboard:delete"
DASHBOARD_SHARE = "dashboard:share"
# Plugins
PLUGIN_READ = "plugin:read"
PLUGIN_WRITE = "plugin:write"
PLUGIN_DELETE = "plugin:delete"
PLUGIN_ADMIN = "plugin:admin"
PLUGIN_INSTALL = "plugin:install"
# Service connections
CONNECTION_READ = "connection:read"
CONNECTION_WRITE = "connection:write"
CONNECTION_DELETE = "connection:delete"
CONNECTION_TEST = "connection:test"
# System
SYSTEM_READ = "system:read"
SYSTEM_WRITE = "system:write"
AUDIT_READ = "audit:read"
BACKUP_RESTORE = "backup:restore"
# API tokens
API_TOKEN_READ = "api_token:read"
API_TOKEN_WRITE = "api_token:write"
ROLE_PERMISSIONS = {
"owner": [p.value for p in Permission],
"admin": [
Permission.USER_READ.value,
Permission.USER_WRITE.value,
Permission.USER_INVITE.value,
Permission.ROLE_READ.value,
Permission.DASHBOARD_READ.value,
Permission.DASHBOARD_WRITE.value,
Permission.DASHBOARD_DELETE.value,
Permission.DASHBOARD_SHARE.value,
Permission.PLUGIN_READ.value,
Permission.PLUGIN_WRITE.value,
Permission.PLUGIN_INSTALL.value,
Permission.CONNECTION_READ.value,
Permission.CONNECTION_WRITE.value,
Permission.CONNECTION_DELETE.value,
Permission.CONNECTION_TEST.value,
Permission.SYSTEM_READ.value,
Permission.SYSTEM_WRITE.value,
Permission.AUDIT_READ.value,
Permission.BACKUP_RESTORE.value,
Permission.API_TOKEN_READ.value,
Permission.API_TOKEN_WRITE.value,
],
"editor": [
Permission.USER_READ.value,
Permission.DASHBOARD_READ.value,
Permission.DASHBOARD_WRITE.value,
Permission.DASHBOARD_SHARE.value,
Permission.PLUGIN_READ.value,
Permission.CONNECTION_READ.value,
Permission.CONNECTION_TEST.value,
Permission.API_TOKEN_READ.value,
Permission.API_TOKEN_WRITE.value,
],
"viewer": [
Permission.USER_READ.value,
Permission.DASHBOARD_READ.value,
Permission.PLUGIN_READ.value,
Permission.CONNECTION_READ.value,
],
}
def has_permission(user_permissions: list[str], required: str) -> bool:
return required in user_permissions or "*" in user_permissions
def has_any_permission(user_permissions: list[str], required: list[str]) -> bool:
return any(has_permission(user_permissions, p) for p in required)
def has_all_permissions(user_permissions: list[str], required: list[str]) -> bool:
return all(has_permission(user_permissions, p) for p in required)
+9
View File
@@ -0,0 +1,9 @@
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from apps.api.src.config import settings
limiter = Limiter(
key_func=get_remote_address,
default_limits=[f"{settings.RATE_LIMIT_DEFAULT} per minute"],
)
+32
View File
@@ -0,0 +1,32 @@
from urllib.parse import urlparse
BLOCKED_SCHEMES = {"file", "gopher", "ftp", "dict", "ldap", "tftp"}
BLOCKED_HOSTS = {
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"169.254.169.254", # AWS metadata
}
def is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme not in {"http", "https"}:
return False
hostname = parsed.hostname
if not hostname:
return False
if hostname.lower() in BLOCKED_HOSTS:
return False
# Block internal IP ranges
parts = hostname.split(".")
if len(parts) == 4 and all(p.isdigit() for p in parts):
first = int(parts[0])
if first in {10, 127} or (first == 192 and parts[1] == "168"):
return False
return True
+46
View File
@@ -0,0 +1,46 @@
import uuid
from datetime import datetime, timedelta, timezone
import jwt
from jwt import PyJWTError
from apps.api.src.config import settings
ALGORITHM = "HS256"
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": str(subject),
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "access",
}
return jwt.encode(payload, settings.NEXADASH_SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(subject: str) -> tuple[str, datetime]:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(subject),
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "refresh",
"jti": str(uuid.uuid4()),
}
return jwt.encode(payload, settings.NEXADASH_SECRET_KEY, algorithm=ALGORITHM), expire
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.NEXADASH_SECRET_KEY, algorithms=[ALGORITHM])
def decode_token_safe(token: str) -> dict | None:
try:
return decode_token(token)
except PyJWTError:
return None
+46
View File
@@ -0,0 +1,46 @@
from apps.api.src.database import AsyncSessionLocal
from apps.api.src.plugins.loader import load_builtin_plugins
from apps.api.src.services.role_service import seed_roles
from apps.api.src.services.system_setting_service import set_setting
async def seed() -> None:
async with AsyncSessionLocal() as db:
try:
await seed_roles(db)
await load_builtin_plugins(db)
# Seed default system settings
await set_setting(
db,
"registration",
{"enabled": False, "invite_only": True},
)
await set_setting(
db,
"mail",
{
"host": "",
"port": 587,
"tls": True,
"starttls": True,
"user": "",
"from": "nexadash@localhost",
},
)
await set_setting(
db,
"security",
{
"max_login_attempts": 5,
"lockout_minutes": 15,
"require_strong_passwords": True,
},
)
await set_setting(
db,
"backup",
{"enabled": False, "schedule": "0 2 * * *", "retention_days": 30},
)
except Exception as e:
await db.rollback()
raise e
View File
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.audit_log import AuditLog
async def list_audit_logs(db: AsyncSession, limit: int = 100) -> list[AuditLog]:
result = await db.execute(
select(AuditLog).order_by(AuditLog.timestamp.desc()).limit(limit)
)
return list(result.scalars().all())
+174
View File
@@ -0,0 +1,174 @@
import uuid
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from apps.api.src.models.dashboard import Dashboard
from apps.api.src.models.widget import Widget
from apps.api.src.schemas import dashboard as schemas
from apps.api.src.models.user import User
async def create_dashboard(db: AsyncSession, data: schemas.DashboardCreate, owner: User) -> Dashboard:
dashboard = Dashboard(
**data.model_dump(),
owner_id=owner.id,
)
db.add(dashboard)
await db.commit()
await db.refresh(dashboard)
return dashboard
async def get_dashboard(db: AsyncSession, dashboard_id: uuid.UUID) -> Dashboard | None:
result = await db.execute(
select(Dashboard)
.where(Dashboard.id == dashboard_id)
.options(selectinload(Dashboard.widgets))
)
return result.scalar_one_or_none()
async def list_dashboards(db: AsyncSession, owner: User) -> list[Dashboard]:
result = await db.execute(
select(Dashboard)
.where(Dashboard.owner_id == owner.id)
.order_by(Dashboard.order_index, Dashboard.created_at)
)
return list(result.scalars().all())
async def update_dashboard(
db: AsyncSession,
dashboard_id: uuid.UUID,
data: schemas.DashboardUpdate,
owner: User,
) -> Dashboard:
dashboard = await get_dashboard(db, dashboard_id)
if not dashboard or dashboard.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Dashboard not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(dashboard, key, value)
await db.commit()
await db.refresh(dashboard)
return dashboard
async def delete_dashboard(db: AsyncSession, dashboard_id: uuid.UUID, owner: User) -> None:
dashboard = await get_dashboard(db, dashboard_id)
if not dashboard or dashboard.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Dashboard not found")
await db.delete(dashboard)
await db.commit()
async def duplicate_dashboard(db: AsyncSession, dashboard_id: uuid.UUID, owner: User) -> Dashboard:
dashboard = await get_dashboard(db, dashboard_id)
if not dashboard or dashboard.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Dashboard not found")
new_dashboard = Dashboard(
title=f"{dashboard.title} (Copy)",
description=dashboard.description,
icon=dashboard.icon,
folder=dashboard.folder,
owner_id=owner.id,
layout=dashboard.layout,
layouts_by_breakpoint=dashboard.layouts_by_breakpoint,
refresh_interval_seconds=dashboard.refresh_interval_seconds,
tags=dashboard.tags,
)
db.add(new_dashboard)
await db.flush()
await db.refresh(new_dashboard)
for widget in dashboard.widgets:
new_widget = Widget(
dashboard_id=new_dashboard.id,
plugin_id=widget.plugin_id,
widget_type=widget.widget_type,
title=widget.title,
description=widget.description,
position_x=widget.position_x,
position_y=widget.position_y,
width=widget.width,
height=widget.height,
min_width=widget.min_width,
min_height=widget.min_height,
max_width=widget.max_width,
max_height=widget.max_height,
order_index=widget.order_index,
settings=widget.settings,
instance_id=widget.instance_id,
is_visible=widget.is_visible,
is_static=widget.is_static,
)
db.add(new_widget)
await db.commit()
await db.refresh(new_dashboard)
return new_dashboard
async def create_widget(
db: AsyncSession,
dashboard_id: uuid.UUID,
data: schemas.DashboardWidgetCreate,
owner: User,
) -> Widget:
dashboard = await get_dashboard(db, dashboard_id)
if not dashboard or dashboard.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Dashboard not found")
widget = Widget(dashboard_id=dashboard_id, **data.model_dump())
db.add(widget)
await db.commit()
await db.refresh(widget)
return widget
async def update_widget(
db: AsyncSession,
widget_id: uuid.UUID,
data: schemas.DashboardWidgetUpdate,
owner: User,
) -> Widget:
result = await db.execute(
select(Widget).where(Widget.id == widget_id).join(Dashboard).where(Dashboard.owner_id == owner.id)
)
widget = result.scalar_one_or_none()
if not widget:
raise HTTPException(status_code=404, detail="Widget not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(widget, key, value)
await db.commit()
await db.refresh(widget)
return widget
async def delete_widget(db: AsyncSession, widget_id: uuid.UUID, owner: User) -> None:
result = await db.execute(
select(Widget).where(Widget.id == widget_id).join(Dashboard).where(Dashboard.owner_id == owner.id)
)
widget = result.scalar_one_or_none()
if not widget:
raise HTTPException(status_code=404, detail="Widget not found")
await db.delete(widget)
await db.commit()
async def update_layout(
db: AsyncSession,
dashboard_id: uuid.UUID,
data: schemas.DashboardLayoutUpdate,
owner: User,
) -> Dashboard:
dashboard = await get_dashboard(db, dashboard_id)
if not dashboard or dashboard.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Dashboard not found")
dashboard.layout = data.layout
dashboard.layouts_by_breakpoint = data.layouts_by_breakpoint
await db.commit()
await db.refresh(dashboard)
return dashboard
@@ -0,0 +1,54 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.notification import Notification
from apps.api.src.models.user import User
async def create_notification(
db: AsyncSession,
user: User,
title: str,
message: str | None,
type: str = "info",
link: str | None = None,
metadata: dict | None = None,
) -> Notification:
notif = Notification(
user_id=user.id,
title=title,
message=message,
type=type,
link=link,
metadata=metadata or {},
)
db.add(notif)
await db.commit()
await db.refresh(notif)
return notif
async def list_notifications(
db: AsyncSession,
user: User,
unread_only: bool = False,
limit: int = 50,
) -> list[Notification]:
stmt = select(Notification).where(Notification.user_id == user.id)
if unread_only:
stmt = stmt.where(Notification.is_read == False)
stmt = stmt.order_by(Notification.created_at.desc()).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def mark_as_read(db: AsyncSession, user: User, notification_id: uuid.UUID | None = None) -> None:
stmt = update(Notification).where(Notification.user_id == user.id)
if notification_id:
stmt = stmt.where(Notification.id == notification_id)
stmt = stmt.values(is_read=True, read_at=datetime.now(timezone.utc)) # type: ignore
await db.execute(stmt)
await db.commit()
+245
View File
@@ -0,0 +1,245 @@
import json
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from apps.api.src.models.plugin import Plugin
from apps.api.src.models.plugin_instance import PluginInstance
from apps.api.src.models.plugin_log import PluginLog
from apps.api.src.models.plugin_version import PluginVersion
from apps.api.src.schemas import plugin as schemas
from apps.api.src.security.permissions import Permission
class PluginValidationError(Exception):
pass
async def get_plugin_by_id(db: AsyncSession, plugin_id: str) -> Plugin | None:
result = await db.execute(
select(Plugin).where(Plugin.id == plugin_id).options(selectinload(Plugin.instances))
)
return result.scalar_one_or_none()
async def list_plugins(db: AsyncSession, active_only: bool = False) -> list[Plugin]:
stmt = select(Plugin)
if active_only:
stmt = stmt.where(Plugin.is_active == True)
result = await db.execute(stmt)
return list(result.scalars().all())
async def create_plugin(db: AsyncSession, data: schemas.PluginCreate) -> Plugin:
existing = await get_plugin_by_id(db, data.id)
if existing:
raise HTTPException(status_code=400, detail="Plugin already exists")
plugin = Plugin(**data.model_dump())
db.add(plugin)
await db.commit()
await db.refresh(plugin)
return plugin
async def update_plugin(
db: AsyncSession,
plugin_id: str,
data: schemas.PluginUpdate,
) -> Plugin:
plugin = await get_plugin_by_id(db, plugin_id)
if not plugin:
raise HTTPException(status_code=404, detail="Plugin not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(plugin, key, value)
await db.commit()
await db.refresh(plugin)
return plugin
async def delete_plugin(db: AsyncSession, plugin_id: str) -> None:
plugin = await get_plugin_by_id(db, plugin_id)
if not plugin:
raise HTTPException(status_code=404, detail="Plugin not found")
if plugin.is_builtin:
raise HTTPException(status_code=400, detail="Cannot delete built-in plugin")
await db.delete(plugin)
await db.commit()
async def create_instance(
db: AsyncSession,
plugin_id: str,
data: schemas.PluginInstanceCreate,
) -> PluginInstance:
plugin = await get_plugin_by_id(db, plugin_id)
if not plugin:
raise HTTPException(status_code=404, detail="Plugin not found")
instance = PluginInstance(plugin_id=plugin_id, **data.model_dump())
db.add(instance)
await db.commit()
await db.refresh(instance)
return instance
async def get_instance(db: AsyncSession, instance_id: uuid.UUID) -> PluginInstance | None:
result = await db.execute(
select(PluginInstance).where(PluginInstance.id == instance_id)
)
return result.scalar_one_or_none()
async def update_instance(
db: AsyncSession,
instance_id: uuid.UUID,
data: schemas.PluginInstanceUpdate,
) -> PluginInstance:
instance = await get_instance(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="Plugin instance not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(instance, key, value)
await db.commit()
await db.refresh(instance)
return instance
async def delete_instance(db: AsyncSession, instance_id: uuid.UUID) -> None:
instance = await get_instance(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="Plugin instance not found")
await db.delete(instance)
await db.commit()
async def list_instances(db: AsyncSession, plugin_id: str | None = None) -> list[PluginInstance]:
stmt = select(PluginInstance)
if plugin_id:
stmt = stmt.where(PluginInstance.plugin_id == plugin_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def add_plugin_log(
db: AsyncSession,
plugin_id: str,
level: str,
message: str,
instance_id: uuid.UUID | None = None,
context: dict | None = None,
) -> PluginLog:
log = PluginLog(
plugin_id=plugin_id,
instance_id=instance_id,
level=level,
message=message,
context=context or {},
timestamp=datetime.now(timezone.utc),
)
db.add(log)
await db.commit()
await db.refresh(log)
return log
async def list_plugin_logs(
db: AsyncSession,
plugin_id: str | None = None,
limit: int = 100,
) -> list[PluginLog]:
stmt = select(PluginLog)
if plugin_id:
stmt = stmt.where(PluginLog.plugin_id == plugin_id)
stmt = stmt.order_by(PluginLog.timestamp.desc()).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
def validate_manifest(manifest: dict) -> None:
required = ["id", "name", "version", "nexadashPluginApi"]
for key in required:
if not manifest.get(key):
raise PluginValidationError(f"Missing required manifest field: {key}")
if not isinstance(manifest.get("widgets", []), list):
raise PluginValidationError("widgets must be a list")
async def install_plugin_from_zip(db: AsyncSession, file: UploadFile, plugin_dir: Path) -> Plugin:
import tempfile
temp_path = Path(tempfile.gettempdir()) / f"nexadash_plugin_{file.filename}"
with temp_path.open("wb") as f:
content = await file.read()
f.write(content)
try:
with zipfile.ZipFile(temp_path, "r") as z:
names = z.namelist()
manifest_names = [n for n in names if n.endswith("plugin.manifest.json")]
if not manifest_names:
raise PluginValidationError("plugin.manifest.json not found in archive")
manifest_name = manifest_names[0]
with z.open(manifest_name) as mf:
manifest = json.load(mf)
validate_manifest(manifest)
plugin_id = manifest["id"]
target_dir = plugin_dir / plugin_id
target_dir.mkdir(parents=True, exist_ok=True)
z.extractall(target_dir)
except (json.JSONDecodeError, zipfile.BadZipFile) as e:
raise PluginValidationError(f"Invalid plugin package: {e}")
finally:
temp_path.unlink(missing_ok=True)
existing = await get_plugin_by_id(db, plugin_id)
if existing:
existing.is_installed = True
existing.version = manifest["version"]
existing.manifest = manifest
existing.settings_schema = manifest.get("settingsSchema", {})
existing.credentials_schema = manifest.get("credentialsSchema", {})
existing.widget_types = manifest.get("widgets", [])
existing.permissions = manifest.get("permissions", [])
await db.commit()
await db.refresh(existing)
return existing
plugin = Plugin(
id=plugin_id,
name=manifest["name"],
version=manifest["version"],
description=manifest.get("description"),
author=manifest.get("author"),
category=manifest.get("category", "Generic"),
is_installed=True,
is_active=False,
path=str(target_dir),
manifest=manifest,
settings_schema=manifest.get("settingsSchema", {}),
credentials_schema=manifest.get("credentialsSchema", {}),
widget_types=manifest.get("widgets", []),
permissions=manifest.get("permissions", []),
healthcheck_definition=manifest.get("healthcheck", {}),
api_routes=manifest.get("apiRoutes", []),
has_frontend=manifest.get("hasFrontend", True),
)
db.add(plugin)
await db.commit()
await db.refresh(plugin)
# Record version
version = PluginVersion(
plugin_id=plugin_id,
version=manifest["version"],
is_active=True,
manifest=manifest,
)
db.add(version)
await db.commit()
return plugin
+69
View File
@@ -0,0 +1,69 @@
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.role import Role
from apps.api.src.schemas import role as schemas
async def seed_roles(db: AsyncSession) -> None:
from apps.api.src.security.permissions import ROLE_PERMISSIONS
for name, permissions in ROLE_PERMISSIONS.items():
result = await db.execute(select(Role).where(Role.name == name))
if not result.scalar_one_or_none():
db.add(
Role(
name=name,
is_system=True,
permissions=permissions,
description=f"System role: {name}",
)
)
await db.commit()
async def create_role(db: AsyncSession, data: schemas.RoleCreate) -> Role:
existing = await db.execute(select(Role).where(Role.name == data.name))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Role already exists")
role = Role(**data.model_dump())
db.add(role)
await db.commit()
await db.refresh(role)
return role
async def get_role_by_id(db: AsyncSession, role_id: uuid.UUID) -> Role | None:
result = await db.execute(select(Role).where(Role.id == role_id))
return result.scalar_one_or_none()
async def list_roles(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[Role]:
result = await db.execute(select(Role).offset(skip).limit(limit))
return list(result.scalars().all())
async def update_role(db: AsyncSession, role_id: uuid.UUID, data: schemas.RoleUpdate) -> Role:
role = await get_role_by_id(db, role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
if role.is_system:
raise HTTPException(status_code=400, detail="Cannot modify system role")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(role, key, value)
await db.commit()
await db.refresh(role)
return role
async def delete_role(db: AsyncSession, role_id: uuid.UUID) -> None:
role = await get_role_by_id(db, role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
if role.is_system:
raise HTTPException(status_code=400, detail="Cannot delete system role")
await db.delete(role)
await db.commit()
+65
View File
@@ -0,0 +1,65 @@
import uuid
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.secret import Secret
from apps.api.src.models.user import User
from apps.api.src.schemas import secret as schemas
from apps.api.src.security.encryption import decrypt_secret, encrypt_secret
async def create_secret(db: AsyncSession, data: schemas.SecretCreate, owner: User) -> Secret:
secret = Secret(
name=data.name,
owner_id=owner.id,
encrypted_value=encrypt_secret(data.value),
secret_type=data.secret_type,
scope=data.scope,
metadata=data.metadata,
)
db.add(secret)
await db.commit()
await db.refresh(secret)
return secret
async def get_secret_by_id(db: AsyncSession, secret_id: uuid.UUID) -> Secret | None:
result = await db.execute(select(Secret).where(Secret.id == secret_id))
return result.scalar_one_or_none()
async def list_secrets(db: AsyncSession, owner: User) -> list[Secret]:
result = await db.execute(select(Secret).where(Secret.owner_id == owner.id))
return list(result.scalars().all())
async def update_secret(db: AsyncSession, secret_id: uuid.UUID, data: schemas.SecretUpdate, owner: User) -> Secret:
secret = await get_secret_by_id(db, secret_id)
if not secret or secret.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Secret not found")
if data.name is not None:
secret.name = data.name
if data.value is not None:
secret.encrypted_value = encrypt_secret(data.value)
if data.metadata is not None:
secret.metadata = data.metadata
await db.commit()
await db.refresh(secret)
return secret
async def delete_secret(db: AsyncSession, secret_id: uuid.UUID, owner: User) -> None:
secret = await get_secret_by_id(db, secret_id)
if not secret or secret.owner_id != owner.id:
raise HTTPException(status_code=404, detail="Secret not found")
await db.delete(secret)
await db.commit()
async def decrypt_secret_value(db: AsyncSession, secret_id: uuid.UUID) -> str:
secret = await get_secret_by_id(db, secret_id)
if not secret:
raise HTTPException(status_code=404, detail="Secret not found")
return decrypt_secret(secret.encrypted_value)
@@ -0,0 +1,134 @@
import uuid
import httpx
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.secret import Secret
from apps.api.src.models.service_connection import ServiceConnection
from apps.api.src.schemas import service_connection as schemas
from apps.api.src.security import encryption
from apps.api.src.security.ssrf import is_safe_url
async def create_connection(
db: AsyncSession,
data: schemas.ServiceConnectionCreate,
owner_id: uuid.UUID,
) -> ServiceConnection:
if not is_safe_url(data.base_url):
raise HTTPException(status_code=400, detail="URL is not allowed")
secret_id = None
if data.credentials:
secret = Secret(
name=f"{data.name} credentials",
owner_id=owner_id,
encrypted_value=encryption.encrypt_dict(data.credentials),
secret_type="plugin_credentials",
scope=f"plugin:{data.plugin_id}",
)
db.add(secret)
await db.flush()
secret_id = secret.id
conn = ServiceConnection(
name=data.name,
plugin_id=data.plugin_id,
base_url=data.base_url,
verify_tls=data.verify_tls,
timeout_seconds=data.timeout_seconds,
credentials_id=secret_id,
is_enabled=data.is_enabled,
extra_headers=data.extra_headers,
)
db.add(conn)
await db.commit()
await db.refresh(conn)
return conn
async def get_connection(db: AsyncSession, connection_id: uuid.UUID) -> ServiceConnection | None:
result = await db.execute(select(ServiceConnection).where(ServiceConnection.id == connection_id))
return result.scalar_one_or_none()
async def list_connections(db: AsyncSession, plugin_id: str | None = None) -> list[ServiceConnection]:
stmt = select(ServiceConnection)
if plugin_id:
stmt = stmt.where(ServiceConnection.plugin_id == plugin_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def update_connection(
db: AsyncSession,
connection_id: uuid.UUID,
data: schemas.ServiceConnectionUpdate,
) -> ServiceConnection:
conn = await get_connection(db, connection_id)
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
if data.base_url is not None and not is_safe_url(data.base_url):
raise HTTPException(status_code=400, detail="URL is not allowed")
update_data = data.model_dump(exclude_unset=True)
credentials = update_data.pop("credentials", None)
for key, value in update_data.items():
setattr(conn, key, value)
if credentials is not None:
if conn.credentials_id:
secret = await db.get(Secret, conn.credentials_id)
if secret:
secret.encrypted_value = encryption.encrypt_dict(credentials)
else:
secret = Secret(
name=f"{conn.name} credentials",
encrypted_value=encryption.encrypt_dict(credentials),
secret_type="plugin_credentials",
scope=f"plugin:{conn.plugin_id}",
)
db.add(secret)
await db.flush()
conn.credentials_id = secret.id
await db.commit()
await db.refresh(conn)
return conn
async def delete_connection(db: AsyncSession, connection_id: uuid.UUID) -> None:
conn = await get_connection(db, connection_id)
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
if conn.credentials_id:
secret = await db.get(Secret, conn.credentials_id)
if secret:
await db.delete(secret)
await db.delete(conn)
await db.commit()
async def test_connection(data: schemas.ServiceConnectionTestRequest) -> dict:
if not is_safe_url(data.base_url):
raise HTTPException(status_code=400, detail="URL is not allowed")
try:
async with httpx.AsyncClient(
verify=data.verify_tls,
timeout=float(data.timeout_seconds),
follow_redirects=False,
) as client:
response = await client.get(
data.base_url,
headers=data.extra_headers,
)
return {
"success": 200 <= response.status_code < 400,
"status_code": response.status_code,
"message": "Connection test completed",
}
except httpx.HTTPError as e:
return {"success": False, "status_code": None, "message": str(e)}
@@ -0,0 +1,35 @@
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.system_setting import SystemSetting
from apps.api.src.schemas import system_setting as schemas
async def get_setting(db: AsyncSession, key: str) -> SystemSetting | None:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
return result.scalar_one_or_none()
async def get_setting_value(db: AsyncSession, key: str, default: dict | None = None) -> dict:
setting = await get_setting(db, key)
if setting:
return setting.value
return default or {}
async def set_setting(db: AsyncSession, key: str, data: schemas.SystemSettingUpdate) -> SystemSetting:
setting = await get_setting(db, key)
if setting:
setting.value = data.value
else:
setting = SystemSetting(key=key, value=data.value, description="", is_sensitive=False)
db.add(setting)
await db.commit()
await db.refresh(setting)
return setting
async def list_settings(db: AsyncSession) -> list[SystemSetting]:
result = await db.execute(select(SystemSetting))
return list(result.scalars().all())
+204
View File
@@ -0,0 +1,204 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from apps.api.src.models.role import Role
from apps.api.src.models.user import User
from apps.api.src.schemas import user as schemas
from apps.api.src.security import encryption, password
from apps.api.src.security.permissions import ROLE_PERMISSIONS
async def create_owner(db: AsyncSession, data: schemas.SetupRequest) -> User:
existing = await db.execute(select(User))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Setup already completed",
)
# Create owner role if not exists
role_result = await db.execute(select(Role).where(Role.name == "owner"))
owner_role = role_result.scalar_one_or_none()
if not owner_role:
owner_role = Role(
name="owner",
description="System owner with full access",
is_system=True,
permissions=ROLE_PERMISSIONS["owner"],
)
db.add(owner_role)
await db.flush()
user = User(
email=data.email,
hashed_password=password.hash_password(data.password),
first_name=data.first_name,
last_name=data.last_name,
locale=data.locale,
is_active=True,
is_superuser=True,
is_owner=True,
email_verified=True,
role_id=owner_role.id,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def setup_required(db: AsyncSession) -> bool:
result = await db.execute(select(User))
return result.scalar_one_or_none() is None
async def create_user(db: AsyncSession, data: schemas.UserCreate, creator: User) -> User:
existing = await db.execute(select(User).where(User.email == data.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=data.email,
hashed_password=password.hash_password(data.password),
first_name=data.first_name,
last_name=data.last_name,
locale=data.locale,
is_active=True,
role_id=data.role_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).offset(skip).limit(limit))
return list(result.scalars().all())
async def update_user(
db: AsyncSession,
user_id: uuid.UUID,
data: schemas.UserUpdate,
current_user: User,
) -> User:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.is_owner and not current_user.is_owner:
raise HTTPException(status_code=403, detail="Cannot modify owner")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
await db.commit()
await db.refresh(user)
return user
async def delete_user(db: AsyncSession, user_id: uuid.UUID, current_user: User) -> None:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.is_owner:
raise HTTPException(status_code=403, detail="Cannot delete owner")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
await db.delete(user)
await db.commit()
async def change_password(db: AsyncSession, user: User, current_password: str, new_password: str) -> None:
if not password.verify_password(current_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
user.hashed_password = password.hash_password(new_password)
await db.commit()
async def create_invite(db: AsyncSession, email: str, role_id: uuid.UUID) -> str:
user = await get_user_by_email(db, email)
if user:
raise HTTPException(status_code=400, detail="User already exists")
token = encryption.generate_random_token(32)
expires = datetime.now(timezone.utc) + timedelta(hours=48) # type: ignore
user = User(
email=email,
hashed_password="",
role_id=role_id,
invite_token=encryption.hash_token(token),
invite_token_expires=expires,
is_active=False,
)
db.add(user)
await db.commit()
return token
async def accept_invite(db: AsyncSession, token: str, new_password: str) -> User:
hashed = encryption.hash_token(token)
result = await db.execute(select(User).where(User.invite_token == hashed))
user = result.scalar_one_or_none()
if not user or not user.invite_token_expires or user.invite_token_expires < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired invite token")
user.hashed_password = password.hash_password(new_password)
user.is_active = True
user.invite_token = None
user.invite_token_expires = None
await db.commit()
await db.refresh(user)
return user
async def create_password_reset(db: AsyncSession, email: str) -> str:
user = await get_user_by_email(db, email)
if not user:
return ""
token = encryption.generate_random_token(32)
user.password_reset_token = encryption.hash_token(token)
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(hours=24) # type: ignore
await db.commit()
return token
async def reset_password(db: AsyncSession, token: str, new_password: str) -> None:
hashed = encryption.hash_token(token)
result = await db.execute(select(User).where(User.password_reset_token == hashed))
user = result.scalar_one_or_none()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
user.hashed_password = password.hash_password(new_password)
user.password_reset_token = None
user.password_reset_expires = None
await db.commit()
async def update_last_login(db: AsyncSession, user: User, ip: str | None) -> None:
user.last_login_at = datetime.now(timezone.utc)
user.last_login_ip = ip
user.login_attempts = 0
user.locked_until = None
await db.commit()
async def record_failed_login(db: AsyncSession, user: User) -> None:
user.login_attempts += 1
if user.login_attempts >= 5:
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=15) # type: ignore
await db.commit()
View File
+39
View File
@@ -0,0 +1,39 @@
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from apps.api.src.database import Base, get_db
from apps.api.src.main import app
TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost:5432/nexadash_test"
engine = create_async_engine(TEST_DATABASE_URL, echo=False, future=True)
TestingSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest_asyncio.fixture(scope="session", autouse=True)
async def prepare_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db():
async with TestingSessionLocal() as session:
yield session
await session.rollback()
@pytest.fixture
def client(db):
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
+32
View File
@@ -0,0 +1,32 @@
import pytest
@pytest.fixture
def setup_response(client):
return client.post("/api/v1/auth/setup", json={
"email": "owner@nexadash.local",
"password": "StrongPassword123!",
"first_name": "Owner",
"last_name": "User",
})
def test_setup_status(client):
r = client.get("/api/v1/auth/setup-status")
assert r.status_code == 200
assert "setup_required" in r.json()
def test_setup(client, setup_response):
assert setup_response.status_code in (200, 400)
if setup_response.status_code == 200:
assert "access_token" in setup_response.json()
def test_login(client, setup_response):
r = client.post("/api/v1/auth/login", json={
"email": "owner@nexadash.local",
"password": "StrongPassword123!",
})
assert r.status_code == 200
assert "access_token" in r.json()
+21
View File
@@ -0,0 +1,21 @@
import pytest
from apps.api.src.plugins import registry
from apps.api.src.plugins.connectors.proxmox_ve import ProxmoxVEConnector
from apps.api.src.plugins.connectors.adguard_home import AdGuardHomeConnector
from apps.api.src.plugins.connectors.generic_http import GenericHTTPConnector
def test_builtin_connectors_registered():
assert "proxmox-ve" in registry.list_connectors()
assert "adguard-home" in registry.list_connectors()
assert "generic-http" in registry.list_connectors()
def test_connector_classes():
assert registry.get_connector("proxmox-ve") is ProxmoxVEConnector
assert registry.get_connector("adguard-home") is AdGuardHomeConnector
assert registry.get_connector("generic-http") is GenericHTTPConnector
def test_registry_missing():
assert registry.get_connector("unknown") is None
+29
View File
@@ -0,0 +1,29 @@
from apps.api.src.security.password import hash_password, verify_password
from apps.api.src.security.encryption import encrypt_value, decrypt_value, mask_secret
from apps.api.src.security.ssrf import is_safe_url
def test_password_hash():
pw = "super-secret-password"
hashed = hash_password(pw)
assert verify_password(pw, hashed)
assert not verify_password("wrong", hashed)
def test_encryption():
value = "api-token-secret"
encrypted = encrypt_value(value)
decrypted = decrypt_value(encrypted)
assert decrypted == value
def test_mask_secret():
assert mask_secret("1234567890", 4) == "******7890"
def test_ssrf_protection():
assert not is_safe_url("http://localhost:8000")
assert not is_safe_url("http://127.0.0.1/admin")
assert not is_safe_url("http://169.254.169.254")
assert not is_safe_url("ftp://example.com")
assert is_safe_url("https://example.com/api")
View File
+28
View File
@@ -0,0 +1,28 @@
const http = require("http");
const options = {
hostname: "localhost",
port: process.env.PORT || 3000,
path: "/",
method: "GET",
timeout: 2000,
};
const req = http.request(options, (res) => {
if (res.statusCode >= 200 && res.statusCode < 400) {
process.exit(0);
} else {
process.exit(1);
}
});
req.on("error", () => {
process.exit(1);
});
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
transpilePackages: ["@nexadash/ui", "@nexadash/shared", "@nexadash/plugin-sdk"],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
},
];
},
};
module.exports = nextConfig;
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@nexadash/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nexadash/shared": "workspace:*",
"@nexadash/ui": "workspace:*",
"@nexadash/plugin-sdk": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"axios": "^1.7.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^11.11.0",
"lucide-react": "^0.454.0",
"next": "^15.0.0",
"next-themes": "^0.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-grid-layout": "^1.5.0",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.0",
"zod": "^3.23.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"jsdom": "^25.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

Some files were not shown because too many files have changed in this diff Show More