diff --git a/.gitignore b/.gitignore index 52ff9c97a9..d4af18c534 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ __pycache__ # secrets .env +/.secrets/ # database mariadb diff --git a/backend/adapters/services/smb_controller.py b/backend/adapters/services/smb_controller.py new file mode 100644 index 0000000000..c2a3743818 --- /dev/null +++ b/backend/adapters/services/smb_controller.py @@ -0,0 +1,71 @@ +import json +import socket +from typing import Any + +from config import SMB_CONTROLLER_SOCKET, SMB_CONTROLLER_TIMEOUT + + +class SmbControllerError(Exception): + pass + + +class SmbController: + def request(self, action: str, **payload: Any) -> dict[str, Any]: + request = json.dumps({"action": action, **payload}, separators=(",", ":")) + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(SMB_CONTROLLER_TIMEOUT) + client.connect(SMB_CONTROLLER_SOCKET) + client.sendall(request.encode() + b"\n") + response = bytearray() + while not response.endswith(b"\n"): + chunk = client.recv(65536) + if not chunk: + break + response.extend(chunk) + if len(response) > 1024 * 1024: + raise SmbControllerError("SMB controller response is too large") + except (OSError, TimeoutError) as exc: + raise SmbControllerError("SMB controller is unavailable") from exc + + try: + result = json.loads(response) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise SmbControllerError("SMB controller returned an invalid response") from exc + + if not result.get("ok"): + raise SmbControllerError(result.get("error", "SMB controller request failed")) + return result + + def status(self) -> dict[str, Any]: + return self.request("status") + + def start(self) -> None: + self.request("start") + + def restart(self) -> None: + self.request("restart") + + def logs(self, limit: int) -> list[str]: + result = self.request("logs", limit=limit) + lines = result.get("lines") + if not isinstance(lines, list) or not all( + isinstance(line, str) for line in lines + ): + raise SmbControllerError("SMB controller returned invalid logs") + return lines + + def create_user(self, username: str, password: str) -> None: + self.request("create_user", username=username, password=password) + + def rotate_user(self, username: str, password: str) -> None: + self.request("rotate_user", username=username, password=password) + + def delete_user(self, username: str) -> None: + self.request("delete_user", username=username) + + def sync_config(self, users: list[dict[str, Any]]) -> None: + self.request("sync_config", users=users) + + +smb_controller = SmbController() diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4b278d2b93..97574b06b0 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,9 +1,9 @@ import sys from pathlib import Path -from alembic import context from sqlalchemy import create_engine +from alembic import context from config.config_manager import ConfigManager from logger.logger import unify_logger from models.assets import Save, Screenshot, State # noqa @@ -13,6 +13,7 @@ from models.music import MusicFavoriteTrack, MusicPlaylist, MusicPlaylistTrack # noqa from models.platform import Platform # noqa from models.rom import Rom, RomFacets, RomMetadata, SiblingRom # noqa +from models.smb import SmbPlatformPermission, SmbUser # noqa from models.user import User # noqa # this is the Alembic Config object, which provides diff --git a/backend/alembic/versions/0104_smb_access.py b/backend/alembic/versions/0104_smb_access.py new file mode 100644 index 0000000000..7e3a579275 --- /dev/null +++ b/backend/alembic/versions/0104_smb_access.py @@ -0,0 +1,72 @@ +"""Add managed SMB users and platform permissions + +Revision ID: 0104_smb_access +Revises: 0103_roms_facets_provider_ids +Create Date: 2026-07-27 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0104_smb_access" +down_revision = "0103_roms_facets_provider_ids" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "smb_users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_smb_users_username", "smb_users", ["username"], unique=True) + + op.create_table( + "smb_platform_permissions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("smb_user_id", sa.Integer(), nullable=False), + sa.Column("platform_id", sa.Integer(), nullable=False), + sa.Column( + "access", + sa.Enum( + "read", + "write", + name="smbaccessmode", + native_enum=False, + length=10, + ), + nullable=False, + ), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["platform_id"], ["platforms.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["smb_user_id"], ["smb_users.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "smb_user_id", "platform_id", name="uq_smb_user_platform" + ), + ) + op.create_index( + "ix_smb_platform_permissions_platform_id", + "smb_platform_permissions", + ["platform_id"], + ) + op.create_index( + "ix_smb_platform_permissions_smb_user_id", + "smb_platform_permissions", + ["smb_user_id"], + ) + + +def downgrade() -> None: + op.drop_table("smb_platform_permissions") + op.drop_table("smb_users") diff --git a/backend/config/__init__.py b/backend/config/__init__.py index bb784992cc..9d486055cb 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -3,7 +3,6 @@ import yarl from dotenv import load_dotenv - from utils.database import safe_int, safe_str_to_bool load_dotenv() @@ -265,6 +264,20 @@ def _get_env(var: str, fallback: str | None = None) -> str | None: "SYNC_SSH_KNOWN_HOSTS_PATH", f"{SYNC_BASE_PATH}/known_hosts" ) +# SMB +ENABLE_SMB: Final[bool] = safe_str_to_bool(_get_env("ENABLE_SMB")) +SMB_CONTROLLER_SOCKET: Final[str] = _get_env( + "SMB_CONTROLLER_SOCKET", "/run/romm-smb/control.sock" +) +SMB_CONTROLLER_TIMEOUT: Final[int] = max( + 1, safe_int(_get_env("SMB_CONTROLLER_TIMEOUT"), 5) +) +SMB_ADVERTISED_HOST: Final[str | None] = _get_env("SMB_ADVERTISED_HOST") +SMB_ADVERTISED_PORT: Final[int] = min( + 65535, max(1, safe_int(_get_env("SMB_ADVERTISED_PORT"), 445)) +) +SMB_WORKGROUP: Final[str] = _get_env("SMB_WORKGROUP", "WORKGROUP") + # EMULATION DISABLE_EMULATOR_JS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_EMULATOR_JS")) DISABLE_RUFFLE_RS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_RUFFLE_RS")) diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index c0732d02b7..d5dfd6614d 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -1,14 +1,13 @@ from datetime import datetime from typing import Annotated -from fastapi import Body -from fastapi import Path as PathVar -from fastapi import Query, Request, status - +from adapters.services.smb_controller import SmbControllerError +from config import ENABLE_SMB from decorators.auth import protected_route -from endpoints.responses.platform import PlatformSchema from exceptions.endpoint_exceptions import PlatformNotFoundInDatabaseException from exceptions.fs_exceptions import PlatformAlreadyExistsException +from fastapi import Body, HTTPException, Query, Request, status +from fastapi import Path as PathVar from handler.auth.constants import Scope from handler.auth.dependencies import ( assert_can, @@ -18,6 +17,7 @@ from handler.database import db_platform_handler from handler.filesystem import fs_platform_handler from handler.scan_handler import scan_platform +from handler.smb_access_handler import smb_access_handler from logger.formatter import BLUE from logger.formatter import highlight as hl from logger.logger import log @@ -30,6 +30,8 @@ from utils.platforms import get_filesystem_platforms, get_supported_platforms from utils.router import APIRouter +from endpoints.responses.platform import PlatformSchema + router = APIRouter( prefix="/platforms", tags=["platforms"], @@ -192,4 +194,12 @@ async def delete_platform( log.info( f"Deleting {hl(platform.name, color=BLUE)} [{hl(platform.fs_slug)}] from database" ) + if ENABLE_SMB: + try: + smb_access_handler.sync_config(excluded_platform_ids={id}) + except SmbControllerError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc db_platform_handler.delete_platform(id) diff --git a/backend/endpoints/responses/smb.py b/backend/endpoints/responses/smb.py new file mode 100644 index 0000000000..056787b8c5 --- /dev/null +++ b/backend/endpoints/responses/smb.py @@ -0,0 +1,42 @@ +from models.smb import SmbAccessMode +from pydantic import BaseModel, ConfigDict + +from .base import UTCDatetime + + +class SmbPlatformPermissionSchema(BaseModel): + platform_id: int + platform_name: str + platform_fs_slug: str + share_name: str + access: SmbAccessMode + + +class SmbUserSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + permissions: list[SmbPlatformPermissionSchema] + created_at: UTCDatetime + updated_at: UTCDatetime + + +class SmbUserSecretSchema(SmbUserSchema): + password: str + + +class SmbStatusSchema(BaseModel): + enabled: bool + controller_online: bool + samba_running: bool + samba_version: str | None = None + advertised_host: str | None = None + advertised_port: int + workgroup: str + started_at: UTCDatetime | None = None + user_count: int + + +class SmbLogsSchema(BaseModel): + lines: list[str] diff --git a/backend/endpoints/smb.py b/backend/endpoints/smb.py new file mode 100644 index 0000000000..f01053f9b7 --- /dev/null +++ b/backend/endpoints/smb.py @@ -0,0 +1,279 @@ +from typing import Annotated + +from adapters.services.smb_controller import SmbControllerError, smb_controller +from config import ( + ENABLE_SMB, + SMB_ADVERTISED_HOST, + SMB_ADVERTISED_PORT, + SMB_WORKGROUP, +) +from decorators.auth import protected_route +from fastapi import Body, HTTPException, Query, Request, Response, status +from handler.auth.constants import Scope +from handler.auth.dependencies import assert_admin +from handler.smb_access_handler import _share_name, smb_access_handler +from models.smb import SmbAccessMode, SmbUser +from pydantic import BaseModel, Field, field_validator +from utils.router import APIRouter + +from endpoints.responses.smb import ( + SmbLogsSchema, + SmbPlatformPermissionSchema, + SmbStatusSchema, + SmbUserSchema, + SmbUserSecretSchema, +) + +router = APIRouter(prefix="/smb", tags=["smb"]) + + +class SmbPermissionPayload(BaseModel): + platform_id: int = Field(ge=1) + access: SmbAccessMode + + +class SmbUserCreatePayload(BaseModel): + username: str = Field(min_length=3, max_length=32, pattern=r"^[a-z][a-z0-9._-]+$") + permissions: list[SmbPermissionPayload] = Field(min_length=1) + + @field_validator("username") + @classmethod + def normalize_username(cls, value: str) -> str: + return value.strip().lower() + + +class SmbUserUpdatePayload(BaseModel): + permissions: list[SmbPermissionPayload] = Field(min_length=1) + + +def _require_enabled() -> None: + if not ENABLE_SMB: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="SMB management is disabled", + ) + + +def _permissions_schema(user: SmbUser) -> list[SmbPlatformPermissionSchema]: + return [ + SmbPlatformPermissionSchema( + platform_id=permission.platform.id, + platform_name=permission.platform.custom_name or permission.platform.name, + platform_fs_slug=permission.platform.fs_slug, + share_name=_share_name( + permission.platform.id, permission.platform.fs_slug + ), + access=permission.access, + ) + for permission in user.permissions + ] + + +def _schema(user: SmbUser) -> SmbUserSchema: + return SmbUserSchema( + id=user.id, + username=user.username, + permissions=_permissions_schema(user), + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +def _secret_schema(user: SmbUser, password: str) -> SmbUserSecretSchema: + return SmbUserSecretSchema( + id=user.id, + username=user.username, + permissions=_permissions_schema(user), + created_at=user.created_at, + updated_at=user.updated_at, + password=password, + ) + + +def _permissions(payload: list[SmbPermissionPayload]): + return [(item.platform_id, item.access) for item in payload] + + +def _controller_error(exc: SmbControllerError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) + + +def _prevent_secret_caching(response: Response) -> None: + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + + +def _get_status() -> SmbStatusSchema: + users = smb_access_handler.list_users() + if not ENABLE_SMB: + return SmbStatusSchema( + enabled=False, + controller_online=False, + samba_running=False, + advertised_host=SMB_ADVERTISED_HOST, + advertised_port=SMB_ADVERTISED_PORT, + workgroup=SMB_WORKGROUP, + user_count=len(users), + ) + try: + controller_status = smb_controller.status() + except SmbControllerError: + return SmbStatusSchema( + enabled=True, + controller_online=False, + samba_running=False, + advertised_host=SMB_ADVERTISED_HOST, + advertised_port=SMB_ADVERTISED_PORT, + workgroup=SMB_WORKGROUP, + user_count=len(users), + ) + return SmbStatusSchema( + enabled=True, + controller_online=True, + samba_running=bool(controller_status.get("samba_running")), + samba_version=controller_status.get("samba_version"), + advertised_host=SMB_ADVERTISED_HOST, + advertised_port=SMB_ADVERTISED_PORT, + workgroup=str(controller_status.get("workgroup") or SMB_WORKGROUP), + started_at=controller_status.get("started_at"), + user_count=len(users), + ) + + +@protected_route(router.get, "/status", [Scope.USERS_READ]) +def get_status(request: Request) -> SmbStatusSchema: + assert_admin(request) + return _get_status() + + +@protected_route(router.post, "/start", [Scope.USERS_WRITE]) +def start_service(request: Request) -> SmbStatusSchema: + assert_admin(request) + _require_enabled() + try: + smb_controller.start() + smb_access_handler.sync_config() + except SmbControllerError as exc: + raise _controller_error(exc) from exc + return _get_status() + + +@protected_route(router.post, "/restart", [Scope.USERS_WRITE]) +def restart_service(request: Request) -> SmbStatusSchema: + assert_admin(request) + _require_enabled() + try: + smb_controller.restart() + smb_access_handler.sync_config() + except SmbControllerError as exc: + raise _controller_error(exc) from exc + return _get_status() + + +@protected_route(router.get, "/logs", [Scope.USERS_READ]) +def get_logs( + request: Request, + lines: int = Query(default=200, ge=1, le=500), +) -> SmbLogsSchema: + assert_admin(request) + _require_enabled() + try: + return SmbLogsSchema(lines=smb_controller.logs(lines)) + except SmbControllerError as exc: + raise _controller_error(exc) from exc + + +@protected_route(router.get, "/users", [Scope.USERS_READ]) +def list_users(request: Request) -> list[SmbUserSchema]: + assert_admin(request) + return [_schema(user) for user in smb_access_handler.list_users()] + + +@protected_route( + router.post, + "/users", + [Scope.USERS_WRITE], + status_code=status.HTTP_201_CREATED, +) +def create_user( + request: Request, + response: Response, + payload: Annotated[SmbUserCreatePayload, Body()], +) -> SmbUserSecretSchema: + assert_admin(request) + _require_enabled() + if smb_access_handler.get_user_by_username(payload.username): + raise HTTPException(status_code=409, detail="SMB username already exists") + try: + user, password = smb_access_handler.create_user( + payload.username, _permissions(payload.permissions) + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SmbControllerError as exc: + raise _controller_error(exc) from exc + _prevent_secret_caching(response) + return _secret_schema(user, password) + + +@protected_route(router.put, "/users/{user_id}", [Scope.USERS_WRITE]) +def update_user( + request: Request, + user_id: int, + payload: Annotated[SmbUserUpdatePayload, Body()], +) -> SmbUserSchema: + assert_admin(request) + _require_enabled() + try: + user = smb_access_handler.update_user( + user_id, _permissions(payload.permissions) + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SmbControllerError as exc: + raise _controller_error(exc) from exc + if user is None: + raise HTTPException(status_code=404, detail="SMB user not found") + return _schema(user) + + +@protected_route(router.post, "/users/{user_id}/rotate", [Scope.USERS_WRITE]) +def rotate_user( + request: Request, response: Response, user_id: int +) -> SmbUserSecretSchema: + assert_admin(request) + _require_enabled() + try: + result = smb_access_handler.rotate_password(user_id) + except SmbControllerError as exc: + raise _controller_error(exc) from exc + if result is None: + raise HTTPException(status_code=404, detail="SMB user not found") + user, password = result + _prevent_secret_caching(response) + return _secret_schema(user, password) + + +@protected_route(router.delete, "/users/{user_id}", [Scope.USERS_WRITE]) +def delete_user(request: Request, user_id: int) -> None: + assert_admin(request) + _require_enabled() + try: + deleted = smb_access_handler.delete_user(user_id) + except SmbControllerError as exc: + raise _controller_error(exc) from exc + if not deleted: + raise HTTPException(status_code=404, detail="SMB user not found") + + +@protected_route(router.post, "/sync", [Scope.USERS_WRITE]) +def sync_config(request: Request) -> None: + assert_admin(request) + _require_enabled() + try: + smb_access_handler.sync_config() + except SmbControllerError as exc: + raise _controller_error(exc) from exc diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index 1d2f4cfe49..e99ac3b912 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -10,6 +10,7 @@ from .roms_handler import DBRomsHandler from .saves_handler import DBSavesHandler from .screenshots_handler import DBScreenshotsHandler +from .smb_handler import DBSmbHandler from .states_handler import DBStatesHandler from .stats_handler import DBStatsHandler from .sync_sessions_handler import DBSyncSessionsHandler @@ -27,6 +28,7 @@ db_rom_handler = DBRomsHandler() db_save_handler = DBSavesHandler() db_screenshot_handler = DBScreenshotsHandler() +db_smb_handler = DBSmbHandler() db_state_handler = DBStatesHandler() db_stats_handler = DBStatsHandler() db_sync_session_handler = DBSyncSessionsHandler() diff --git a/backend/handler/database/smb_handler.py b/backend/handler/database/smb_handler.py new file mode 100644 index 0000000000..8f875e10be --- /dev/null +++ b/backend/handler/database/smb_handler.py @@ -0,0 +1,93 @@ +from collections.abc import Sequence + +from decorators.database import begin_session +from models.smb import SmbPlatformPermission, SmbUser +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload + +from .base_handler import DBBaseHandler + + +class DBSmbHandler(DBBaseHandler): + @begin_session + def list_users( + self, + session: Session = None, # type: ignore + ) -> Sequence[SmbUser]: + return session.scalars( + select(SmbUser) + .options( + selectinload(SmbUser.permissions).selectinload( + SmbPlatformPermission.platform + ) + ) + .order_by(SmbUser.username.asc()) + ).all() + + @begin_session + def get_user( + self, + user_id: int, + session: Session = None, # type: ignore + ) -> SmbUser | None: + return session.scalar( + select(SmbUser) + .where(SmbUser.id == user_id) + .options( + selectinload(SmbUser.permissions).selectinload( + SmbPlatformPermission.platform + ) + ) + ) + + @begin_session + def get_user_by_username( + self, + username: str, + session: Session = None, # type: ignore + ) -> SmbUser | None: + return session.scalar(select(SmbUser).where(SmbUser.username == username)) + + @begin_session + def add_user( + self, + user: SmbUser, + session: Session = None, # type: ignore + ) -> SmbUser: + session.add(user) + session.flush() + return self.get_user(user.id, session=session) + + @begin_session + def replace_permissions( + self, + user_id: int, + permissions: list[SmbPlatformPermission], + session: Session = None, # type: ignore + ) -> SmbUser | None: + user = session.scalar( + select(SmbUser) + .where(SmbUser.id == user_id) + .options(selectinload(SmbUser.permissions)) + ) + if user is None: + return None + + # Flush the orphan removals before inserting replacements. This keeps + # the per-user/platform uniqueness constraint valid when an access + # mode is changed for an existing platform. + user.permissions.clear() + session.flush() + for permission in permissions: + user.permissions.append(permission) + session.flush() + return self.get_user(user_id, session=session) + + @begin_session + def delete_user( + self, + user_id: int, + session: Session = None, # type: ignore + ) -> int: + result = session.execute(delete(SmbUser).where(SmbUser.id == user_id)) + return result.rowcount diff --git a/backend/handler/smb_access_handler.py b/backend/handler/smb_access_handler.py new file mode 100644 index 0000000000..c28a88b363 --- /dev/null +++ b/backend/handler/smb_access_handler.py @@ -0,0 +1,136 @@ +import re +import secrets +from contextlib import suppress + +from adapters.services.smb_controller import SmbControllerError, smb_controller +from models.smb import SmbAccessMode, SmbPlatformPermission, SmbUser + +from handler.database import db_platform_handler, db_smb_handler +from handler.filesystem import fs_platform_handler + + +def _share_name(platform_id: int, fs_slug: str) -> str: + safe_slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", fs_slug).strip("_") + return f"platform_{platform_id}_{safe_slug[:40]}" + + +class SmbAccessHandler: + def list_users(self): + return db_smb_handler.list_users() + + def get_user(self, user_id: int) -> SmbUser | None: + return db_smb_handler.get_user(user_id) + + def get_user_by_username(self, username: str) -> SmbUser | None: + return db_smb_handler.get_user_by_username(username) + + def create_user( + self, + username: str, + permissions: list[tuple[int, SmbAccessMode]], + ) -> tuple[SmbUser, str]: + validated = self._build_permissions(permissions) + raw_password = secrets.token_urlsafe(24) + user = db_smb_handler.add_user( + SmbUser(username=username, permissions=validated) + ) + try: + smb_controller.create_user(username, raw_password) + self.sync_config() + except SmbControllerError: + with suppress(SmbControllerError): + smb_controller.delete_user(username) + db_smb_handler.delete_user(user.id) + raise + return user, raw_password + + def update_user( + self, + user_id: int, + permissions: list[tuple[int, SmbAccessMode]], + ) -> SmbUser | None: + validated = self._build_permissions(permissions) + current_user = db_smb_handler.get_user(user_id) + if current_user is None: + return None + previous_permissions = [ + SmbPlatformPermission( + platform_id=permission.platform_id, + access=permission.access, + ) + for permission in current_user.permissions + ] + user = db_smb_handler.replace_permissions(user_id, validated) + if user is not None: + try: + self.sync_config() + except SmbControllerError: + db_smb_handler.replace_permissions(user_id, previous_permissions) + with suppress(SmbControllerError): + self.sync_config() + raise + return user + + def rotate_password(self, user_id: int) -> tuple[SmbUser, str] | None: + user = db_smb_handler.get_user(user_id) + if user is None: + return None + raw_password = secrets.token_urlsafe(24) + smb_controller.rotate_user(user.username, raw_password) + return user, raw_password + + def delete_user(self, user_id: int) -> bool: + user = db_smb_handler.get_user(user_id) + if user is None: + return False + smb_controller.delete_user(user.username) + deleted = db_smb_handler.delete_user(user_id) > 0 + self.sync_config() + return deleted + + def sync_config(self, excluded_platform_ids: set[int] | None = None) -> None: + excluded_platform_ids = excluded_platform_ids or set() + users = [] + for user in db_smb_handler.list_users(): + permissions = [] + for permission in user.permissions: + platform = permission.platform + if platform.id in excluded_platform_ids: + continue + permissions.append( + { + "platform_id": platform.id, + "share_name": _share_name(platform.id, platform.fs_slug), + "path": fs_platform_handler.get_platform_fs_structure( + platform.fs_slug + ), + "access": permission.access.value, + } + ) + users.append({"username": user.username, "permissions": permissions}) + smb_controller.sync_config(users) + + def _build_permissions( + self, + permissions: list[tuple[int, SmbAccessMode]], + ) -> list[SmbPlatformPermission]: + platform_ids = [platform_id for platform_id, _ in permissions] + if len(platform_ids) != len(set(platform_ids)): + raise ValueError("Each platform can only be assigned once") + + result = [] + for platform_id, access in permissions: + platform = db_platform_handler.get_platform(platform_id) + if platform is None: + raise ValueError(f"Platform {platform_id} was not found") + if platform.missing_from_fs: + raise ValueError( + f"Platform {platform.display_name if hasattr(platform, 'display_name') else platform.name} is missing from the filesystem" + ) + result.append( + SmbPlatformPermission(platform_id=platform_id, access=access) + ) + return result + + +smb_access_handler = SmbAccessHandler() diff --git a/backend/main.py b/backend/main.py index bba55b4706..4cc35729f1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,6 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi_pagination import add_pagination from starlette.middleware.authentication import AuthenticationMiddleware -from startup import main import endpoints.sockets.activity # noqa import endpoints.sockets.logs # noqa @@ -52,6 +51,7 @@ from endpoints.saves import router as saves_router from endpoints.screenshots import router as screenshots_router from endpoints.search import router as search_router +from endpoints.smb import router as smb_router from endpoints.states import router as states_router from endpoints.stats import router as stats_router from endpoints.streaming import router as streaming_router @@ -65,6 +65,7 @@ from handler.middleware.upload_size_middleware import UploadSizeLimitMiddleware from handler.socket_handler import netplay_socket_handler, socket_handler from logger.formatter import LOGGING_CONFIG +from startup import main from utils import get_version from utils.context import ( ctx_aiohttp_session, @@ -195,6 +196,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(netplay_router, prefix="/api") app.include_router(permissions_router, prefix="/api") app.include_router(streaming_router, prefix="/api") +app.include_router(smb_router, prefix="/api") app.mount("/ws", socket_handler.socket_app) app.mount("/netplay", netplay_socket_handler.socket_app) diff --git a/backend/models/smb.py b/backend/models/smb.py new file mode 100644 index 0000000000..522e4037a3 --- /dev/null +++ b/backend/models/smb.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import enum +from typing import TYPE_CHECKING + +from sqlalchemy import Enum, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import BaseModel + +if TYPE_CHECKING: + from models.platform import Platform + + +class SmbAccessMode(enum.StrEnum): + READ = "read" + WRITE = "write" + + +class SmbUser(BaseModel): + __tablename__ = "smb_users" + __table_args__ = ({"extend_existing": True},) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(32), unique=True, index=True) + + permissions: Mapped[list[SmbPlatformPermission]] = relationship( + back_populates="smb_user", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class SmbPlatformPermission(BaseModel): + __tablename__ = "smb_platform_permissions" + __table_args__ = ( + UniqueConstraint( + "smb_user_id", + "platform_id", + name="uq_smb_user_platform", + ), + {"extend_existing": True}, + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + smb_user_id: Mapped[int] = mapped_column( + ForeignKey("smb_users.id", ondelete="CASCADE"), index=True + ) + platform_id: Mapped[int] = mapped_column( + ForeignKey("platforms.id", ondelete="CASCADE"), index=True + ) + access: Mapped[SmbAccessMode] = mapped_column( + Enum( + SmbAccessMode, + native_enum=False, + length=10, + values_callable=lambda values: [value.value for value in values], + ) + ) + + smb_user: Mapped[SmbUser] = relationship(back_populates="permissions") + platform: Mapped[Platform] = relationship(lazy="joined") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ced230b688..8403652185 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -4,11 +4,6 @@ import alembic.config import pytest -from hypothesis import settings -from joserfc import jwt -from sqlalchemy import create_engine, text -from sqlalchemy.orm import sessionmaker - from config import ROMM_DB_DRIVER from config.config_manager import ConfigManager from handler.auth import auth_handler @@ -22,6 +17,8 @@ db_state_handler, db_user_handler, ) +from hypothesis import settings +from joserfc import jwt from models.assets import Save, Screenshot, State from models.client_token import ClientToken from models.device import Device @@ -29,8 +26,11 @@ from models.platform import Platform from models.play_session import PlaySession from models.rom import Rom, RomFile +from models.smb import SmbPlatformPermission, SmbUser from models.sync_session import SyncSession from models.user import Role, User +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker engine = create_engine(ConfigManager.get_db_engine(), pool_pre_ping=True) session = sessionmaker(bind=engine, expire_on_commit=False) @@ -100,6 +100,8 @@ def clear_database(): s.query(Screenshot).delete(synchronize_session="evaluate") s.query(RomFile).delete(synchronize_session="evaluate") s.query(Rom).delete(synchronize_session="evaluate") + s.query(SmbPlatformPermission).delete(synchronize_session="evaluate") + s.query(SmbUser).delete(synchronize_session="evaluate") s.query(Platform).delete(synchronize_session="evaluate") s.query(User).delete(synchronize_session="evaluate") diff --git a/backend/tests/handler/database/test_smb_handler.py b/backend/tests/handler/database/test_smb_handler.py new file mode 100644 index 0000000000..9e24a50a1d --- /dev/null +++ b/backend/tests/handler/database/test_smb_handler.py @@ -0,0 +1,86 @@ +import pytest +from adapters.services.smb_controller import SmbControllerError +from handler.database import db_smb_handler +from handler.smb_access_handler import smb_access_handler +from models.smb import SmbAccessMode, SmbPlatformPermission, SmbUser + + +def test_replace_permissions_updates_existing_platform_access(platform): + user = db_smb_handler.add_user( + SmbUser( + username="lounge", + permissions=[ + SmbPlatformPermission( + platform_id=platform.id, + access=SmbAccessMode.READ, + ) + ], + ) + ) + + updated = db_smb_handler.replace_permissions( + user.id, + [ + SmbPlatformPermission( + platform_id=platform.id, + access=SmbAccessMode.WRITE, + ) + ], + ) + + assert updated is not None + assert len(updated.permissions) == 1 + assert updated.permissions[0].platform_id == platform.id + assert updated.permissions[0].access == SmbAccessMode.WRITE + + persisted = db_smb_handler.get_user(user.id) + assert persisted is not None + assert len(persisted.permissions) == 1 + assert persisted.permissions[0].access == SmbAccessMode.WRITE + + +def test_delete_user_cascades_platform_permissions(platform): + user = db_smb_handler.add_user( + SmbUser( + username="bedroom", + permissions=[ + SmbPlatformPermission( + platform_id=platform.id, + access=SmbAccessMode.READ, + ) + ], + ) + ) + + assert db_smb_handler.delete_user(user.id) == 1 + assert db_smb_handler.get_user(user.id) is None + + +def test_update_user_restores_permissions_when_samba_sync_fails(platform, mocker): + user = db_smb_handler.add_user( + SmbUser( + username="office", + permissions=[ + SmbPlatformPermission( + platform_id=platform.id, + access=SmbAccessMode.READ, + ) + ], + ) + ) + sync = mocker.patch( + "handler.smb_access_handler.smb_controller.sync_config", + side_effect=SmbControllerError("controller unavailable"), + ) + + with pytest.raises(SmbControllerError, match="controller unavailable"): + smb_access_handler.update_user( + user.id, + [(platform.id, SmbAccessMode.WRITE)], + ) + + persisted = db_smb_handler.get_user(user.id) + assert persisted is not None + assert len(persisted.permissions) == 1 + assert persisted.permissions[0].access == SmbAccessMode.READ + assert sync.call_count == 2 diff --git a/docker-compose.yml b/docker-compose.yml index e91d2218f4..7929e1640c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,11 @@ services: - REDIS_HOST=romm-valkey-dev - DB_HOST=${DB_HOST:-romm-db-dev} - ROMM_BASE_PATH=/app/romm + - ENABLE_SMB=${ENABLE_SMB:-true} + - SMB_CONTROLLER_SOCKET=/run/romm-smb/control.sock + - SMB_ADVERTISED_HOST=${ROMM_SMB_ADVERTISED_HOST:-} + - SMB_ADVERTISED_PORT=${ROMM_SMB_PORT:-1445} + - SMB_WORKGROUP=${ROMM_SMB_WORKGROUP:-WORKGROUP} ports: - "3000:3000" # Vite dev server (custom) - "5173:5173" # Vite dev server (default) @@ -24,15 +29,49 @@ services: - /app/frontend/node_modules # Empty directory - /app/frontend/dist # Empty directory - ./romm_mock:/app/romm + - romm-smb-control:/run/romm-smb - ~/.vite-plugin-mkcert:/app/.vite-plugin-mkcert depends_on: - romm-db-dev - romm-valkey-dev - romm-postgres-dev + - romm-smb command: /bin/bash -c "cd /app && bash" stdin_open: true tty: true + romm-smb: + build: + context: . + dockerfile: docker/smb/Dockerfile + image: romm-smb:local + container_name: romm-smb + restart: unless-stopped + environment: + - ROMM_SMB_WORKGROUP=${ROMM_SMB_WORKGROUP:-WORKGROUP} + volumes: + - ./romm_mock/library:/library + - romm-smb-control:/run/romm-smb + - romm-smb-data:/var/lib/samba + networks: + - romm-smb + ports: + - ${ROMM_SMB_BIND_ADDRESS:-127.0.0.1}:${ROMM_SMB_PORT:-1445}:445/tcp + tmpfs: + - /run/samba:mode=0755 + - /var/cache/samba:mode=0755 + - /var/log/samba:mode=0755,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - NET_BIND_SERVICE + - SETGID + - SETUID + romm-db-dev: image: mariadb:11.3.2 container_name: romm-db-dev @@ -120,3 +159,8 @@ volumes: postgres-db: authentik-media: authentik-templates: + romm-smb-control: + romm-smb-data: + +networks: + romm-smb: diff --git a/docker/smb/Dockerfile b/docker/smb/Dockerfile new file mode 100644 index 0000000000..80623e482b --- /dev/null +++ b/docker/smb/Dockerfile @@ -0,0 +1,25 @@ +ARG ALPINE_VERSION=3.23 +ARG ALPINE_SHA256=25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 + +FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} + +ARG SAMBA_VERSION=4.22.10-r0 + +RUN apk add --no-cache \ + python3=3.12.13-r0 \ + samba-client=${SAMBA_VERSION} \ + samba-common-tools=${SAMBA_VERSION} \ + samba-server=${SAMBA_VERSION} && \ + addgroup -g 1000 -S romm && \ + adduser -u 1000 -D -H -S -G romm -s /sbin/nologin romm + +COPY --chmod=644 docker/smb/smb.conf.template /etc/samba/smb.conf.template +COPY --chmod=755 docker/smb/controller.py /usr/local/bin/smb-controller +COPY --chmod=755 docker/smb/healthcheck.sh /usr/local/bin/smb-healthcheck + +EXPOSE 445/tcp + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/usr/local/bin/smb-healthcheck"] + +ENTRYPOINT ["/usr/local/bin/smb-controller"] diff --git a/docker/smb/controller.py b/docker/smb/controller.py new file mode 100755 index 0000000000..c6dada639a --- /dev/null +++ b/docker/smb/controller.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 + +import json +import os +import pwd +import re +import signal +import socket +import socketserver +import struct +import subprocess +import tempfile +import threading +import time +from collections import defaultdict, deque +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +CONFIG_TEMPLATE = Path("/etc/samba/smb.conf.template") +CONFIG_PATH = Path("/run/samba/smb.conf") +CONTROL_SOCKET = Path("/run/romm-smb/control.sock") +LIBRARY_PATH = Path("/library") +MANAGED_USERS_PATH = Path("/var/lib/samba/romm-managed-users.json") +MAX_REQUEST_BYTES = 1024 * 1024 +MAX_LOG_LINES = 500 +MAX_LOG_BYTES = 256 * 1024 +MAX_LOG_FILES = 20 +USERNAME_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{2,31}$") +SHARE_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,80}$") +ALLOWED_PEER_UIDS = {0, 1000} + + +class ControllerError(Exception): + pass + + +def run_command( + command: list[str], + *, + input_text: str | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + input=input_text, + text=True, + check=check, + capture_output=True, + ) + + +class SmbController: + def __init__(self) -> None: + self._lock = threading.Lock() + self._smbd: subprocess.Popen[str] | None = None + self._started_at: str | None = None + self._events: deque[str] = deque(maxlen=MAX_LOG_LINES) + self._configured_shares: set[str] = set() + self._prepare_directories() + self._managed_unix_users = self._load_managed_unix_users() + self._write_base_config() + self._restore_unix_users() + self._record_event("SMB controller initialized") + + def start_samba(self) -> None: + if self.samba_running(): + return + self._smbd = subprocess.Popen( + [ + "smbd", + "--foreground", + "--no-process-group", + f"--configfile={CONFIG_PATH}", + ], + start_new_session=True, + ) + self._started_at = datetime.now(UTC).isoformat() + self._record_event("Samba service started") + + def stop_samba(self) -> None: + if self._smbd is None: + return + if self._smbd.poll() is not None: + self._started_at = None + return + self._smbd.terminate() + try: + self._smbd.wait(timeout=10) + except subprocess.TimeoutExpired: + self._smbd.kill() + self._smbd.wait(timeout=5) + self._started_at = None + self._record_event("Samba service stopped") + + def samba_running(self) -> bool: + return self._smbd is not None and self._smbd.poll() is None + + def reap_orphaned_children(self) -> None: + active_smbd_pid = self._smbd.pid if self._smbd is not None else None + for status_path in Path("/proc").glob("[0-9]*/status"): + pid = int(status_path.parent.name) + if pid == active_smbd_pid: + continue + try: + status_lines = status_path.read_text().splitlines() + except (FileNotFoundError, ProcessLookupError): + continue + state = next( + ( + line.split(":", 1)[1].strip() + for line in status_lines + if line.startswith("State:") + ), + "", + ) + parent_pid = next( + ( + line.split(":", 1)[1].strip() + for line in status_lines + if line.startswith("PPid:") + ), + "", + ) + if state.startswith("Z") and parent_pid == str(os.getpid()): + try: + os.waitpid(pid, os.WNOHANG) + except (ChildProcessError, ProcessLookupError): + pass + + def handle(self, request: dict[str, Any]) -> dict[str, Any]: + action = request.get("action") + with self._lock: + if action == "status": + version = run_command(["smbd", "--version"]).stdout.strip() + return { + "ok": True, + "samba_running": self.samba_running(), + "samba_version": version, + "started_at": self._started_at, + "workgroup": os.environ.get("ROMM_SMB_WORKGROUP", "WORKGROUP"), + } + if action == "start": + self.start_samba() + self._wait_until_running() + return {"ok": True} + if action == "restart": + self.stop_samba() + self.start_samba() + self._wait_until_running() + return {"ok": True} + if action == "logs": + return {"ok": True, "lines": self._logs(request.get("limit"))} + if action == "create_user": + self._set_password( + self._username(request), self._password(request), create=True + ) + self._record_event("SMB user created") + return {"ok": True} + if action == "rotate_user": + self._set_password( + self._username(request), self._password(request), create=False + ) + self._record_event("SMB user password regenerated") + return {"ok": True} + if action == "delete_user": + self._delete_user(self._username(request)) + self._record_event("SMB user deleted") + return {"ok": True} + if action == "sync_config": + self._sync_config(request.get("users")) + self._record_event("SMB configuration synchronized") + return {"ok": True} + raise ControllerError("Unsupported controller action") + + def _wait_until_running(self) -> None: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not self.samba_running(): + raise ControllerError("Samba failed to start") + result = run_command( + [ + "smbcontrol", + "--configfile", + str(CONFIG_PATH), + "smbd", + "ping", + ], + check=False, + ) + if result.returncode == 0: + return + time.sleep(0.2) + raise ControllerError("Samba did not become ready") + + def _record_event(self, message: str) -> None: + timestamp = datetime.now(UTC).isoformat(timespec="seconds") + self._events.append(f"{timestamp} controller: {message}") + + def _logs(self, requested_limit: Any) -> list[str]: + if not isinstance(requested_limit, int) or isinstance(requested_limit, bool): + raise ControllerError("Log limit must be an integer") + if requested_limit < 1 or requested_limit > MAX_LOG_LINES: + raise ControllerError(f"Log limit must be between 1 and {MAX_LOG_LINES}") + + lines = list(self._events) + log_root = Path("/var/log/samba").resolve() + remaining_bytes = MAX_LOG_BYTES + + def modification_time(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0 + + log_paths = sorted( + log_root.glob("log.*"), + key=modification_time, + reverse=True, + )[:MAX_LOG_FILES] + for log_path in log_paths: + if remaining_bytes <= 0: + break + resolved_path = log_path.resolve() + if ( + not resolved_path.is_relative_to(log_root) + or not resolved_path.is_file() + ): + continue + with resolved_path.open("rb") as log_file: + log_file.seek(0, os.SEEK_END) + size = log_file.tell() + bytes_to_read = min(remaining_bytes, size) + log_file.seek(max(0, size - bytes_to_read)) + raw_content = log_file.read(bytes_to_read) + remaining_bytes -= len(raw_content) + content = raw_content.decode( + "utf-8", errors="replace" + ) + source = resolved_path.name + lines.extend( + f"{source}: {line[:2000]}" + for line in content.splitlines() + if line.strip() + ) + return lines[-requested_limit:] + + def _prepare_directories(self) -> None: + for directory in ( + Path("/run/samba/ncalrpc"), + Path("/run/romm-smb"), + Path("/var/cache/samba"), + Path("/var/lib/samba/private"), + Path("/var/lib/samba/usershares"), + Path("/var/log/samba"), + ): + directory.mkdir(parents=True, exist_ok=True) + os.chown("/run/romm-smb", 0, 1000) + os.chmod("/run/romm-smb", 0o750) + + def _write_base_config(self) -> None: + workgroup = os.environ.get("ROMM_SMB_WORKGROUP", "WORKGROUP") + if not re.fullmatch(r"[A-Za-z0-9_-]{1,15}", workgroup): + raise ControllerError("ROMM_SMB_WORKGROUP is invalid") + config = CONFIG_TEMPLATE.read_text().replace("{{SMB_WORKGROUP}}", workgroup) + self._replace_config(config) + + def _restore_unix_users(self) -> None: + result = run_command( + ["pdbedit", "-s", str(CONFIG_PATH), "-L"], check=False + ) + if result.returncode not in (0, 1): + raise ControllerError("Unable to read the Samba user database") + for line in result.stdout.splitlines(): + username = line.split(":", 1)[0] + if USERNAME_PATTERN.fullmatch(username): + if username not in self._managed_unix_users: + try: + existing_user = pwd.getpwnam(username) + except KeyError: + existing_user = None + if existing_user is not None and not self._is_managed_unix_user( + existing_user + ): + run_command( + [ + "smbpasswd", + "-c", + str(CONFIG_PATH), + "-x", + username, + ], + check=False, + ) + self._record_event( + "Removed an SMB credential that conflicted with a system account" + ) + continue + self._managed_unix_users.add(username) + self._save_managed_unix_users() + self._ensure_unix_user(username) + + def _load_managed_unix_users(self) -> set[str]: + if not MANAGED_USERS_PATH.exists(): + return set() + try: + data = json.loads(MANAGED_USERS_PATH.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise ControllerError("Managed SMB user registry is invalid") from exc + if not isinstance(data, list) or not all( + isinstance(username, str) and USERNAME_PATTERN.fullmatch(username) + for username in data + ): + raise ControllerError("Managed SMB user registry is invalid") + + managed_users = set(data) + for username in tuple(managed_users): + try: + existing_user = pwd.getpwnam(username) + except KeyError: + continue + if not self._is_managed_unix_user(existing_user): + managed_users.remove(username) + if managed_users != set(data): + self._managed_unix_users = managed_users + self._save_managed_unix_users() + return managed_users + + def _save_managed_unix_users(self) -> None: + with tempfile.NamedTemporaryFile( + mode="w", dir=MANAGED_USERS_PATH.parent, delete=False + ) as temporary: + json.dump(sorted(self._managed_unix_users), temporary) + temporary.write("\n") + temporary_path = Path(temporary.name) + try: + os.chmod(temporary_path, 0o600) + temporary_path.replace(MANAGED_USERS_PATH) + finally: + temporary_path.unlink(missing_ok=True) + + def _is_managed_unix_user(self, user: pwd.struct_passwd) -> bool: + romm_group_id = pwd.getpwnam("romm").pw_gid + return ( + user.pw_name != "romm" + and user.pw_gid == romm_group_id + and user.pw_shell in ("/sbin/nologin", "/usr/sbin/nologin") + ) + + def _ensure_unix_user(self, username: str) -> None: + try: + existing_user = pwd.getpwnam(username) + except KeyError: + existing_user = None + + if existing_user is not None: + if username in self._managed_unix_users and self._is_managed_unix_user( + existing_user + ): + return + raise ControllerError("SMB username conflicts with a system account") + + run_command( + [ + "adduser", + "-D", + "-H", + "-S", + "-G", + "romm", + "-s", + "/sbin/nologin", + username, + ] + ) + self._managed_unix_users.add(username) + self._save_managed_unix_users() + + def _set_password(self, username: str, password: str, *, create: bool) -> None: + self._ensure_unix_user(username) + command = ["smbpasswd", "-c", str(CONFIG_PATH)] + if create: + command.append("-a") + command.extend(["-s", username]) + result = run_command( + command, + input_text=f"{password}\n{password}\n", + check=False, + ) + if result.returncode != 0: + raise ControllerError("Unable to update the SMB credential") + + def _delete_user(self, username: str) -> None: + run_command( + ["smbpasswd", "-c", str(CONFIG_PATH), "-x", username], check=False + ) + if username not in self._managed_unix_users: + return + try: + existing_user = pwd.getpwnam(username) + except KeyError: + existing_user = None + if existing_user is not None and self._is_managed_unix_user(existing_user): + run_command(["deluser", username], check=False) + self._managed_unix_users.discard(username) + self._save_managed_unix_users() + + def _sync_config(self, users: Any) -> None: + if not isinstance(users, list): + raise ControllerError("Controller users must be a list") + + shares: dict[tuple[str, str], dict[str, set[str]]] = defaultdict( + lambda: {"read": set(), "write": set()} + ) + for user in users: + if not isinstance(user, dict): + raise ControllerError("Controller user is invalid") + username = self._validate_username(user.get("username")) + permissions = user.get("permissions") + if not isinstance(permissions, list): + raise ControllerError("Controller permissions must be a list") + for permission in permissions: + if not isinstance(permission, dict): + raise ControllerError("Controller permission is invalid") + share_name = permission.get("share_name") + if not isinstance(share_name, str) or not SHARE_PATTERN.fullmatch( + share_name + ): + raise ControllerError("SMB share name is invalid") + path = self._validate_library_path(permission.get("path")) + access = permission.get("access") + if access not in ("read", "write"): + raise ControllerError("SMB access mode is invalid") + shares[(share_name, path)][access].add(username) + + base = CONFIG_TEMPLATE.read_text().replace( + "{{SMB_WORKGROUP}}", os.environ.get("ROMM_SMB_WORKGROUP", "WORKGROUP") + ) + sections = [base.rstrip()] + for (share_name, path), access_lists in sorted(shares.items()): + readers = sorted(access_lists["read"]) + writers = sorted(access_lists["write"]) + valid_users = sorted(set(readers + writers)) + lines = [ + f"[{share_name}]", + f"path = {path}", + "read only = yes", + "guest ok = no", + "browseable = yes", + f"valid users = {' '.join(valid_users)}", + "force user = romm", + "force group = romm", + "create mask = 0664", + "directory mask = 0775", + "follow symlinks = no", + "wide links = no", + ] + if readers: + lines.append(f"read list = {' '.join(readers)}") + if writers: + lines.append(f"write list = {' '.join(writers)}") + sections.append("\n".join(lines)) + + previous_shares = self._configured_shares + configured_shares = {share_name for share_name, _path in shares} + self._replace_config("\n\n".join(sections) + "\n") + if self.samba_running(): + result = run_command( + [ + "smbcontrol", + "--configfile", + str(CONFIG_PATH), + "smbd", + "reload-config", + ], + check=False, + ) + if result.returncode != 0: + raise ControllerError("Unable to reload the SMB configuration") + for share_name in sorted(previous_shares | configured_shares): + result = run_command( + [ + "smbcontrol", + "--configfile", + str(CONFIG_PATH), + "smbd", + "close-share", + share_name, + ], + check=False, + ) + if result.returncode != 0: + raise ControllerError( + "Unable to disconnect clients from an updated SMB share" + ) + self._configured_shares = configured_shares + + def _replace_config(self, config: str) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", dir=CONFIG_PATH.parent, delete=False + ) as temporary: + temporary.write(config) + temporary_path = Path(temporary.name) + try: + result = run_command( + ["testparm", "-s", str(temporary_path)], check=False + ) + if result.returncode != 0: + raise ControllerError("Generated SMB configuration is invalid") + os.chmod(temporary_path, 0o600) + temporary_path.replace(CONFIG_PATH) + finally: + temporary_path.unlink(missing_ok=True) + + def _username(self, request: dict[str, Any]) -> str: + return self._validate_username(request.get("username")) + + def _validate_username(self, username: Any) -> str: + if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username): + raise ControllerError("SMB username is invalid") + if username == "romm": + raise ControllerError("SMB username is reserved") + return username + + def _password(self, request: dict[str, Any]) -> str: + password = request.get("password") + if ( + not isinstance(password, str) + or len(password) < 16 + or len(password) > 128 + or "\n" in password + or "\r" in password + ): + raise ControllerError("SMB password is invalid") + return password + + def _validate_library_path(self, relative_path: Any) -> str: + if not isinstance(relative_path, str) or any( + character in relative_path for character in ("\x00", "\n", "\r") + ): + raise ControllerError("SMB library path is invalid") + relative = Path(relative_path) + if relative.is_absolute() or ".." in relative.parts: + raise ControllerError("SMB library path is invalid") + root = LIBRARY_PATH.resolve() + candidate = (root / relative).resolve() + candidate_path = str(candidate) + if ( + not candidate.is_relative_to(root) + or not candidate.is_dir() + or any(character in candidate_path for character in ("\x00", "\n", "\r")) + ): + raise ControllerError("SMB platform directory does not exist") + return candidate_path + + +controller = SmbController() + + +class ControlHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + credentials = self.request.getsockopt( + socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i") + ) + _, peer_uid, _ = struct.unpack("3i", credentials) + if peer_uid not in ALLOWED_PEER_UIDS: + self._respond({"ok": False, "error": "Controller access denied"}) + return + + request_data = self.rfile.readline(MAX_REQUEST_BYTES + 1) + if len(request_data) > MAX_REQUEST_BYTES: + self._respond({"ok": False, "error": "Controller request is too large"}) + return + try: + request = json.loads(request_data) + if not isinstance(request, dict): + raise ControllerError("Controller request is invalid") + response = controller.handle(request) + except (ControllerError, json.JSONDecodeError, UnicodeDecodeError) as exc: + response = {"ok": False, "error": str(exc)} + # Keep unexpected command/runtime details off the control protocol. + except Exception: # noqa: BLE001 + response = {"ok": False, "error": "Internal controller error"} + self._respond(response) + + def _respond(self, response: dict[str, Any]) -> None: + self.wfile.write(json.dumps(response, separators=(",", ":")).encode() + b"\n") + + +class ControlServer(socketserver.ThreadingUnixStreamServer): + daemon_threads = True + + +def main() -> None: + CONTROL_SOCKET.unlink(missing_ok=True) + server = ControlServer(str(CONTROL_SOCKET), ControlHandler) + os.chown(CONTROL_SOCKET, 0, 1000) + os.chmod(CONTROL_SOCKET, 0o660) + + stop = threading.Event() + + def request_stop(_signum: int, _frame: Any) -> None: + stop.set() + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + + controller.start_samba() + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + try: + while not stop.is_set(): + controller.reap_orphaned_children() + time.sleep(0.5) + finally: + server.shutdown() + server.server_close() + CONTROL_SOCKET.unlink(missing_ok=True) + controller.stop_samba() + + +if __name__ == "__main__": + main() diff --git a/docker/smb/healthcheck.sh b/docker/smb/healthcheck.sh new file mode 100644 index 0000000000..72809ab90e --- /dev/null +++ b/docker/smb/healthcheck.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eu + +smbcontrol \ + --configfile=/run/samba/smb.conf \ + smbd ping 2>/dev/null | grep -q '^PONG from pid' diff --git a/docker/smb/smb.conf.template b/docker/smb/smb.conf.template new file mode 100644 index 0000000000..2c45b6f829 --- /dev/null +++ b/docker/smb/smb.conf.template @@ -0,0 +1,32 @@ +[global] +server role = standalone server +security = user +workgroup = {{SMB_WORKGROUP}} + +map to guest = Never +restrict anonymous = 2 +usershare allow guests = no +access based share enum = yes + +server min protocol = SMB3_00 +server max protocol = SMB3_11 +ntlm auth = ntlmv2-only +server signing = mandatory +server smb encrypt = required +smb ports = 445 +disable netbios = yes + +load printers = no +disable spoolss = yes +printing = bsd +printcap name = /dev/null + +private dir = /var/lib/samba/private +state directory = /var/lib/samba +cache directory = /var/cache/samba +lock directory = /run/samba +pid directory = /run/samba +ncalrpc dir = /run/samba/ncalrpc +log file = /var/log/samba/log.%m +logging = file +max log size = 1000 diff --git a/examples/docker-compose.example.yml b/examples/docker-compose.example.yml index 827444b486..fc67bab6f7 100644 --- a/examples/docker-compose.example.yml +++ b/examples/docker-compose.example.yml @@ -2,6 +2,11 @@ volumes: mysql_data: romm_resources: romm_redis_data: + romm_smb_control: + romm_smb_data: + +networks: + romm-smb: services: romm: @@ -14,6 +19,11 @@ services: - DB_USER=romm-user # Should match MARIADB_USER in mariadb - DB_PASSWD= # Should match MARIADB_PASSWORD in mariadb - ROMM_AUTH_SECRET_KEY= # Generate a key with `openssl rand -hex 32` + - ENABLE_SMB=${ENABLE_SMB:-true} + - SMB_CONTROLLER_SOCKET=/run/romm-smb/control.sock + - SMB_ADVERTISED_HOST=${ROMM_SMB_ADVERTISED_HOST:-} + - SMB_ADVERTISED_PORT=${ROMM_SMB_PORT:-445} + - SMB_WORKGROUP=${ROMM_SMB_WORKGROUP:-WORKGROUP} - SCREENSCRAPER_USER= # These are the recommended metadata providers - SCREENSCRAPER_PASSWORD= # https://docs.romm.app/latest/Getting-Started/Metadata-Providers/#screenscraper - RETROACHIEVEMENTS_API_KEY= # https://docs.romm.app/latest/Getting-Started/Metadata-Providers/#retroachievements @@ -22,7 +32,8 @@ services: volumes: - romm_resources:/romm/resources # Resources fetched from IGDB (covers, screenshots, etc.) - romm_redis_data:/redis-data # Cached data for background tasks - - /path/to/library:/romm/library # Your game library. Check https://docs.romm.app/latest/Getting-Started/Folder-Structure/ for more details. + - ${ROMM_LIBRARY_PATH:-/path/to/library}:/romm/library # Your game library. Check https://docs.romm.app/latest/Getting-Started/Folder-Structure/ for more details. + - romm_smb_control:/run/romm-smb - /path/to/assets:/romm/assets # Uploaded saves, states, etc. - /path/to/config:/romm/config # (Optional) Path where config.yml is stored ports: @@ -31,6 +42,41 @@ services: romm-db: condition: service_healthy restart: true + romm-smb: + condition: service_started + restart: true + + romm-smb: + build: + context: .. + dockerfile: docker/smb/Dockerfile + image: romm-smb:local + container_name: romm-smb + restart: unless-stopped + environment: + - ROMM_SMB_WORKGROUP=${ROMM_SMB_WORKGROUP:-WORKGROUP} + volumes: + - ${ROMM_LIBRARY_PATH:-/path/to/library}:/library + - romm_smb_control:/run/romm-smb + - romm_smb_data:/var/lib/samba + networks: + - romm-smb + ports: + - ${ROMM_SMB_BIND_ADDRESS:-127.0.0.1}:${ROMM_SMB_PORT:-445}:445/tcp + tmpfs: + - /run/samba:mode=0755 + - /var/cache/samba:mode=0755 + - /var/log/samba:mode=0755,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - NET_BIND_SERVICE + - SETGID + - SETUID romm-db: image: mariadb:latest diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 12762d7482..486f4d8d43 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -170,6 +170,15 @@ export type { SiblingRomSchema } from './models/SiblingRomSchema'; export type { SimpleRomSchema } from './models/SimpleRomSchema'; export type { SlotSummarySchema } from './models/SlotSummarySchema'; export type { SmartCollectionSchema } from './models/SmartCollectionSchema'; +export type { SmbAccessMode } from './models/SmbAccessMode'; +export type { SmbLogsSchema } from './models/SmbLogsSchema'; +export type { SmbPermissionPayload } from './models/SmbPermissionPayload'; +export type { SmbPlatformPermissionSchema } from './models/SmbPlatformPermissionSchema'; +export type { SmbStatusSchema } from './models/SmbStatusSchema'; +export type { SmbUserCreatePayload } from './models/SmbUserCreatePayload'; +export type { SmbUserSchema } from './models/SmbUserSchema'; +export type { SmbUserSecretSchema } from './models/SmbUserSecretSchema'; +export type { SmbUserUpdatePayload } from './models/SmbUserUpdatePayload'; export type { SoundtrackTrackMetaSchema } from './models/SoundtrackTrackMetaSchema'; export type { SSAgeRating } from './models/SSAgeRating'; export type { StateSchema } from './models/StateSchema'; diff --git a/frontend/src/__generated__/models/SmbAccessMode.ts b/frontend/src/__generated__/models/SmbAccessMode.ts new file mode 100644 index 0000000000..ac2f5e6d42 --- /dev/null +++ b/frontend/src/__generated__/models/SmbAccessMode.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SmbAccessMode = 'read' | 'write'; diff --git a/frontend/src/__generated__/models/SmbLogsSchema.ts b/frontend/src/__generated__/models/SmbLogsSchema.ts new file mode 100644 index 0000000000..7707f1cdf8 --- /dev/null +++ b/frontend/src/__generated__/models/SmbLogsSchema.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SmbLogsSchema = { + lines: Array; +}; + diff --git a/frontend/src/__generated__/models/SmbPermissionPayload.ts b/frontend/src/__generated__/models/SmbPermissionPayload.ts new file mode 100644 index 0000000000..6b5719aac8 --- /dev/null +++ b/frontend/src/__generated__/models/SmbPermissionPayload.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SmbAccessMode } from './SmbAccessMode'; +export type SmbPermissionPayload = { + platform_id: number; + access: SmbAccessMode; +}; + diff --git a/frontend/src/__generated__/models/SmbPlatformPermissionSchema.ts b/frontend/src/__generated__/models/SmbPlatformPermissionSchema.ts new file mode 100644 index 0000000000..c8ff8dc3c1 --- /dev/null +++ b/frontend/src/__generated__/models/SmbPlatformPermissionSchema.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SmbAccessMode } from './SmbAccessMode'; +export type SmbPlatformPermissionSchema = { + platform_id: number; + platform_name: string; + platform_fs_slug: string; + share_name: string; + access: SmbAccessMode; +}; + diff --git a/frontend/src/__generated__/models/SmbStatusSchema.ts b/frontend/src/__generated__/models/SmbStatusSchema.ts new file mode 100644 index 0000000000..d0946ee9b0 --- /dev/null +++ b/frontend/src/__generated__/models/SmbStatusSchema.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SmbStatusSchema = { + enabled: boolean; + controller_online: boolean; + samba_running: boolean; + samba_version?: (string | null); + advertised_host?: (string | null); + advertised_port: number; + workgroup: string; + started_at?: (string | null); + user_count: number; +}; + diff --git a/frontend/src/__generated__/models/SmbUserCreatePayload.ts b/frontend/src/__generated__/models/SmbUserCreatePayload.ts new file mode 100644 index 0000000000..d648fdd724 --- /dev/null +++ b/frontend/src/__generated__/models/SmbUserCreatePayload.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SmbPermissionPayload } from './SmbPermissionPayload'; +export type SmbUserCreatePayload = { + username: string; + permissions: Array; +}; + diff --git a/frontend/src/__generated__/models/SmbUserSchema.ts b/frontend/src/__generated__/models/SmbUserSchema.ts new file mode 100644 index 0000000000..24691d93d9 --- /dev/null +++ b/frontend/src/__generated__/models/SmbUserSchema.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SmbPlatformPermissionSchema } from './SmbPlatformPermissionSchema'; +export type SmbUserSchema = { + id: number; + username: string; + permissions: Array; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/SmbUserSecretSchema.ts b/frontend/src/__generated__/models/SmbUserSecretSchema.ts new file mode 100644 index 0000000000..ec9f5c9be9 --- /dev/null +++ b/frontend/src/__generated__/models/SmbUserSecretSchema.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SmbPlatformPermissionSchema } from './SmbPlatformPermissionSchema'; +export type SmbUserSecretSchema = { + id: number; + username: string; + permissions: Array; + created_at: string; + updated_at: string; + password: string; +}; + diff --git a/frontend/src/__generated__/models/SmbUserUpdatePayload.ts b/frontend/src/__generated__/models/SmbUserUpdatePayload.ts new file mode 100644 index 0000000000..69f73ce05a --- /dev/null +++ b/frontend/src/__generated__/models/SmbUserUpdatePayload.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SmbPermissionPayload } from './SmbPermissionPayload'; +export type SmbUserUpdatePayload = { + permissions: Array; +}; + diff --git a/frontend/src/locales/bg_BG/settings.json b/frontend/src/locales/bg_BG/settings.json index 281464f72e..48c39458af 100644 --- a/frontend/src/locales/bg_BG/settings.json +++ b/frontend/src/locales/bg_BG/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Показвай икони за статус в галерията (в списъка, играна, завършена и др.)", "show-virtual-collections": "Покажи автоматично генерирани колекции", "show-virtual-collections-desc": "Показва се на началната страница и в страничната лента с колекции.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Сортирай по брой игри", "sort-by-name": "Сортирай по име", "sort-by-size": "Сортирай по размер", diff --git a/frontend/src/locales/cs_CZ/settings.json b/frontend/src/locales/cs_CZ/settings.json index 2e79107283..393b75022a 100644 --- a/frontend/src/locales/cs_CZ/settings.json +++ b/frontend/src/locales/cs_CZ/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Zobrazit statusové ikony v galerii (odloženo, hraju, dokončeno atd.)", "show-virtual-collections": "Zobrazit automaticky vytvořené kolekce", "show-virtual-collections-desc": "Zobrazit na hlavní stránce a v panelu kolekcí", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Řadit podle počtu her", "sort-by-name": "Řadit podle názvu", "sort-by-size": "Řadit podle velikosti", diff --git a/frontend/src/locales/de_DE/settings.json b/frontend/src/locales/de_DE/settings.json index 39fa54ef9a..9e9791be06 100644 --- a/frontend/src/locales/de_DE/settings.json +++ b/frontend/src/locales/de_DE/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Ziegt den aktuellen Status eines ROMs als Icons in der Galerie an (Vorgemerkt, derzeit gespielt, durchgespielt, etc)", "show-virtual-collections": "Zeige automatisch generierte Sammlungen", "show-virtual-collections-desc": "Wird auf der Startseite und in der Sammlungs-Seitenleiste angezeigt.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Nach Spielanzahl sortieren", "sort-by-name": "Nach Name sortieren", "sort-by-size": "Nach Größe sortieren", diff --git a/frontend/src/locales/en_GB/settings.json b/frontend/src/locales/en_GB/settings.json index f8e91406a5..c61e110c12 100644 --- a/frontend/src/locales/en_GB/settings.json +++ b/frontend/src/locales/en_GB/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Show status icons in the gallery (backlogged, playing, completed, etc)", "show-virtual-collections": "Show autogenerated collections", "show-virtual-collections-desc": "Displayed in the homepage and collections sidebar.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Sort by game count", "sort-by-name": "Sort by name", "sort-by-size": "Sort by size", diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index f3d1045a3b..f70e0055bf 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Show status icons in the gallery (backlogged, playing, completed, etc)", "show-virtual-collections": "Show autogenerated collections", "show-virtual-collections-desc": "Displayed in the homepage and collections sidebar.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Sort by game count", "sort-by-name": "Sort by name", "sort-by-size": "Sort by size", diff --git a/frontend/src/locales/es_ES/settings.json b/frontend/src/locales/es_ES/settings.json index 5d52476cc3..2011b1b912 100644 --- a/frontend/src/locales/es_ES/settings.json +++ b/frontend/src/locales/es_ES/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Mostrar icono de estado en la galería (backlogged, playing, completed, etc)", "show-virtual-collections": "Mostrar colecciones generadas automáticamente", "show-virtual-collections-desc": "Mostrado en la página principal y en la barra lateral de colecciones.", + "smb-access": "Acceso SMB", + "smb-address": "Dirección del servidor", + "smb-address-copied": "Dirección del servidor SMB copiada", + "smb-all-read": "Todas en solo lectura", + "smb-all-write": "Todas en lectura y escritura", + "smb-bulk-actions": "Permisos rápidos", + "smb-container-unavailable": "El contenedor SMB no está disponible. Las instalaciones nuevas lo incluyen automáticamente; en una instalación existente, inicia una vez el servicio romm-smb desde el equipo anfitrión.", + "smb-controller-offline": "Desconectado", + "smb-controller-online": "Conectado", + "smb-copy-address": "Copiar dirección", + "smb-copy-password": "Copiar contraseña", + "smb-create": "Crear usuario", + "smb-credential-title": "Credencial SMB", + "smb-credential-warning": "Por seguridad, RomM no guarda esta contraseña. Puedes volver a verla hasta que recargues la página; después tendrás que regenerarla si la necesitas.", + "smb-delete-confirm": "¿Eliminar el usuario SMB {username} y revocar su acceso de red?", + "smb-delete-failed": "No se pudo eliminar el usuario SMB: {detail}", + "smb-description": "Gestiona cuentas SMB independientes y elige acceso de lectura o escritura para cada plataforma.", + "smb-disabled": "La gestión SMB está desactivada. Activa ENABLE_SMB para gestionar cuentas.", + "smb-edit-user": "Editar usuario SMB", + "smb-hide-password": "Ocultar contraseña", + "smb-load-failed": "No se pudo cargar la configuración SMB: {detail}", + "smb-logs-empty": "Todavía no hay entradas en los registros SMB.", + "smb-logs-failed": "No se pudieron cargar los registros SMB: {detail}", + "smb-logs-hint": "Últimas entradas del controlador y de Samba. Las contraseñas nunca se escriben aquí.", + "smb-logs-title": "Registros del servicio SMB", + "smb-new-user": "Nuevo usuario SMB", + "smb-no-platforms-found": "Ninguna plataforma coincide con la búsqueda.", + "smb-none": "Sin acceso", + "smb-not-available": "No disponible", + "smb-password": "Contraseña", + "smb-password-copied": "Contraseña SMB copiada", + "smb-platform-access": "Acceso a plataformas", + "smb-platform-access-hint": "Elige sin acceso, solo lectura o lectura y escritura para cada plataforma.", + "smb-platforms-selected": "{selected} de {total} plataformas con acceso", + "smb-port": "Puerto", + "smb-read": "Solo lectura", + "smb-refresh": "Actualizar", + "smb-restart-service": "Reiniciar servicio", + "smb-reveal-password": "Mostrar contraseña", + "smb-rotate": "Rotar contraseña", + "smb-rotate-confirm": "¿Rotar la contraseña de {username}? Las conexiones existentes necesitarán la nueva contraseña.", + "smb-rotate-title": "Rotar contraseña SMB", + "smb-save-failed": "No se pudo guardar el usuario SMB: {detail}", + "smb-select-platform": "Selecciona al menos una plataforma.", + "smb-server-details": "Datos de conexión", + "smb-server-details-hint": "Usa estos datos para conectarte desde otro dispositivo de la misma red.", + "smb-service-failed": "No se pudo controlar el servicio SMB: {detail}", + "smb-service-restarted": "Servicio SMB reiniciado", + "smb-service-running": "En ejecución", + "smb-service-started": "Servicio SMB iniciado", + "smb-service-stopped": "Detenido", + "smb-shares": "Recursos disponibles", + "smb-start-service": "Iniciar servicio", + "smb-started-at": "Iniciado", + "smb-sync": "Sincronizar", + "smb-sync-failed": "No se pudo sincronizar la configuración SMB: {detail}", + "smb-synced": "Configuración SMB sincronizada", + "smb-unknown-error": "Error desconocido", + "smb-user-created": "Usuario SMB {username} creado", + "smb-user-deleted": "Usuario SMB {username} eliminado", + "smb-user-rotated": "Contraseña rotada para {username}", + "smb-user-updated": "Usuario SMB {username} actualizado", + "smb-username": "Usuario SMB", + "smb-username-invalid": "Usa entre 3 y 32 letras minúsculas, números, puntos, guiones bajos o guiones, empezando por una letra.", + "smb-username-required": "El nombre de usuario es obligatorio", + "smb-users-empty": "No hay usuarios SMB configurados.", + "smb-version": "Versión", + "smb-view-logs": "Ver registros", + "smb-view-password": "Ver contraseña", + "smb-workgroup": "Grupo de trabajo", + "smb-write": "Lectura y escritura", "sort-by-game-count": "Por cantidad de juegos", "sort-by-name": "Por nombre", "sort-by-size": "Por tamaño", diff --git a/frontend/src/locales/fr_FR/settings.json b/frontend/src/locales/fr_FR/settings.json index 9d8eaf48c4..17f0f8b27a 100644 --- a/frontend/src/locales/fr_FR/settings.json +++ b/frontend/src/locales/fr_FR/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Afficher les icônes de statut dans la galerie (en attente, en train de jouer, terminé, etc.)", "show-virtual-collections": "Afficher les collections générées automatiquement", "show-virtual-collections-desc": "Affiché sur la page d'accueil et dans la barre latérale des collections.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Trier par nombre de jeux", "sort-by-name": "Trier par nom", "sort-by-size": "Trier par taille", diff --git a/frontend/src/locales/hu_HU/settings.json b/frontend/src/locales/hu_HU/settings.json index 2baf3b89b3..7863ed1bda 100644 --- a/frontend/src/locales/hu_HU/settings.json +++ b/frontend/src/locales/hu_HU/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Állapotikonok megjelenítése (függőben, folyamatban, befejezett stb.)", "show-virtual-collections": "Automatikus gyűjtemények megjelenítése", "show-virtual-collections-desc": "A kezdőlapon és a gyűjtemények oldalsávban jelenik meg.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Rendezés játékszám szerint", "sort-by-name": "Rendezés név szerint", "sort-by-size": "Rendezés méret szerint", diff --git a/frontend/src/locales/it_IT/settings.json b/frontend/src/locales/it_IT/settings.json index be020e5bde..8eedb92910 100644 --- a/frontend/src/locales/it_IT/settings.json +++ b/frontend/src/locales/it_IT/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Mostra le icone di stato nella galleria (in attesa, in gioco, completato, ecc.)", "show-virtual-collections": "Mostra collezioni autogenerate", "show-virtual-collections-desc": "Visualizzate nella home e nella barra laterale delle collezioni", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Ordina per numero di giochi", "sort-by-name": "Ordina per nome", "sort-by-size": "Ordina per dimensione", diff --git a/frontend/src/locales/ja_JP/settings.json b/frontend/src/locales/ja_JP/settings.json index be54a55ed2..b888293eef 100644 --- a/frontend/src/locales/ja_JP/settings.json +++ b/frontend/src/locales/ja_JP/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "ギャラリーにステータスアイコンを表示 (未プレイ, プレイ中, 完了済, その他)", "show-virtual-collections": "自動生成されたコレクションを表示", "show-virtual-collections-desc": "ホームに自動生成されたコレクションを表示", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "ゲーム数で並び替え", "sort-by-name": "名前で並び替え", "sort-by-size": "サイズで並び替え", diff --git a/frontend/src/locales/ko_KR/settings.json b/frontend/src/locales/ko_KR/settings.json index 4e19ee9b04..456fdecf79 100644 --- a/frontend/src/locales/ko_KR/settings.json +++ b/frontend/src/locales/ko_KR/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "갤러리에서 상태 아이콘을 보여줍니다(플레이중, 완료됨 등)", "show-virtual-collections": "자동 생성된 모음집 보이기", "show-virtual-collections-desc": "홈페이지와 모음집 사이드바에 표시됩니다", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "게임 수로 정렬", "sort-by-name": "이름으로 정렬", "sort-by-size": "크기로 정렬", diff --git a/frontend/src/locales/pl_PL/settings.json b/frontend/src/locales/pl_PL/settings.json index 6a08bb0f67..3777e58fa6 100644 --- a/frontend/src/locales/pl_PL/settings.json +++ b/frontend/src/locales/pl_PL/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Pokaż ikony statusów w galerii (zaległe, grane, ukończone itd.)", "show-virtual-collections": "Pokaż automatyczne kolekcje", "show-virtual-collections-desc": "Wyświetlane na stronie głównej i w panelu kolekcji", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Sortuj według liczby gier", "sort-by-name": "Sortuj według nazwy", "sort-by-size": "Sortuj według rozmiaru", diff --git a/frontend/src/locales/pt_BR/settings.json b/frontend/src/locales/pt_BR/settings.json index 03fd236089..e7da08b054 100644 --- a/frontend/src/locales/pt_BR/settings.json +++ b/frontend/src/locales/pt_BR/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Mostrar ícones de status na galeria (em espera, jogando, concluído, etc)", "show-virtual-collections": "Mostrar coleções geradas automaticamente", "show-virtual-collections-desc": "Exibido na página inicial e na barra lateral de coleções.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Ordenar por quantidade de jogos", "sort-by-name": "Ordenar por nome", "sort-by-size": "Ordenar por tamanho", diff --git a/frontend/src/locales/ro_RO/settings.json b/frontend/src/locales/ro_RO/settings.json index 7a95c7fd99..9d369be812 100644 --- a/frontend/src/locales/ro_RO/settings.json +++ b/frontend/src/locales/ro_RO/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Afișează pictogramele de status în galerie (în așteptare, în curs de joc, finalizat etc.)", "show-virtual-collections": "Afișează colecțiile generate automat", "show-virtual-collections-desc": "Afișate pe pagina principală și în bara laterală a colecțiilor.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Sortează după numărul de jocuri", "sort-by-name": "Sortează după nume", "sort-by-size": "Sortează după dimensiune", diff --git a/frontend/src/locales/ru_RU/settings.json b/frontend/src/locales/ru_RU/settings.json index f452197d53..2867044355 100644 --- a/frontend/src/locales/ru_RU/settings.json +++ b/frontend/src/locales/ru_RU/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Показать иконки статуса в галерее (в ожидании, играется, завершено и т.д.)", "show-virtual-collections": "Показать автоматически созданные коллекции", "show-virtual-collections-desc": "Отображается на главной странице и в боковой панели коллекций.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Сортировать по числу игр", "sort-by-name": "Сортировать по имени", "sort-by-size": "Сортировать по размеру", diff --git a/frontend/src/locales/tr_TR/settings.json b/frontend/src/locales/tr_TR/settings.json index e3d56c1531..d94defad04 100644 --- a/frontend/src/locales/tr_TR/settings.json +++ b/frontend/src/locales/tr_TR/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "Galeride durum ikonlarını göster (beklemede, oynanıyor, tamamlandı vb.)", "show-virtual-collections": "Otomatik koleksiyonları göster", "show-virtual-collections-desc": "Ana sayfada ve koleksiyonlar kenar çubuğunda görüntülenir.", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "Oyun sayısına göre sırala", "sort-by-name": "Ada göre sırala", "sort-by-size": "Boyuta göre sırala", diff --git a/frontend/src/locales/zh_CN/settings.json b/frontend/src/locales/zh_CN/settings.json index 2900194376..a811e8d549 100644 --- a/frontend/src/locales/zh_CN/settings.json +++ b/frontend/src/locales/zh_CN/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "在游戏库中显示状态标识(积压、游玩中、已完成等)", "show-virtual-collections": "显示自动生成的收藏", "show-virtual-collections-desc": "在主页和收藏侧边栏中显示", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "按游戏数量排序", "sort-by-name": "按名称排序", "sort-by-size": "按容量排序", diff --git a/frontend/src/locales/zh_TW/settings.json b/frontend/src/locales/zh_TW/settings.json index c934363f32..5ecd3b2f58 100644 --- a/frontend/src/locales/zh_TW/settings.json +++ b/frontend/src/locales/zh_TW/settings.json @@ -423,6 +423,77 @@ "show-status-desc": "在遊戲庫中顯示狀態圖標(待遊玩、遊玩中、已完成等)", "show-virtual-collections": "顯示自動生成收藏庫", "show-virtual-collections-desc": "在主頁及收藏庫側邊欄中顯示", + "smb-access": "SMB access", + "smb-address": "Server address", + "smb-address-copied": "SMB server address copied", + "smb-all-read": "All read only", + "smb-all-write": "All read and write", + "smb-bulk-actions": "Quick permissions", + "smb-container-unavailable": "The SMB container is unavailable. New installations include it automatically; on an existing installation, start the romm-smb service from the host once.", + "smb-controller-offline": "Offline", + "smb-controller-online": "Online", + "smb-copy-address": "Copy address", + "smb-copy-password": "Copy password", + "smb-create": "Create user", + "smb-credential-title": "SMB credential", + "smb-credential-warning": "For security, this password is not stored in RomM. You can reopen it until this page is refreshed; after that, regenerate it if needed.", + "smb-delete-confirm": "Delete SMB user {username} and revoke network access?", + "smb-delete-failed": "Unable to delete SMB user: {detail}", + "smb-description": "Manage independent SMB accounts and choose read or write access for each platform.", + "smb-disabled": "SMB management is disabled. Enable ENABLE_SMB to manage accounts.", + "smb-edit-user": "Edit SMB user", + "smb-hide-password": "Hide password", + "smb-load-failed": "Unable to load SMB configuration: {detail}", + "smb-logs-empty": "No SMB log entries are available yet.", + "smb-logs-failed": "Unable to load SMB logs: {detail}", + "smb-logs-hint": "Latest controller and Samba entries. Passwords are never written here.", + "smb-logs-title": "SMB service logs", + "smb-new-user": "New SMB user", + "smb-no-platforms-found": "No platforms match your search.", + "smb-none": "No access", + "smb-not-available": "Not available", + "smb-password": "Password", + "smb-password-copied": "SMB password copied", + "smb-platform-access": "Platform access", + "smb-platform-access-hint": "Choose no access, read only, or read and write for each platform.", + "smb-platforms-selected": "{selected} of {total} platforms with access", + "smb-port": "Port", + "smb-read": "Read only", + "smb-refresh": "Refresh", + "smb-restart-service": "Restart service", + "smb-reveal-password": "Reveal password", + "smb-rotate": "Rotate password", + "smb-rotate-confirm": "Rotate the password for {username}? Existing connections will need the new password.", + "smb-rotate-title": "Rotate SMB password", + "smb-save-failed": "Unable to save SMB user: {detail}", + "smb-select-platform": "Select at least one platform.", + "smb-server-details": "Connection details", + "smb-server-details-hint": "Use these details to connect from another device on the same network.", + "smb-service-failed": "Unable to control the SMB service: {detail}", + "smb-service-restarted": "SMB service restarted", + "smb-service-running": "Running", + "smb-service-started": "SMB service started", + "smb-service-stopped": "Stopped", + "smb-shares": "Available shares", + "smb-start-service": "Start service", + "smb-started-at": "Started", + "smb-sync": "Synchronize", + "smb-sync-failed": "Unable to synchronize SMB configuration: {detail}", + "smb-synced": "SMB configuration synchronized", + "smb-unknown-error": "Unknown error", + "smb-user-created": "SMB user {username} created", + "smb-user-deleted": "SMB user {username} deleted", + "smb-user-rotated": "Password rotated for {username}", + "smb-user-updated": "SMB user {username} updated", + "smb-username": "SMB username", + "smb-username-invalid": "Use 3 to 32 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter.", + "smb-username-required": "Username is required", + "smb-users-empty": "No SMB users configured.", + "smb-version": "Version", + "smb-view-logs": "View logs", + "smb-view-password": "View password", + "smb-workgroup": "Workgroup", + "smb-write": "Read and write", "sort-by-game-count": "依遊戲數量排序", "sort-by-name": "依名稱排序", "sort-by-size": "依大小排序", diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index 1fe6facee8..f18cfece8e 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -43,6 +43,7 @@ export const ROUTES = { METADATA_SOURCES: "metadata-sources", CLIENT_API_TOKENS: "client-api-tokens", ADMINISTRATION: "administration", + SMB_ACCESS: "smb-access", SERVER_STATS: "server-stats", LOGS: "logs", PAIR: "pair", @@ -411,6 +412,18 @@ const routes = [ v2: v2For(ROUTES.ADMINISTRATION), }, }, + { + path: "smb-access", + name: ROUTES.SMB_ACCESS, + meta: { + title: i18n.global.t("settings.smb-access"), + bare: true, + }, + components: { + default: () => import("@/views/Home.vue"), + v2: v2For(ROUTES.SMB_ACCESS), + }, + }, { path: "server-stats", name: ROUTES.SERVER_STATS, @@ -572,6 +585,7 @@ const routePermissions: RoutePermissions[] = [ { path: ROUTES.LIBRARY_MANAGEMENT, requiredScopes: ["platforms.write"] }, { path: ROUTES.SCAN_SETTINGS, requiredScopes: ["platforms.write"] }, { path: ROUTES.ADMINISTRATION, requiredScopes: ["users.write"] }, + { path: ROUTES.SMB_ACCESS, requiredScopes: ["users.write"] }, { path: ROUTES.LOGS, requiredScopes: ["logs.read"] }, ]; diff --git a/frontend/src/services/api/smb.ts b/frontend/src/services/api/smb.ts new file mode 100644 index 0000000000..ca1545f9e1 --- /dev/null +++ b/frontend/src/services/api/smb.ts @@ -0,0 +1,69 @@ +import type { + SmbAccessMode, + SmbLogsSchema, + SmbStatusSchema, + SmbUserSchema, + SmbUserSecretSchema, +} from "@/__generated__"; +import api from "@/services/api"; + +export interface SmbPermissionInput { + platform_id: number; + access: SmbAccessMode; +} + +async function getStatus() { + return api.get("/smb/status"); +} + +async function getUsers() { + return api.get("/smb/users"); +} + +async function startService() { + return api.post("/smb/start"); +} + +async function restartService() { + return api.post("/smb/restart"); +} + +async function getLogs(lines = 200) { + return api.get("/smb/logs", { params: { lines } }); +} + +async function createUser(payload: { + username: string; + permissions: SmbPermissionInput[]; +}) { + return api.post("/smb/users", payload); +} + +async function updateUser(userId: number, permissions: SmbPermissionInput[]) { + return api.put(`/smb/users/${userId}`, { permissions }); +} + +async function rotateUser(userId: number) { + return api.post(`/smb/users/${userId}/rotate`); +} + +async function deleteUser(userId: number) { + return api.delete(`/smb/users/${userId}`); +} + +async function syncConfig() { + return api.post("/smb/sync"); +} + +export default { + getStatus, + startService, + restartService, + getLogs, + getUsers, + createUser, + updateUser, + rotateUser, + deleteUser, + syncConfig, +}; diff --git a/frontend/src/v2/components/AppShell/UserMenu.vue b/frontend/src/v2/components/AppShell/UserMenu.vue index 0be6e99c8e..1675448a35 100644 --- a/frontend/src/v2/components/AppShell/UserMenu.vue +++ b/frontend/src/v2/components/AppShell/UserMenu.vue @@ -245,6 +245,13 @@ async function onLogout() { :label="t('common.administration')" @click="open = false" /> + (() => { to: { name: ROUTES.ADMINISTRATION }, visible: scopes.value.includes("users.write"), }, + { + icon: "mdi-folder-network-outline", + label: t("settings.smb-access"), + to: { name: ROUTES.SMB_ACCESS }, + visible: scopes.value.includes("users.write"), + }, { icon: "mdi-access-point", label: t("activity.active-sessions"), diff --git a/frontend/src/v2/components/Settings/SmbAccessSection.vue b/frontend/src/v2/components/Settings/SmbAccessSection.vue new file mode 100644 index 0000000000..1934d74c0f --- /dev/null +++ b/frontend/src/v2/components/Settings/SmbAccessSection.vue @@ -0,0 +1,1111 @@ + + + + + diff --git a/frontend/src/v2/router/routes.ts b/frontend/src/v2/router/routes.ts index cc0bfb5078..1dcd99420d 100644 --- a/frontend/src/v2/router/routes.ts +++ b/frontend/src/v2/router/routes.ts @@ -52,6 +52,7 @@ export const v2RouteComponents: Partial> = { "metadata-sources": () => import("@/v2/views/Settings/MetadataSources.vue"), "client-api-tokens": () => import("@/v2/views/Settings/ClientApiTokens.vue"), administration: () => import("@/v2/views/Settings/Administration.vue"), + "smb-access": () => import("@/v2/views/Settings/SmbAccess.vue"), "server-stats": () => import("@/v2/views/Settings/ServerStats.vue"), logs: () => import("@/v2/views/Settings/Logs.vue"), // V2-only index pages (no v1 equivalent — the v1 UI uses its drawer) diff --git a/frontend/src/v2/views/Settings/Administration.vue b/frontend/src/v2/views/Settings/Administration.vue index 720c84ca9d..ad2e0f3927 100644 --- a/frontend/src/v2/views/Settings/Administration.vue +++ b/frontend/src/v2/views/Settings/Administration.vue @@ -19,6 +19,7 @@ import EditUserDialog from "@/v2/components/Settings/EditUserDialog.vue"; import GroupFormDialog from "@/v2/components/Settings/GroupFormDialog.vue"; import InviteLinkDialog from "@/v2/components/Settings/InviteLinkDialog.vue"; import PermissionGroupsSection from "@/v2/components/Settings/PermissionGroupsSection.vue"; +import SmbAccessSection from "@/v2/components/Settings/SmbAccessSection.vue"; import TasksSection from "@/v2/components/Settings/TasksSection.vue"; import UsersSection from "@/v2/components/Settings/UsersSection.vue"; @@ -27,8 +28,8 @@ const route = useRoute(); const router = useRouter(); const auth = storeAuth(); -type Tab = "users" | "groups" | "tokens" | "tasks"; -const validTabs: Tab[] = ["users", "groups", "tokens", "tasks"]; +type Tab = "users" | "groups" | "tokens" | "smb" | "tasks"; +const validTabs: Tab[] = ["users", "groups", "tokens", "smb", "tasks"]; const tab = ref( (validTabs as string[]).includes(route.query.tab as string) @@ -79,6 +80,13 @@ const tabs = computed(() => { icon: "mdi-key-variant", }); } + if (auth.scopes.includes("users.write")) { + items.push({ + id: "smb", + label: t("settings.smb-access"), + icon: "mdi-folder-network-outline", + }); + } if (auth.scopes.includes("tasks.run")) { items.push({ id: "tasks", @@ -105,6 +113,7 @@ const tabModel = computed({ + diff --git a/frontend/src/v2/views/Settings/SmbAccess.vue b/frontend/src/v2/views/Settings/SmbAccess.vue new file mode 100644 index 0000000000..0a68df7690 --- /dev/null +++ b/frontend/src/v2/views/Settings/SmbAccess.vue @@ -0,0 +1,9 @@ + + +