Merge branch 'master' into scheduled-tasks

This commit is contained in:
Georges-Antoine Assi
2023-08-25 19:22:38 -04:00
107 changed files with 4244 additions and 1048 deletions

View File

@@ -298,4 +298,5 @@ Games can be tagged with region, revision or other tags using parenthesis in the
# 🎖 Credits
* Pc icon support - <a href="https://www.flaticon.com/free-icons/keyboard-and-mouse" title="Keyboard and mouse icons">Keyboard and mouse icons created by Flat Icons - Flaticon</a>
* PC icon support - <a href="https://www.flaticon.com/free-icons/keyboard-and-mouse" title="Keyboard and mouse icons">Keyboard and mouse icons created by Flat Icons - Flaticon</a>
* Default user icon - <a target="_blank" href="https://icons8.com/icon/tZuAOUGm9AuS/user-default">User Default</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>

View File

@@ -0,0 +1,47 @@
"""empty message
Revision ID: 583453a8a07f
Revises: 1.8.3
Create Date: 2023-08-10 22:18:24.012779
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "1.9.2"
down_revision = "1.8.3"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(length=255), nullable=True),
sa.Column("hashed_password", sa.String(length=255), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=True),
sa.Column(
"role", sa.Enum("VIEWER", "EDITOR", "ADMIN", name="role"), nullable=True
),
sa.Column("avatar_path", sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_users_username"), ["username"], unique=True
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_users_username"))
op.drop_table("users")
# ### end Alembic commands ###

View File

@@ -1,49 +1,60 @@
import os
import secrets
from dotenv import load_dotenv
from typing import Final
load_dotenv()
# Uvicorn
DEV_PORT: int = int(os.environ.get("VITE_BACKEND_DEV_PORT", "5000"))
DEV_HOST: str = "0.0.0.0"
# UVICORN
DEV_PORT: Final = int(os.environ.get("VITE_BACKEND_DEV_PORT", "5000"))
DEV_HOST: Final = "0.0.0.0"
# PATHS
ROMM_BASE_PATH: str = os.environ.get("ROMM_BASE_PATH", "/romm")
LIBRARY_BASE_PATH: str = f"{ROMM_BASE_PATH}/library"
FRONT_LIBRARY_PATH: str = "/assets/romm/library"
ROMM_USER_CONFIG_PATH: str = f"{ROMM_BASE_PATH}/config.yml"
SQLITE_DB_BASE_PATH: str = f"{ROMM_BASE_PATH}/database"
RESOURCES_BASE_PATH: str = f"{ROMM_BASE_PATH}/resources"
LOGS_BASE_PATH: str = f"{ROMM_BASE_PATH}/logs"
HIGH_PRIO_STRUCTURE_PATH: str = f"{LIBRARY_BASE_PATH}/roms"
ROMM_BASE_PATH: Final = os.environ.get("ROMM_BASE_PATH", "/romm")
LIBRARY_BASE_PATH: Final = f"{ROMM_BASE_PATH}/library"
FRONT_LIBRARY_PATH: Final = "/assets/romm/library"
ROMM_USER_CONFIG_PATH: Final = f"{ROMM_BASE_PATH}/config.yml"
SQLITE_DB_BASE_PATH: Final = f"{ROMM_BASE_PATH}/database"
RESOURCES_BASE_PATH: Final = f"{ROMM_BASE_PATH}/resources"
LOGS_BASE_PATH: Final = f"{ROMM_BASE_PATH}/logs"
HIGH_PRIO_STRUCTURE_PATH: Final = f"{LIBRARY_BASE_PATH}/roms"
# DEFAULT RESOURCES
DEFAULT_URL_COVER_L: str = (
DEFAULT_URL_COVER_L: Final = (
"https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png"
)
DEFAULT_PATH_COVER_L: str = "default/default/cover/big.png"
DEFAULT_URL_COVER_S: str = (
DEFAULT_PATH_COVER_L: Final = "default/default/cover/big.png"
DEFAULT_URL_COVER_S: Final = (
"https://images.igdb.com/igdb/image/upload/t_cover_small/nocover.png"
)
DEFAULT_PATH_COVER_S: str = "default/default/cover/small.png"
DEFAULT_PATH_COVER_S: Final = "default/default/cover/small.png"
# MARIADB
DB_HOST = os.environ.get("DB_HOST", "127.0.0.1")
DB_PORT: int = int(os.environ.get("DB_PORT", 3306))
DB_USER = os.environ.get("DB_USER")
DB_PASSWD = os.environ.get("DB_PASSWD")
DB_NAME = os.environ.get("DB_NAME", "romm")
DB_HOST: Final = os.environ.get("DB_HOST", "127.0.0.1")
DB_PORT: Final = int(os.environ.get("DB_PORT", 3306))
DB_USER: Final = os.environ.get("DB_USER")
DB_PASSWD: Final = os.environ.get("DB_PASSWD")
DB_NAME: Final = os.environ.get("DB_NAME", "romm")
# REDIS
REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost')
REDIS_PORT = os.environ.get('REDIS_PORT', '6379')
ENABLE_EXPERIMENTAL_REDIS: Final = os.environ.get("ENABLE_EXPERIMENTAL_REDIS", "false") == "true"
REDIS_HOST: Final = os.environ.get("REDIS_HOST", "localhost")
REDIS_PORT: Final = os.environ.get("REDIS_PORT", "6379")
# IGDB
CLIENT_ID = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "")
CLIENT_ID: Final = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET: Final = os.environ.get("CLIENT_SECRET", "")
# STEAMGRIDDB
STEAMGRIDDB_API_KEY = os.environ.get("STEAMGRIDDB_API_KEY", "")
STEAMGRIDDB_API_KEY: Final = os.environ.get("STEAMGRIDDB_API_KEY", "")
# DB DRIVERS
ROMM_DB_DRIVER = os.environ.get("ROMM_DB_DRIVER", "sqlite")
ROMM_DB_DRIVER: Final = os.environ.get("ROMM_DB_DRIVER", "sqlite")
# AUTH
ROMM_AUTH_ENABLED: Final = os.environ.get("ROMM_AUTH_ENABLED", "false") == "true"
ROMM_AUTH_USERNAME: Final = os.environ.get("ROMM_AUTH_USERNAME", "admin")
ROMM_AUTH_PASSWORD: Final = os.environ.get("ROMM_AUTH_PASSWORD", "admin")
ROMM_AUTH_SECRET_KEY: Final = os.environ.get(
"ROMM_AUTH_SECRET_KEY", secrets.token_hex(32)
)

View File

@@ -0,0 +1,210 @@
import secrets
from typing import Optional, Annotated
from fastapi import APIRouter, HTTPException, status, Request, Depends, File, UploadFile
from fastapi.security.http import HTTPBasic
from pydantic import BaseModel, BaseConfig
from handler import dbh
from models.user import User, Role
from utils.cache import cache
from utils.auth import authenticate_user, get_password_hash, clear_session
from utils.oauth import protected_route
from utils.fs import build_avatar_path
from config import ROMM_AUTH_ENABLED
from exceptions.credentials_exceptions import credentials_exception, disabled_exception
router = APIRouter()
class UserSchema(BaseModel):
id: int
username: str
enabled: bool
role: Role
oauth_scopes: list[str]
avatar_path: str
class Config(BaseConfig):
orm_mode = True
@router.post("/login")
def login(request: Request, credentials=Depends(HTTPBasic())):
"""Session login endpoint"""
user = authenticate_user(credentials.username, credentials.password)
if not user:
raise credentials_exception
if not user.enabled:
raise disabled_exception
# Generate unique session key and store in cache
request.session["session_id"] = secrets.token_hex(16)
cache.set(f'romm:{request.session["session_id"]}', user.username) # type: ignore[attr-defined]
return {"message": "Successfully logged in"}
@router.post("/logout")
def logout(request: Request):
"""Session logout endpoint"""
# Check if session key already stored in cache
session_id = request.session.get("session_id")
if not session_id:
return {"message": "Already logged out"}
if not request.user.is_authenticated:
return {"message": "Already logged out"}
clear_session(request)
return {"message": "Successfully logged out"}
@protected_route(router.get, "/users", ["users.read"])
def users(request: Request) -> list[UserSchema]:
"""Get all users"""
return dbh.get_users()
@protected_route(router.get, "/users/me", ["me.read"])
def current_user(request: Request) -> UserSchema | None:
"""Get current user"""
return request.user
@protected_route(router.get, "/users/{user_id}", ["users.read"])
def get_user(request: Request, user_id: int) -> UserSchema:
"""Get a specific user"""
user = dbh.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@protected_route(
router.post,
"/users",
["users.write"],
status_code=status.HTTP_201_CREATED,
)
def create_user(
request: Request, username: str, password: str, role: str
) -> UserSchema:
"""Create a new user"""
if not ROMM_AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Cannot create user: ROMM_AUTH_ENABLED is set to False",
)
user = User(
username=username,
hashed_password=get_password_hash(password),
role=Role[role.upper()],
)
return dbh.add_user(user)
class UserUpdateForm:
def __init__(
self,
username: Optional[str] = None,
password: Optional[str] = None,
role: Optional[str] = None,
enabled: Optional[bool] = None,
avatar: Optional[UploadFile] = File(None),
):
self.username = username
self.password = password
self.role = role
self.enabled = enabled
self.avatar = avatar
@protected_route(router.put, "/users/{user_id}", ["users.write"])
def update_user(
request: Request, user_id: int, form_data: Annotated[UserUpdateForm, Depends()]
) -> UserSchema:
"""Update a specific user"""
if not ROMM_AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Cannot update user: ROMM_AUTH_ENABLED is set to False",
)
user = dbh.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
cleaned_data = {}
if form_data.username and form_data.username != user.username:
existing_user = dbh.get_user_by_username(form_data.username.lower())
if existing_user:
raise HTTPException(
status_code=400, detail="Username already in use by another user"
)
cleaned_data["username"] = form_data.username.lower()
if form_data.password:
cleaned_data["hashed_password"] = get_password_hash(form_data.password)
# You can't change your own role
if form_data.role and request.user.id != user_id:
cleaned_data["role"] = Role[form_data.role.upper()] # type: ignore[assignment]
# You can't disable yourself
if form_data.enabled is not None and request.user.id != user_id:
cleaned_data["enabled"] = form_data.enabled # type: ignore[assignment]
if form_data.avatar is not None:
cleaned_data["avatar_path"], avatar_user_path = build_avatar_path(
form_data.avatar.filename, form_data.username
)
file_location = f"{avatar_user_path}/{form_data.avatar.filename}"
with open(file_location, "wb+") as file_object:
file_object.write(form_data.avatar.file.read())
if cleaned_data:
dbh.update_user(user_id, cleaned_data)
# Log out the current user if username or password changed
creds_updated = cleaned_data.get("username") or cleaned_data.get(
"hashed_password"
)
if request.user.id == user_id and creds_updated:
clear_session(request)
return dbh.get_user(user_id)
@protected_route(router.delete, "/users/{user_id}", ["users.write"])
def delete_user(request: Request, user_id: int):
"""Delete a specific user"""
if not ROMM_AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Cannot delete user: ROMM_AUTH_ENABLED is set to False",
)
user = dbh.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# You can't delete the user you're logged in as
if request.user.id == user_id:
raise HTTPException(status_code=400, detail="You cannot delete yourself")
# You can't delete the last admin user
if user.role == Role.ADMIN and len(dbh.get_admin_users()) == 1:
raise HTTPException(
status_code=400, detail="You cannot delete the last admin user"
)
dbh.delete_user(user_id)
return {"message": "User successfully deleted"}

112
backend/endpoints/oauth.py Normal file
View File

@@ -0,0 +1,112 @@
from typing import Annotated, Final
from datetime import timedelta
from fastapi import Depends, APIRouter, HTTPException, status
from utils.auth import authenticate_user
from utils.oauth import (
OAuth2RequestForm,
create_oauth_token,
get_current_active_user_from_bearer_token,
)
ACCESS_TOKEN_EXPIRE_MINUTES: Final = 30
REFRESH_TOKEN_EXPIRE_DAYS: Final = 7
router = APIRouter()
@router.post("/token")
async def token(form_data: Annotated[OAuth2RequestForm, Depends()]):
"""OAuth2 token endpoint"""
# Suppport refreshing access tokens
if form_data.grant_type == "refresh_token":
token = form_data.refresh_token
if not token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token"
)
user, payload = await get_current_active_user_from_bearer_token(token)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
access_token = create_oauth_token(
data={
"sub": user.username,
"scopes": payload.get("scopes"),
"type": "access",
},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {
"access_token": access_token,
"token_type": "bearer",
"expires": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
}
# Authentication via username/password
elif form_data.grant_type == "password":
if not form_data.username or not form_data.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing username or password",
)
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
# TODO: Authentication via client_id/client_secret
elif form_data.grant_type == "client_credentials":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Client credentials are not yet supported",
)
else:
# All other grant types are unsupported
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or unsupported grant type",
)
# Check if user has access to requested scopes
if not set(form_data.scopes).issubset(user.oauth_scopes):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient scope",
)
access_token = create_oauth_token(
data={
"sub": user.username,
"scopes": " ".join(form_data.scopes),
"type": "access",
},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = create_oauth_token(
data={
"sub": user.username,
"scopes": " ".join(form_data.scopes),
"type": "refresh",
},
expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
}

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter
from fastapi import APIRouter, Request
from pydantic import BaseModel, BaseConfig
from handler import dbh
from utils.oauth import protected_route
router = APIRouter()
@@ -22,7 +23,7 @@ class PlatformSchema(BaseModel):
orm_mode = True
@router.get("/platforms", status_code=200)
def platforms() -> list[PlatformSchema]:
@protected_route(router.get, "/platforms", ["platforms.read"])
def platforms(request: Request) -> list[PlatformSchema]:
"""Returns platforms data"""
return dbh.get_platforms()

View File

@@ -6,14 +6,14 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel, BaseConfig
from stat import S_IFREG
from stream_zip import ZIP_64, stream_zip
from stream_zip import ZIP_64, stream_zip # type: ignore[import]
from logger.logger import log
from handler import dbh
from utils import fs, get_file_name_with_no_tags
from utils.exceptions import RomNotFoundError, RomAlreadyExistsException
from models.rom import Rom
from models.platform import Platform
from exceptions.fs_exceptions import RomNotFoundError, RomAlreadyExistsException
from utils.oauth import protected_route
from models import Rom, Platform
from config import LIBRARY_BASE_PATH
from .utils import CustomStreamingResponse
@@ -66,15 +66,16 @@ class RomSchema(BaseModel):
orm_mode = True
@router.get("/platforms/{p_slug}/roms/{id}", status_code=200)
def rom(id: int) -> RomSchema:
@protected_route(router.get, "/platforms/{p_slug}/roms/{id}", ["roms.read"])
def rom(request: Request, id: int) -> RomSchema:
"""Returns one rom data of the desired platform"""
return dbh.get_rom(id)
@router.get("/platforms/{p_slug}/roms/{id}/download", status_code=200)
def download_rom(id: int, files: str):
@protected_route(router.get, "/platforms/{p_slug}/roms/{id}/download", ["roms.read"])
def download_rom(request: Request, id: int, files: str):
"""Downloads a rom or a zip file with multiple roms"""
rom = dbh.get_rom(id)
rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}"
@@ -104,9 +105,13 @@ def download_rom(id: int, files: str):
)
@router.get("/platforms/{p_slug}/roms", status_code=200)
@protected_route(router.get, "/platforms/{p_slug}/roms", ["roms.read"])
def roms(
p_slug: str, size: int = 60, cursor: str = "", search_term: str = ""
request: Request,
p_slug: str,
size: int = 60,
cursor: str = "",
search_term: str = "",
) -> CursorPage[RomSchema]:
"""Returns all roms of the desired platform"""
with dbh.session.begin() as session:
@@ -123,11 +128,11 @@ def roms(
return paginate(session, qq, cursor_params)
@router.patch("/platforms/{p_slug}/roms/{id}", status_code=200)
async def updateRom(req: Request, p_slug: str, id: int) -> dict:
@protected_route(router.patch, "/platforms/{p_slug}/roms/{id}", ["roms.write"])
async def update_rom(request: Request, p_slug: str, id: int) -> dict:
"""Updates rom details"""
data: dict = await req.json()
data: dict = await request.json()
updated_rom: dict = data["updatedRom"]
db_rom: Rom = dbh.get_rom(id)
platform: Platform = dbh.get_platform(p_slug)
@@ -149,14 +154,14 @@ async def updateRom(req: Request, p_slug: str, id: int) -> dict:
overwrite=True,
p_slug=platform.slug,
r_name=updated_rom["file_name_no_tags"],
url_cover=updated_rom["url_cover"],
url_cover=updated_rom.get("url_cover", ""),
)
)
updated_rom.update(
fs.get_screenshots(
p_slug=platform.slug,
r_name=updated_rom["file_name_no_tags"],
url_screenshots=updated_rom["url_screenshots"],
url_screenshots=updated_rom.get("url_screenshots", ""),
),
)
dbh.update_rom(id, updated_rom)
@@ -167,14 +172,15 @@ async def updateRom(req: Request, p_slug: str, id: int) -> dict:
}
@router.delete("/platforms/{p_slug}/roms/{id}", status_code=200)
def delete_rom(p_slug: str, id: int, filesystem: bool = False) -> dict:
"""Detele rom from database [and filesystem]"""
def _delete_single_rom(rom_id: int, p_slug: str, filesystem: bool = False):
rom = dbh.get_rom(rom_id)
if not rom:
error = f"Rom with id {rom_id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
rom: Rom = dbh.get_rom(id)
log.info(f"Deleting {rom.file_name} from database")
dbh.delete_rom(id)
dbh.update_n_roms(p_slug)
dbh.delete_rom(rom_id)
if filesystem:
log.info(f"Deleting {rom.file_name} from filesystem")
@@ -186,4 +192,35 @@ def delete_rom(p_slug: str, id: int, filesystem: bool = False) -> dict:
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
return rom
@protected_route(router.delete, "/platforms/{p_slug}/roms/{id}", ["roms.write"])
def delete_rom(
request: Request, p_slug: str, id: int, filesystem: bool = False
) -> dict:
"""Detele rom from database [and filesystem]"""
rom = _delete_single_rom(id, p_slug, filesystem)
dbh.update_n_roms(p_slug)
return {"msg": f"{rom.file_name} deleted successfully!"}
@protected_route(router.post, "/platforms/{p_slug}/roms/delete", ["roms.write"])
async def mass_delete_roms(
request: Request,
p_slug: str,
filesystem: bool = False,
) -> dict:
"""Detele multiple roms from database [and filesystem]"""
data: dict = await request.json()
roms_ids: list = data["roms"]
for rom_id in roms_ids:
_delete_single_rom(rom_id, p_slug, filesystem)
dbh.update_n_roms(p_slug)
return {"msg": f"{len(roms_ids)} roms deleted successfully!"}

View File

@@ -2,14 +2,19 @@ import emoji
import socketio # type: ignore
from logger.logger import log
from config import ENABLE_EXPERIMENTAL_REDIS
from utils import fs, fastapi
from utils.exceptions import PlatformsNotFoundException, RomsNotFoundException
from exceptions.fs_exceptions import PlatformsNotFoundException, RomsNotFoundException
from handler import dbh
from utils.socket import socket_server
from utils.redis import high_prio_queue, redis_url, redis_connectable
from endpoints.platform import PlatformSchema
from endpoints.rom import RomSchema
async def scan_platforms(paltforms: str, complete_rescan: bool):
async def scan_platforms(
platform_slugs: list[str], complete_rescan: bool, selected_roms: list[str]
):
# Connect to external socketio server
sm = (
socketio.AsyncRedisManager(redis_url, write_only=True)
@@ -25,7 +30,8 @@ async def scan_platforms(paltforms: str, complete_rescan: bool):
await sm.emit("scan:done_ko", e.message)
return
platform_list = paltforms.split(",") if paltforms else fs_platforms
platform_list = [dbh.get_platform(s).fs_slug for s in platform_slugs]
platform_list = platform_list or fs_platforms
for p_slug in platform_list:
try:
# Verify that platform exists
@@ -34,36 +40,33 @@ async def scan_platforms(paltforms: str, complete_rescan: bool):
log.error(e)
continue
new_platform = dbh.add_platform(scanned_platform)
await sm.emit(
"scan:scanning_platform",
{"p_name": scanned_platform.name, "p_slug": scanned_platform.slug},
"scan:scanning_platform", PlatformSchema.from_orm(new_platform).dict()
)
dbh.add_platform(scanned_platform)
# Scanning roms
fs_roms = fs.get_roms(scanned_platform.fs_slug)
for rom in fs_roms:
rom_id = dbh.rom_exists(scanned_platform.slug, rom["file_name"])
if rom_id and not complete_rescan:
for fs_rom in fs_roms:
rom_id = dbh.rom_exists(scanned_platform.slug, fs_rom["file_name"])
if rom_id and rom_id not in selected_roms and not complete_rescan:
continue
scanned_rom = fastapi.scan_rom(scanned_platform, rom)
await sm.emit(
"scan:scanning_rom",
{
"p_slug": scanned_platform.slug,
"p_name": scanned_platform.name,
"file_name": scanned_rom.file_name,
"r_name": scanned_rom.r_name,
"r_igdb_id": scanned_rom.r_igdb_id,
},
)
scanned_rom = fastapi.scan_rom(scanned_platform, fs_rom)
if rom_id:
scanned_rom.id = rom_id
dbh.add_rom(scanned_rom)
rom = dbh.add_rom(scanned_rom)
await sm.emit(
"scan:scanning_rom",
{
"p_name": scanned_platform.name,
**RomSchema.from_orm(rom).dict(),
},
)
dbh.purge_roms(scanned_platform.slug, [rom["file_name"] for rom in fs_roms])
dbh.update_n_roms(scanned_platform.slug)
dbh.purge_platforms(fs_platforms)
@@ -72,14 +75,20 @@ async def scan_platforms(paltforms: str, complete_rescan: bool):
@socket_server.on("scan")
async def scan_handler(_sid: str, platforms: str, complete_rescan: bool = True):
async def scan_handler(_sid: str, options: dict):
"""Scan platforms and roms and write them in database."""
log.info(emoji.emojize(":magnifying_glass_tilted_right: Scanning "))
fs.store_default_resources()
platform_slugs = options.get("platforms", [])
complete_rescan = options.get("rescan", False)
selected_roms = options.get("roms", [])
# Run in worker if redis is available
if redis_connectable:
return high_prio_queue.enqueue(scan_platforms, platforms, complete_rescan)
if redis_connectable and ENABLE_EXPERIMENTAL_REDIS:
return high_prio_queue.enqueue(
scan_platforms, platform_slugs, complete_rescan, selected_roms
)
else:
await scan_platforms(platforms, complete_rescan)
await scan_platforms(platform_slugs, complete_rescan, selected_roms)

View File

@@ -1,19 +1,20 @@
from fastapi import APIRouter, Request
import emoji
from fastapi import APIRouter, Request
from logger.logger import log
from handler import igdbh
from utils.oauth import protected_route
router = APIRouter()
@router.put("/search/roms/igdb", status_code=200)
@protected_route(router.put, "/search/roms/igdb", ["roms.read"])
async def search_rom_igdb(
req: Request, search_term: str = "", search_by: str = ""
request: Request, search_term: str = "", search_by: str = ""
) -> dict:
"""Get all the roms matched from igdb."""
"""Search IGDB for ROMs"""
data: dict = await req.json()
data: dict = await request.json()
rom: dict = data["rom"]
log.info(emoji.emojize(":magnifying_glass_tilted_right: IGDB Searching"))
matched_roms: list = []

View File

View File

@@ -0,0 +1,32 @@
import pytest
from utils.oauth import create_oauth_token
from datetime import timedelta
from handler.tests.conftest import setup_database, clear_database, admin_user, editor_user, viewer_user, platform, rom # noqa
from ..oauth import ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS
@pytest.fixture()
def access_token(admin_user): # noqa
data = {
"sub": admin_user.username,
"scopes": " ".join(admin_user.oauth_scopes),
"type": "access",
}
return create_oauth_token(
data=data, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
@pytest.fixture()
def refresh_token(admin_user): # noqa
data = {
"sub": admin_user.username,
"scopes": " ".join(admin_user.oauth_scopes),
"type": "refresh",
}
return create_oauth_token(
data=data, expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
)

View File

@@ -0,0 +1,11 @@
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_heartbeat():
response = client.get("/heartbeat")
assert response.status_code == 200
assert response.json() == {"ROMM_AUTH_ENABLED": True}

View File

@@ -0,0 +1,105 @@
import base64
import pytest
from fastapi.testclient import TestClient
from main import app
from utils.cache import cache
from models.user import Role
client = TestClient(app)
@pytest.fixture(autouse=True)
def clear_cache():
yield
cache.flushall()
def test_login_logout(admin_user):
response = client.get("/login")
assert response.status_code == 405
basic_auth = base64.b64encode(
"test_admin:test_admin_password".encode("ascii")
).decode("ascii")
response = client.post("/login", headers={"Authorization": f"Basic {basic_auth}"})
assert response.status_code == 200
assert response.cookies.get("session")
assert response.json()["message"] == "Successfully logged in"
response = client.post("/logout")
assert response.status_code == 200
assert response.json()["message"] == "Successfully logged out"
def test_get_all_users(access_token):
response = client.get("/users", headers={"Authorization": f"Bearer {access_token}"})
assert response.status_code == 200
users = response.json()
assert len(users) == 1
assert users[0]["username"] == "test_admin"
def test_get_current_user(access_token):
response = client.get(
"/users/me", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
user = response.json()
assert user["username"] == "test_admin"
def test_get_user(access_token, editor_user):
response = client.get(
f"/users/{editor_user.id}", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
user = response.json()
assert user["username"] == "test_editor"
def test_create_user(access_token):
response = client.post(
"/users",
params={
"username": "new_user",
"password": "new_user_password",
"role": "viewer",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 201
user = response.json()
assert user["username"] == "new_user"
assert user["role"] == "viewer"
def test_update_user(access_token, editor_user):
assert editor_user.role == Role.EDITOR
response = client.put(
f"/users/{editor_user.id}",
params={"username": "editor_user_new_username", "role": "viewer"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
user = response.json()
assert user["role"] == "viewer"
def test_delete_user(access_token, editor_user):
response = client.delete(
f"/users/{editor_user.id}", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
body = response.json()
assert body["message"] == "User successfully deleted"

View File

@@ -0,0 +1,113 @@
from fastapi.testclient import TestClient
from fastapi.exceptions import HTTPException
from main import app
from endpoints.oauth import ACCESS_TOKEN_EXPIRE_MINUTES
from utils.oauth import WRITE_SCOPES
client = TestClient(app)
def test_refreshing_oauth_token(refresh_token):
response = client.post(
"/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
)
assert response.status_code == 200
body = response.json()
assert body["access_token"]
assert body["token_type"] == "bearer"
assert body["expires"] == ACCESS_TOKEN_EXPIRE_MINUTES * 60
def test_refreshing_oauth_token_without_refresh_token():
try:
client.post(
"/token",
data={
"grant_type": "refresh_token",
},
)
except HTTPException as e:
assert e.status_code == 400
assert e.detail == "Missing refresh token"
def test_refreshing_oauth_token_with_invalid_refresh_token():
try:
client.post(
"/token",
data={
"grant_type": "refresh_token",
"refresh_token": "invalid_token",
},
)
except HTTPException as e:
assert e.status_code == 400
assert e.detail == "Invalid refresh token"
def test_auth_via_upass(admin_user):
response = client.post(
"/token",
data={
"grant_type": "password",
"username": "test_admin",
"password": "test_admin_password",
},
)
assert response.status_code == 200
body = response.json()
assert body["access_token"]
assert body["refresh_token"]
assert body["token_type"] == "bearer"
assert body["expires"] == ACCESS_TOKEN_EXPIRE_MINUTES * 60
def test_auth_via_upass_with_invalid_credentials(admin_user):
try:
client.post(
"/token",
data={
"grant_type": "password",
"username": "test_admin",
"password": "a_bad_password",
},
)
except HTTPException as e:
assert e.status_code == 401
assert e.detail == "Invalid username or password"
def test_auth_via_upass_with_excess_scopes(viewer_user):
try:
client.post(
"/token",
data={
"grant_type": "password",
"username": "test_viewer",
"password": "test_viewer_password",
"scopes": WRITE_SCOPES
},
)
except HTTPException as e:
assert e.status_code == 403
assert e.detail == "Insufficient scope"
def test_auth_with_invalid_grant_type():
try:
client.post(
"/token",
data={
"grant_type": "invalid_type",
},
)
except HTTPException as e:
assert e.status_code == 400
assert e.detail == "Invalid or unsupported grant type"

View File

@@ -0,0 +1,18 @@
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_get_platforms(access_token, platform):
response = client.get("/platforms")
assert response.status_code == 403
response = client.get(
"/platforms", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200
platforms = response.json()
assert len(platforms) == 1

View File

@@ -0,0 +1,55 @@
from fastapi.testclient import TestClient
from unittest.mock import patch
from main import app
client = TestClient(app)
def test_get_rom(access_token, rom):
response = client.get(
f"/platforms/{rom.p_slug}/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
body = response.json()
assert body["id"] == rom.id
def test_get_all_roms(access_token, rom):
response = client.get(
f"/platforms/{rom.p_slug}/roms",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) == 1
assert body["items"][0]["id"] == rom.id
@patch("utils.fs.rename_rom")
def test_update_rom(rename_rom, access_token, rom):
response = client.patch(
f"/platforms/{rom.p_slug}/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
json={"updatedRom": {"file_name": "new_file_name"}},
)
assert response.status_code == 200
body = response.json()
assert body["rom"]["file_name"] == "new_file_name"
assert rename_rom.called
def test_delete_roms(access_token, rom):
response = client.delete(
f"/platforms/{rom.p_slug}/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
body = response.json()
assert body["msg"] == f"{rom.file_name} deleted successfully!"

View File

View File

@@ -0,0 +1,17 @@
from fastapi import HTTPException, status
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
authentication_scheme_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
)
disabled_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Disabled user",
)

View File

@@ -7,8 +7,7 @@ from sqlalchemy.exc import ProgrammingError
from logger.logger import log
from config.config_loader import ConfigLoader
from models.platform import Platform
from models.rom import Rom
from models import Platform, Rom, User, Role
class DBHandler:
@@ -35,7 +34,7 @@ class DBHandler:
def add_platform(self, platform: Platform):
try:
with self.session.begin() as session:
session.merge(platform)
return session.merge(platform)
except ProgrammingError as e:
self.raise_error(e)
@@ -68,7 +67,7 @@ class DBHandler:
def add_rom(self, rom: Rom):
try:
with self.session.begin() as session:
session.merge(rom)
return session.merge(rom)
except ProgrammingError as e:
self.raise_error(e)
@@ -136,3 +135,59 @@ class DBHandler:
return rom.id if rom else 0
except ProgrammingError as e:
self.raise_error(e)
# ========= Users =========
def add_user(self, user: User):
try:
with self.session.begin() as session:
return session.merge(user)
except ProgrammingError as e:
self.raise_error(e)
def get_user_by_username(self, username: str):
try:
with self.session.begin() as session:
return session.scalars(
select(User).filter_by(username=username)
).first()
except ProgrammingError as e:
self.raise_error(e)
def get_user(self, id: int):
try:
with self.session.begin() as session:
return session.get(User, id)
except ProgrammingError as e:
self.raise_error(e)
def update_user(self, id: int, data: dict):
try:
with self.session.begin() as session:
session.query(User).filter(User.id == id).update(
data, synchronize_session="evaluate"
)
except ProgrammingError as e:
self.raise_error(e)
def delete_user(self, id: int):
try:
with self.session.begin() as session:
session.query(User).filter(User.id == id).delete(
synchronize_session="evaluate"
)
except ProgrammingError as e:
self.raise_error(e)
def get_users(self):
try:
with self.session.begin() as session:
return session.scalars(select(User)).all()
except ProgrammingError as e:
self.raise_error(e)
def get_admin_users(self):
try:
with self.session.begin() as session:
return session.scalars(select(User).filter_by(role=Role.ADMIN)).all()
except ProgrammingError as e:
self.raise_error(e)

View File

@@ -8,25 +8,26 @@ import os
import json
from unidecode import unidecode as uc
from requests.exceptions import HTTPError, Timeout
from typing import Final
from config import CLIENT_ID, CLIENT_SECRET
from utils import get_file_name_with_no_tags as get_search_term
from logger.logger import log
from utils.cache import cache
MAIN_GAME_CATEGORY = 0
EXPANDED_GAME_CATEGORY = 10
N_SCREENSHOTS = 5
PS2_IGDB_ID = 8
SWITCH_IGDB_ID = 130
MAIN_GAME_CATEGORY: Final = 0
EXPANDED_GAME_CATEGORY: Final = 10
N_SCREENSHOTS: Final = 5
PS2_IGDB_ID: Final = 8
SWITCH_IGDB_ID: Final = 130
ps2_opl_regex = r"^([A-Z]{4}_\d{3}\.\d{2})\..*$"
ps2_opl_index_file = os.path.join(
PS2_OPL_REGEX: Final = r"^([A-Z]{4}_\d{3}\.\d{2})\..*$"
PS2_OPL_INDEX_FILE: Final = os.path.join(
os.path.dirname(__file__), "fixtures", "ps2_opl_index.json"
)
switch_titledb_regex = r"^(70[0-9]{12})$"
switch_titledb_index_file = os.path.join(
SWITCH_TITLEDB_REGEX: Final = r"^(70[0-9]{12})$"
SWITCH_TITLEDB_INDEX_FILE: Final = os.path.join(
os.path.dirname(__file__), "fixtures", "switch_titledb.json"
)
@@ -129,23 +130,23 @@ class IGDBHandler:
def get_rom(self, file_name: str, p_igdb_id: int):
search_term = get_search_term(file_name)
# Patch support for PS2 OPL filename format
match = re.match(ps2_opl_regex, search_term)
# Patch support for PS2 OPL flename format
match = re.match(PS2_OPL_REGEX, search_term)
if p_igdb_id == PS2_IGDB_ID and match:
serial_code = match.group(1)
with open(ps2_opl_index_file, "r") as index_json:
with open(PS2_OPL_INDEX_FILE, "r") as index_json:
opl_index = json.loads(index_json.read())
index_entry = opl_index.get(serial_code, None)
if index_entry:
search_term = index_entry["Name"] # type: ignore
# Patch support for switch titleID filename format
match = re.match(switch_titledb_regex, search_term)
match = re.match(SWITCH_TITLEDB_REGEX, search_term)
if p_igdb_id == SWITCH_IGDB_ID and match:
title_id = match.group(1)
with open(switch_titledb_index_file, "r") as index_json:
with open(SWITCH_TITLEDB_INDEX_FILE, "r") as index_json:
titledb_index = json.loads(index_json.read())
index_entry = titledb_index.get(title_id, None)
if index_entry:
@@ -246,8 +247,8 @@ class TwitchAuth:
sys.exit(2)
# Set token in redis to expire in <expires_in> seconds
cache.set("twitch_token", token, ex=expires_in - 10) # type: ignore
cache.set("twitch_token_expires_at", time.time() + expires_in - 10) # type: ignore
cache.set("romm:twitch_token", token, ex=expires_in - 10) # type: ignore[attr-defined]
cache.set("romm:twitch_token_expires_at", time.time() + expires_in - 10) # type: ignore[attr-defined]
log.info("Twitch token fetched!")
@@ -259,8 +260,8 @@ class TwitchAuth:
return "test_token"
# Fetch the token cache
token = cache.get("twitch_token") # type: ignore
token_expires_at = cache.get("twitch_token_expires_at") # type: ignore
token = cache.get("romm:twitch_token") # type: ignore[attr-defined]
token_expires_at = cache.get("romm:twitch_token_expires_at") # type: ignore[attr-defined]
if not token or time.time() > float(token_expires_at or 0):
log.warning("Twitch token invalid: fetching a new one...")

View File

@@ -4,8 +4,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from config.config_loader import ConfigLoader
from models.platform import Platform
from models.rom import Rom
from models import Platform, Rom, User
from models.user import Role
from utils.auth import get_password_hash
from .. import dbh
engine = create_engine(ConfigLoader.get_db_engine(), pool_pre_ping=True)
session = sessionmaker(bind=engine, expire_on_commit=False)
@@ -21,6 +23,7 @@ def clear_database():
with session.begin() as s:
s.query(Platform).delete(synchronize_session="evaluate")
s.query(Rom).delete(synchronize_session="evaluate")
s.query(User).delete(synchronize_session="evaluate")
@pytest.fixture
@@ -28,9 +31,7 @@ def platform():
platform = Platform(
name="test_platform", slug="test_platform_slug", fs_slug="test_platform"
)
with session.begin() as s:
s.merge(platform)
return platform
return dbh.add_platform(platform)
@pytest.fixture
@@ -44,6 +45,34 @@ def rom(platform):
file_name_no_tags="test_rom",
file_path="test_platform_slug/roms",
)
with session.begin() as s:
s.merge(rom)
return rom
return dbh.add_rom(rom)
@pytest.fixture
def admin_user():
user = User(
username="test_admin",
hashed_password=get_password_hash("test_admin_password"),
role=Role.ADMIN,
)
return dbh.add_user(user)
@pytest.fixture
def editor_user():
user = User(
username="test_editor",
hashed_password=get_password_hash("test_editor_password"),
role=Role.EDITOR,
)
return dbh.add_user(user)
@pytest.fixture
def viewer_user():
user = User(
username="test_viewer",
hashed_password=get_password_hash("test_viewer_password"),
role=Role.VIEWER,
)
return dbh.add_user(user)

View File

@@ -1,6 +1,9 @@
from sqlalchemy.exc import IntegrityError
from handler.db_handler import DBHandler
from models.platform import Platform
from models.rom import Rom
from models import Platform, Rom, User
from models.user import Role
from utils.auth import get_password_hash
dbh = DBHandler()
@@ -62,3 +65,41 @@ def test_utils(rom):
with dbh.session.begin() as session:
roms = session.scalars(dbh.get_roms("test_platform_slug")).all()
assert dbh.rom_exists("test_platform_slug", "test_rom") == roms[0].id
def test_users(admin_user):
dbh.add_user(
User(
username="new_user",
hashed_password=get_password_hash("new_password"),
)
)
all_users = dbh.get_users()
assert len(all_users) == 2
new_user = dbh.get_user_by_username("new_user")
assert new_user.username == "new_user"
assert new_user.role == Role.VIEWER
assert new_user.enabled
dbh.update_user(new_user.id, {"role": Role.EDITOR})
new_user = dbh.get_user(new_user.id)
assert new_user.role == Role.EDITOR
dbh.delete_user(new_user.id)
all_users = dbh.get_users()
assert len(all_users) == 1
try:
new_user = dbh.add_user(
User(
username="test_admin",
hashed_password=get_password_hash("new_password"),
role=Role.ADMIN,
)
)
except IntegrityError as e:
assert "Duplicate entry 'test_admin' for key" in str(e)

View File

@@ -1,14 +1,25 @@
import uvicorn
import alembic.config
import re
import sys
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi_pagination import add_pagination
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.sessions import SessionMiddleware
from config import DEV_PORT, DEV_HOST
from endpoints import search, platform, rom, scan # noqa
from config import DEV_PORT, DEV_HOST, ROMM_AUTH_SECRET_KEY, ROMM_AUTH_ENABLED
from endpoints import search, platform, rom, identity, oauth, scan # noqa
from handler import dbh
from utils.socket import socket_app
from utils.auth import (
HybridAuthBackend,
CustomCSRFMiddleware,
create_default_admin_user,
)
app = FastAPI(title="RomM API", version="0.1.0")
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -16,18 +27,52 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(search.router)
if ROMM_AUTH_ENABLED and "pytest" not in sys.modules:
# CSRF protection (except endpoints listed in exempt_urls)
app.add_middleware(
CustomCSRFMiddleware,
secret=ROMM_AUTH_SECRET_KEY,
exempt_urls=[re.compile(r"^/token.*"), re.compile(r"^/ws")],
)
# Handles both basic and oauth authentication
app.add_middleware(
AuthenticationMiddleware,
backend=HybridAuthBackend(),
)
# Enables support for sessions on requests
app.add_middleware(
SessionMiddleware,
secret_key=ROMM_AUTH_SECRET_KEY,
same_site="strict",
https_only=False,
)
app.include_router(oauth.router)
app.include_router(identity.router)
app.include_router(platform.router)
app.include_router(rom.router)
app.include_router(search.router)
add_pagination(app)
app.mount("/ws", socket_app)
# Endpoint to set the CSRF token in cache
@app.get("/heartbeat")
def heartbeat():
return {"ROMM_AUTH_ENABLED": ROMM_AUTH_ENABLED}
@app.on_event("startup")
def startup() -> None:
"""Startup application."""
pass
# Create default admin user if no admin user exists
if len(dbh.get_admin_users()) == 0:
create_default_admin_user()
if __name__ == "__main__":

View File

@@ -0,0 +1,3 @@
from .platform import Platform # noqa[401]
from .rom import Rom # noqa[401]
from .user import User, Role # noqa[401]

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Column, String, Integer
from config import DEFAULT_PATH_COVER_S
from models.base import BaseModel
from .base import BaseModel
class Platform(BaseModel):

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Integer, Column, String, Text, Boolean, Float, JSON
from config import DEFAULT_PATH_COVER_S, DEFAULT_PATH_COVER_L, FRONT_LIBRARY_PATH
from models.base import BaseModel
from .base import BaseModel
class Rom(BaseModel):
__tablename__ = "roms"

View File

@@ -1 +1 @@
from handler.tests.conftest import rom, platform
from handler.tests.conftest import setup_database, clear_database, rom, platform, admin_user, editor_user, viewer_user # noqa

View File

@@ -1,5 +1,3 @@
from models.rom import Rom
def test_rom(rom):
assert rom.file_path == "test_platform_slug/roms"
assert rom.full_path == "test_platform_slug/roms/test_rom"

View File

@@ -0,0 +1,10 @@
from utils.oauth import DEFAULT_SCOPES, WRITE_SCOPES, FULL_SCOPES
def test_admin(admin_user):
admin_user.oauth_scopes == FULL_SCOPES
def test_editor(editor_user):
editor_user.oauth_scopes == WRITE_SCOPES
def test_user(viewer_user):
viewer_user.oauth_scopes == DEFAULT_SCOPES

32
backend/models/user.py Normal file
View File

@@ -0,0 +1,32 @@
import enum
from sqlalchemy import Column, String, Boolean, Integer, Enum
from starlette.authentication import SimpleUser
from .base import BaseModel
from utils.oauth import DEFAULT_SCOPES, WRITE_SCOPES, FULL_SCOPES
class Role(enum.Enum):
VIEWER = "viewer"
EDITOR = "editor"
ADMIN = "admin"
class User(BaseModel, SimpleUser):
__tablename__ = "users"
id = Column(Integer(), primary_key=True, autoincrement=True)
username: str = Column(String(length=255), unique=True, index=True)
hashed_password: str = Column(String(length=255))
enabled: bool = Column(Boolean(), default=True)
role: Role = Column(Enum(Role), default=Role.VIEWER)
avatar_path: str = Column(String(length=255), default="")
@property
def oauth_scopes(self):
if self.role == Role.ADMIN:
return FULL_SCOPES
if self.role == Role.EDITOR:
return WRITE_SCOPES
return DEFAULT_SCOPES

154
backend/utils/auth.py Normal file
View File

@@ -0,0 +1,154 @@
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, status, Request
from fastapi.security.http import HTTPBasic
from passlib.context import CryptContext
from starlette.requests import HTTPConnection
from starlette_csrf.middleware import CSRFMiddleware
from starlette.types import Receive, Scope, Send
from starlette.authentication import (
AuthCredentials,
AuthenticationBackend,
)
from handler import dbh
from utils.cache import cache
from models.user import User, Role
from config import (
ROMM_AUTH_ENABLED,
ROMM_AUTH_USERNAME,
ROMM_AUTH_PASSWORD,
)
from .oauth import (
FULL_SCOPES,
get_current_active_user_from_bearer_token,
)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def authenticate_user(username: str, password: str):
user = dbh.get_user_by_username(username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def clear_session(req: HTTPConnection | Request):
session_id = req.session.get("session_id")
if session_id:
cache.delete(f"romm:{session_id}") # type: ignore[attr-defined]
req.session["session_id"] = None
async def get_current_active_user_from_session(conn: HTTPConnection):
# Check if session key already stored in cache
session_id = conn.session.get("session_id")
if not session_id:
return None
username = cache.get(f"romm:{session_id}") # type: ignore[attr-defined]
if not username:
return None
# Key exists therefore user is probably authenticated
user = dbh.get_user_by_username(username)
if user is None:
clear_session(conn)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not found",
)
if not user.enabled:
clear_session(conn)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return user
def create_default_admin_user():
if not ROMM_AUTH_ENABLED:
return
try:
dbh.add_user(
User(
username=ROMM_AUTH_USERNAME,
hashed_password=get_password_hash(ROMM_AUTH_PASSWORD),
role=Role.ADMIN,
)
)
except IntegrityError:
pass
class HybridAuthBackend(AuthenticationBackend):
async def authenticate(self, conn: HTTPConnection):
if not ROMM_AUTH_ENABLED:
return (AuthCredentials(FULL_SCOPES), None)
# Check if session key already stored in cache
user = await get_current_active_user_from_session(conn)
if user:
return (AuthCredentials(user.oauth_scopes), user)
# Check if Authorization header exists
if "Authorization" not in conn.headers:
return (AuthCredentials([]), None)
scheme, token = conn.headers["Authorization"].split()
# Check if basic auth header is valid
if scheme.lower() == "basic":
credentials = await HTTPBasic().__call__(conn) # type: ignore[arg-type]
if not credentials:
return (AuthCredentials([]), None)
user = authenticate_user(credentials.username, credentials.password)
if user is None:
return (AuthCredentials([]), None)
return (AuthCredentials(user.oauth_scopes), user)
# Check if bearer auth header is valid
if scheme.lower() == "bearer":
user, payload = await get_current_active_user_from_bearer_token(token)
# Only access tokens can request resources
if payload.get("type") != "access":
return (AuthCredentials([]), None)
# Only grant access to resources with overlapping scopes
token_scopes = set(list(payload.get("scopes").split(" ")))
overlapping_scopes = list(token_scopes & set(user.oauth_scopes))
return (AuthCredentials(overlapping_scopes), user)
return (AuthCredentials([]), None)
class CustomCSRFMiddleware(CSRFMiddleware):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)

View File

@@ -4,8 +4,7 @@ from typing import Any
from handler import igdbh
from utils import fs, parse_tags, get_file_extension, get_file_name_with_no_tags
from config.config_loader import config
from models.platform import Platform
from models.rom import Rom
from models import Platform, Rom
from logger.logger import log

View File

@@ -14,7 +14,7 @@ from config import (
DEFAULT_PATH_COVER_S,
)
from config.config_loader import config
from utils.exceptions import (
from exceptions.fs_exceptions import (
PlatformsNotFoundException,
RomsNotFoundException,
RomNotFoundError,
@@ -300,3 +300,10 @@ def remove_rom(p_slug: str, file_name: str):
shutil.rmtree(f"{LIBRARY_BASE_PATH}/{rom_path}/{file_name}")
except FileNotFoundError as exc:
raise RomNotFoundError(file_name, p_slug) from exc
# ========= Users utils =========
def build_avatar_path(avatar_path, username):
avatar_user_path = f"{RESOURCES_BASE_PATH}/users/{username}"
Path(avatar_user_path).mkdir(parents=True, exist_ok=True)
return f"users/{username}/{avatar_path}", avatar_user_path

132
backend/utils/oauth.py Normal file
View File

@@ -0,0 +1,132 @@
from datetime import datetime, timedelta
from typing import Optional, Final, Any
from jose import JWTError, jwt
from fastapi import HTTPException, status, Security
from fastapi.param_functions import Form
from fastapi.security.oauth2 import OAuth2PasswordBearer
from fastapi.security.http import HTTPBasic
from fastapi.types import DecoratedCallable
from starlette.authentication import requires
from config import ROMM_AUTH_SECRET_KEY
ALGORITHM: Final = "HS256"
DEFAULT_OAUTH_TOKEN_EXPIRY: Final = 15
DEFAULT_SCOPES_MAP: Final = {
"me.read": "View your profile",
"me.write": "Modify your profile",
"roms.read": "View ROMs",
"platforms.read": "View platforms",
}
WRITE_SCOPES_MAP: Final = {
"roms.write": "Modify ROMs",
"platforms.write": "Modify platforms",
}
FULL_SCOPES_MAP: Final = {
"users.read": "View users",
"users.write": "Modify users",
}
DEFAULT_SCOPES: Final = list(DEFAULT_SCOPES_MAP.keys())
WRITE_SCOPES: Final = DEFAULT_SCOPES + list(WRITE_SCOPES_MAP.keys())
FULL_SCOPES: Final = WRITE_SCOPES + list(FULL_SCOPES_MAP.keys())
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def create_oauth_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=DEFAULT_OAUTH_TOKEN_EXPIRY)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, ROMM_AUTH_SECRET_KEY, algorithm=ALGORITHM)
async def get_current_active_user_from_bearer_token(token: str):
from handler import dbh
try:
payload = jwt.decode(token, ROMM_AUTH_SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise credentials_exception
username = payload.get("sub")
if username is None:
raise credentials_exception
user = dbh.get_user_by_username(username)
if user is None:
raise credentials_exception
if not user.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user"
)
return user, payload
class OAuth2RequestForm:
def __init__(
self,
grant_type: str = Form(default="password"),
scope: str = Form(default=""),
username: Optional[str] = Form(default=None),
password: Optional[str] = Form(default=None),
client_id: Optional[str] = Form(default=None),
client_secret: Optional[str] = Form(default=None),
refresh_token: Optional[str] = Form(default=None),
):
self.grant_type = grant_type
self.scopes = scope.split()
self.username = username
self.password = password
self.client_id = client_id
self.client_secret = client_secret
self.refresh_token = refresh_token
oauth2_password_bearer = OAuth2PasswordBearer(
tokenUrl="/token",
auto_error=False,
scopes={
**DEFAULT_SCOPES_MAP,
**WRITE_SCOPES_MAP,
**FULL_SCOPES_MAP,
},
)
def protected_route(
method: Any,
path: str,
scopes: list[str] = [],
**kwargs,
):
def decorator(func: DecoratedCallable):
fn = requires(scopes)(func)
return method(
path,
dependencies=[
Security(
dependency=oauth2_password_bearer,
scopes=scopes,
),
Security(dependency=HTTPBasic(auto_error=False)),
],
**kwargs,
)(fn)
return decorator

View File

@@ -0,0 +1 @@
from handler.tests.conftest import setup_database, clear_database, admin_user, editor_user, viewer_user, clear_database # noqa

View File

@@ -0,0 +1,275 @@
import pytest
from base64 import b64encode
from fastapi.exceptions import HTTPException
from models import User
from handler import dbh
from ..auth import (
verify_password,
get_password_hash,
authenticate_user,
get_current_active_user_from_session,
create_default_admin_user,
HybridAuthBackend,
)
from ..oauth import WRITE_SCOPES, create_oauth_token
from ..cache import cache
def test_verify_password():
assert verify_password("password", get_password_hash("password"))
assert not verify_password("password", get_password_hash("notpassword"))
def test_authenticate_user(admin_user):
current_user = authenticate_user("test_admin", "test_admin_password")
assert current_user
assert current_user.id == admin_user.id
async def test_get_current_active_user_from_session(editor_user):
session_id = "test_session_id"
cache.set(f"romm:{session_id}", editor_user.username)
class MockConnection:
def __init__(self):
self.session = {"session_id": session_id}
conn = MockConnection()
current_user = await get_current_active_user_from_session(conn)
assert current_user
assert isinstance(current_user, User)
assert current_user.id == editor_user.id
async def test_get_current_active_user_from_session_bad_session_key(editor_user):
cache.set("romm:test_session_id", editor_user.username)
class MockConnection:
def __init__(self):
self.session = {"session_id": "not_real_test_session_id"}
self.headers = {}
conn = MockConnection()
current_user = await get_current_active_user_from_session(conn)
assert not current_user
async def test_get_current_active_user_from_session_bad_username(editor_user):
session_id = "test_session_id"
cache.set(f"romm:{session_id}", "not_real_username")
class MockConnection:
def __init__(self):
self.session = {"session_id": session_id}
self.headers = {}
conn = MockConnection()
try:
await get_current_active_user_from_session(conn)
except HTTPException as e:
assert e.status_code == 403
assert e.detail == "User not found"
async def test_get_current_active_user_from_session_disabled_user(editor_user):
session_id = "test_session_id"
cache.set(f"romm:{session_id}", editor_user.username)
class MockConnection:
def __init__(self):
self.session = {"session_id": session_id}
self.headers = {}
conn = MockConnection()
dbh.update_user(editor_user.id, {"enabled": False})
try:
await get_current_active_user_from_session(conn)
except HTTPException as e:
assert e.status_code == 403
assert e.detail == "Inactive user"
def test_create_default_admin_user():
create_default_admin_user()
user = dbh.get_user_by_username("test_admin")
assert user.username == "test_admin"
assert verify_password("test_admin_password", user.hashed_password)
users = dbh.get_users()
assert len(users) == 1
create_default_admin_user()
users = dbh.get_users()
assert len(users) == 1
async def test_hybrid_auth_backend_session(editor_user):
session_id = "test_session_id"
cache.set(f"romm:{session_id}", editor_user.username)
class MockConnection:
def __init__(self):
self.session = {"session_id": session_id}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert user.id == editor_user.id
assert creds.scopes == editor_user.oauth_scopes
assert creds.scopes == WRITE_SCOPES
async def test_hybrid_auth_backend_empty_session_and_headers(editor_user):
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert not user
assert creds.scopes == []
async def test_hybrid_auth_backend_bearer_auth_header(editor_user):
access_token = create_oauth_token(
data={
"sub": editor_user.username,
"scopes": " ".join(editor_user.oauth_scopes),
"type": "access",
},
)
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": f"Bearer {access_token}"}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert user.id == editor_user.id
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
async def test_hybrid_auth_backend_bearer_invalid_token(editor_user):
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": "Bearer invalid_token"}
backend = HybridAuthBackend()
conn = MockConnection()
with pytest.raises(HTTPException):
await backend.authenticate(conn)
async def test_hybrid_auth_backend_basic_auth_header(editor_user):
token = b64encode("test_editor:test_editor_password".encode()).decode()
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": f"Basic {token}"}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert user.id == editor_user.id
assert creds.scopes == WRITE_SCOPES
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
async def test_hybrid_auth_backend_basic_auth_header_unencoded(editor_user):
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": "Basic test_editor:test_editor_password"}
backend = HybridAuthBackend()
conn = MockConnection()
with pytest.raises(HTTPException):
await backend.authenticate(conn)
async def test_hybrid_auth_backend_invalid_scheme():
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": "Some invalid_scheme"}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert not user
assert creds.scopes == []
async def test_hybrid_auth_backend_with_refresh_token(editor_user):
refresh_token = create_oauth_token(
data={
"sub": editor_user.username,
"scopes": " ".join(editor_user.oauth_scopes),
"type": "refresh",
},
)
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": f"Bearer {refresh_token}"}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert not user
assert creds.scopes == []
async def test_hybrid_auth_backend_scope_subset(editor_user):
scopes = editor_user.oauth_scopes[:3]
access_token = create_oauth_token(
data={
"sub": editor_user.username,
"scopes": " ".join(scopes),
"type": "access",
},
)
class MockConnection:
def __init__(self):
self.session = {}
self.headers = {"Authorization": f"Bearer {access_token}"}
backend = HybridAuthBackend()
conn = MockConnection()
creds, user = await backend.authenticate(conn)
assert user.id == editor_user.id
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
assert set(creds.scopes).issubset(scopes)

View File

@@ -1,9 +1,8 @@
import pytest
from utils.fastapi import scan_platform, scan_rom
from utils.exceptions import RomsNotFoundException
from models.platform import Platform
from models.rom import Rom
from ..fastapi import scan_platform, scan_rom
from exceptions.fs_exceptions import RomsNotFoundException
from models import Platform, Rom
@pytest.mark.vcr()

View File

@@ -1,6 +1,6 @@
import pytest
from utils.fs import (
from ..fs import (
get_cover,
get_platforms,
get_roms_structure,

View File

@@ -0,0 +1,74 @@
import pytest
from fastapi import APIRouter, Request
from fastapi.exceptions import HTTPException
from handler import dbh
from ..oauth import (
create_oauth_token,
get_current_active_user_from_bearer_token,
protected_route,
)
def test_create_oauth_token():
token = create_oauth_token({"sub": "test_user"})
assert isinstance(token, str)
async def test_get_current_active_user_from_bearer_token(admin_user):
token = create_oauth_token(
{
"sub": admin_user.username,
"scopes": " ".join(admin_user.oauth_scopes),
"type": "access",
},
)
user, payload = await get_current_active_user_from_bearer_token(token)
assert user.id == admin_user.id
assert payload["sub"] == admin_user.username
assert set(payload["scopes"].split()).issubset(admin_user.oauth_scopes)
assert payload["type"] == "access"
async def test_get_current_active_user_from_bearer_token_invalid_token():
with pytest.raises(HTTPException):
await get_current_active_user_from_bearer_token("invalid_token")
async def test_get_current_active_user_from_bearer_token_invalid_user():
token = create_oauth_token({"sub": "invalid_user"})
with pytest.raises(HTTPException):
await get_current_active_user_from_bearer_token(token)
async def test_get_current_active_user_from_bearer_token_disabled_user(admin_user):
token = create_oauth_token(
{
"sub": admin_user.username,
"scopes": " ".join(admin_user.oauth_scopes),
"type": "access",
},
)
dbh.update_user(admin_user.id, {"enabled": False})
try:
await get_current_active_user_from_bearer_token(token)
except HTTPException as e:
assert e.status_code == 401
assert e.detail == "Inactive user"
def test_protected_route():
router = APIRouter()
@protected_route(router.get, "/test")
def test_route(request: Request):
return {"test": "test"}
req = Request({"type": "http", "method": "GET", "url": "/test"})
assert test_route(req) == {"test": "test"}

View File

@@ -1,13 +1,14 @@
import sys
from rq import Worker, Queue, Connection
from config import ENABLE_EXPERIMENTAL_REDIS
from utils.redis import redis_client, redis_connectable
listen = ["high", "default", "low"]
if __name__ == "__main__":
# Exit if Redis is not connectable
if not redis_connectable:
if not redis_connectable or not ENABLE_EXPERIMENTAL_REDIS:
sys.exit(0)
with Connection(redis_client):

View File

@@ -12,9 +12,9 @@ services:
ports:
- $DB_PORT:3306
# redis:
# image: redis:alpine
# container_name: redis
# restart: unless-stopped
# ports:
# - ${REDIS_PORT}:6379
redis:
image: redis:alpine
container_name: redis
restart: unless-stopped
ports:
- ${REDIS_PORT}:6379

View File

@@ -9,6 +9,7 @@ RUN npm run build
FROM ubuntu/nginx:1.18-22.04_edge as production-stage
ARG WEBSERVER_FOLDER=/var/www/html
COPY --from=front-build-stage /front/dist ${WEBSERVER_FOLDER}
COPY ./frontend/assets/default_avatar.png ${WEBSERVER_FOLDER}/assets/
COPY ./frontend/assets/platforms ${WEBSERVER_FOLDER}/assets/platforms
RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \
ln -s /romm/library ${WEBSERVER_FOLDER}/assets/romm/library && \

View File

@@ -1,6 +1,6 @@
#!/bin/bash
VERSION=$(cat .romm-version)
VERSION="2.0.0"
branch_name="$(git symbolic-ref HEAD 2>/dev/null)"
branch_name=${branch_name##refs/heads/}
docker build -t zurdi15/romm:local-${VERSION}-${branch_name} . --file ./docker/Dockerfile

View File

@@ -7,7 +7,7 @@
/init_front &
# Start rq worker
# /init_worker &
/init_worker &
# Wait for any process to exit
wait -n

View File

@@ -1,4 +1,4 @@
#!/bin/bash
cd /back
nc -z ${REDIS_HOST:-localhost} ${REDIS_PORT:-6379} && rq worker high default low --logging_level WARN
[[ ${ENABLE_EXPERIMENTAL_REDIS} == "true" ]] && rq worker high default low --logging_level WARN || sleep infinity

View File

@@ -42,10 +42,15 @@ http {
proxy_redirect off;
}
# OpenAPI for swagger and redoc
location /openapi.json {
proxy_pass http://localhost:5000;
}
# Backend api calls
location /api {
rewrite /api/(.*) /$1 break;
proxy_pass http://localhost:5000/;
proxy_pass http://localhost:5000;
}
location /ws {
proxy_pass http://localhost:5000;
@@ -54,4 +59,4 @@ http {
proxy_set_header Connection "upgrade";
}
}
}
}

View File

@@ -1,11 +1,11 @@
ROMM_BASE_PATH=/path/to/romm_mock
VITE_BACKEND_DEV_PORT=5000
# IGDB auth
# IGDB credentials
CLIENT_ID=
CLIENT_SECRET=
# STEAMGRIDDB API KEY
# STEAMGRIDDB API key
STEAMGRIDDB_API_KEY=
# Database driver (mariadb, sqlite)
@@ -22,3 +22,9 @@ DB_ROOT_PASSWD=
# Redis config (optional)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# Authentication
ROMM_AUTH_SECRET_KEY=
ROMM_AUTH_ENABLED=true
ROMM_AUTH_USERNAME=admin
ROMM_AUTH_PASSWORD=admin

View File

@@ -1,4 +1,4 @@
version: '3'
version: "3"
volumes:
mysql_data:
services:
@@ -6,30 +6,40 @@ services:
image: zurdi15/romm:latest
container_name: romm
environment:
- ROMM_DB_DRIVER=mariadb # This variable can be set as: mariadb | sqlite. If it is not defined, sqlite will be the database by default
- DB_HOST=mariadb # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
- DB_PORT=3306 # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
- DB_USER=romm-user # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
- DB_NAME=romm # [Optional] Only needed if ROMM_DB_DRIVER=mariadb. Can be optionally changed, and should match the MYSQL_DATABASE value in the mariadb container.
- DB_PASSWD=<database password> # [Optional] Only needed if ROMM_DB_DRIVER=mariadb
# - REDIS_HOST=redis # [Optional][Experimental] Redis enables workers to run long tasks, like full library rescans, without having to wait on the main thread. Can be used with an already existent redis container
# - REDIS_PORT=6379 # [Optional][Experimental]
- ROMM_DB_DRIVER=mariadb # mariadb | sqlite (default: sqlite)
# [Optional] Only required if using MariaDB as the database
- DB_HOST=mariadb
- DB_PORT=3306
- DB_USER=romm-user
- DB_NAME=romm # Should match the MYSQL_DATABASE value in the mariadb container
- DB_PASSWD=<database password>
# [Optional] Used to fetch metadata from IGDB
- CLIENT_ID=<IGDB client id>
- CLIENT_SECRET=<IGDB client secret>
- STEAMGRIDDB_API_KEY=<SteamGridDB api key> # [Optional]
# [Optional] Use SteamGridDB as a source for covers
- STEAMGRIDDB_API_KEY=<SteamGridDB api key>
# [Optional] Will enable user management and require authentication to access the interface (default to false)
- ROMM_AUTH_ENABLED=true # default: false
- ROMM_AUTH_SECRET_KEY=<secret key> # Generate a key with `openssl rand -hex 32`
- ROMM_AUTH_USERNAME=admin # default: admin
- ROMM_AUTH_PASSWORD=<admin password> # default: admin
# [Optional] Only required if authentication is enabled
- ENABLE_EXPERIMENTAL_REDIS=true # default: false
- REDIS_HOST=redis # default: localhost
- REDIS_PORT=6379 # default: 6379
volumes:
- '/path/to/library:/romm/library'
- '/path/to/resources:/romm/resources' # [Optional] Path where roms metadata (covers) are stored
- '/path/to/config.yml:/romm/config.yml' # [Optional] Path where config is stored
- '/path/to/database:/romm/database' # [Optional] Only needed if ROMM_DB_DRIVER=sqlite or ROMM_DB_DRIVER not exists
- '/path/to/logs:/romm/logs' # [Optional] Path where RomM logs are stored
- "/path/to/library:/romm/library"
- "/path/to/resources:/romm/resources" # [Optional] Path where roms metadata (covers) are stored
- "/path/to/config.yml:/romm/config.yml" # [Optional] Path where config is stored
- "/path/to/database:/romm/database" # [Optional] Only needed if ROMM_DB_DRIVER=sqlite or not set
- "/path/to/logs:/romm/logs" # [Optional] Path where logs are stored
ports:
- 80:80
depends_on:
- romm_db
restart: "unless-stopped"
# [Optional] Only needed if ROMM_DB_DRIVER=mariadb
# [Optional] Only required if using MariaDB as the database
mariadb:
image: mariadb:latest
container_name: mariadb
@@ -39,15 +49,15 @@ services:
- MYSQL_USER=romm-user
- MYSQL_PASSWORD=change-me
volumes:
- mysql_data:/var/lib/mysql # Can also be mounted locally if preferred
- mysql_data:/var/lib/mysql # Can also be mounted locally
ports:
- 3306:3306
restart: "unless-stopped"
# [Optional][Experimental]
# redis:
# image: redis:alpine
# container_name: redis
# restart: unless-stopped
# ports:
# - 6379:6379
# [Optional] Only required if experimental Redis is enabled
redis:
image: redis:alpine
container_name: redis
restart: unless-stopped
ports:
- 6379:6379

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,17 +1,18 @@
{
"name": "romm",
"version": "1.10",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "romm",
"version": "1.9.2",
"version": "2.0.0",
"dependencies": {
"@mdi/font": "7.0.96",
"axios": "^1.3.4",
"core-js": "^3.8.3",
"file-saver": "^2.0.5",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"mitt": "^3.0.0",
@@ -4464,6 +4465,14 @@
"node": ">= 10.13.0"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"engines": {
"node": ">=14"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "romm",
"version": "1.10",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "vite --host",
@@ -13,6 +13,7 @@
"axios": "^1.3.4",
"core-js": "^3.8.3",
"file-saver": "^2.0.5",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"mitt": "^3.0.0",

View File

@@ -1,81 +1,35 @@
<script setup>
import { ref, inject, onMounted } from "vue";
import { useTheme, useDisplay } from "vuetify";
import { fetchPlatformsApi } from "@/services/api.js";
import storePlatforms from "@/stores/platforms.js";
import storeScanning from "@/stores/scanning.js";
import Drawer from "@/components/Drawer/Base.vue";
import AppBar from "@/components/AppBar/Base.vue";
import { onBeforeMount } from "vue";
import { useTheme } from "vuetify";
import cookie from "js-cookie";
import { themes } from "@/styles/themes";
import storeAuth from "@/stores/auth";
import Notification from "@/components/Notification.vue";
import { api } from "./services/api";
// Props
const platforms = storePlatforms();
const scanning = storeScanning();
const refreshPlatforms = ref(false);
const refreshGallery = ref(false);
const { mdAndDown } = useDisplay();
useTheme().global.name.value = localStorage.getItem("theme") || "rommDark";
const auth = storeAuth();
// Event listeners bus
const emitter = inject("emitter");
emitter.on("refreshPlatforms", async () => {
try {
const { data } = await fetchPlatformsApi();
platforms.set(data);
} catch (error) {
console.error("Couldn't fetch platforms:", error);
} finally {
refreshPlatforms.value = !refreshPlatforms.value;
}
onBeforeMount(async () => {
// Set CSRF token for all requests
const { data } = await api.get("/heartbeat");
auth.setEnabled(data.ROMM_AUTH_ENABLED ?? false);
api.defaults.headers.common["x-csrftoken"] = cookie.get("csrftoken");
});
emitter.on("refreshGallery", () => {
refreshGallery.value = !refreshGallery.value;
});
onMounted(async () => {
try {
const { data } = await fetchPlatformsApi();
platforms.set(data);
} catch (error) {
console.error("Couldn't fetch platforms:", error);
}
});
useTheme().global.name.value =
themes[localStorage.getItem("theme")] || themes[0];
</script>
<template>
<v-app>
<notification class="mt-6" />
<v-progress-linear
id="scan-progress-bar"
color="rommAccent1"
:active="scanning.value"
:indeterminate="true"
absolute
fixed
/>
<drawer :key="refreshPlatforms" />
<app-bar v-if="mdAndDown" />
<v-main>
<v-container id="main-container" class="pa-1" fluid>
<router-view :key="refreshGallery" />
</v-container>
<notification class="mt-6" />
<router-view />
</v-main>
</v-app>
</template>
<style>
@import "@/styles/scrollbar.css";
#scan-progress-bar {
z-index: 1000 !important;
}
#main-container {
height: 100%;
}
</style>

View File

@@ -8,8 +8,8 @@ const emitter = inject("emitter");
<template>
<v-list rounded="0" class="pa-0">
<v-list-item
@click="emitter.emit('showSearchDialog', rom)"
class="pt-4 pb-4 pr-5"
@click="emitter.emit('showSearchRomDialog', rom)"
class="py-4 pr-5"
>
<v-list-item-title class="d-flex"
><v-icon icon="mdi-search-web" class="mr-2" />Search
@@ -18,8 +18,8 @@ const emitter = inject("emitter");
</v-list-item>
<v-divider class="border-opacity-25" />
<v-list-item
@click="emitter.emit('showEditDialog', rom)"
class="pt-4 pb-4 pr-5"
@click="emitter.emit('showEditRomDialog', { ...rom })"
class="py-4 pr-5"
>
<v-list-item-title class="d-flex"
><v-icon icon="mdi-pencil-box" class="mr-2" />Edit</v-list-item-title
@@ -27,8 +27,8 @@ const emitter = inject("emitter");
</v-list-item>
<v-divider class="border-opacity-25" />
<v-list-item
@click="emitter.emit('showDeleteDialog', rom)"
class="pt-4 pb-4 pr-5 text-red"
@click="emitter.emit('showDeleteRomDialog', [rom])"
class="py-4 pr-5 text-red"
>
<v-list-item-title class="d-flex"
><v-icon icon="mdi-delete" class="mr-2" />Delete</v-list-item-title

View File

@@ -1,5 +1,5 @@
<script setup>
import storeScanning from "@/stores/scanning.js";
import storeScanning from "@/stores/scanning";
import DrawerToggle from "@/components/AppBar/DrawerToggle.vue";
// Props
@@ -9,7 +9,7 @@ const scanning = storeScanning();
<template>
<v-app-bar elevation="1" density="comfortable">
<v-progress-linear
color="rommAccent1"
color="romm-accent-1"
:active="scanning.value"
:indeterminate="true"
absolute

View File

@@ -14,16 +14,20 @@ emitter.on("showLoadingDialog", (args) => {
<template>
<v-dialog
:modelValue="show"
:scrim="false"
scroll-strategy="none"
width="auto"
:scrim="false"
persistent
>
<v-progress-circular
:width="3"
:size="70"
color="rommAccent1"
indeterminate
/>
<v-card outlined color="transparent">
<v-card-text class="pa-4">
<v-progress-circular
:width="3"
:size="70"
color="romm-accent-1"
indeterminate
/>
</v-card-text>
</v-card>
</v-dialog>
</template>

View File

@@ -2,28 +2,31 @@
import { ref, inject } from "vue";
import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import { deleteRomApi } from "@/services/api.js";
import { deleteRomsApi } from "@/services/api";
import romsStore from "@/stores/roms";
const { xs, mdAndDown, lgAndUp } = useDisplay();
const router = useRouter();
const show = ref(false);
const rom = ref();
const storeRoms = romsStore();
const roms = ref();
const deleteFromFs = ref(false);
const emitter = inject("emitter");
emitter.on("showDeleteDialog", (romToDelete) => {
rom.value = romToDelete;
emitter.on("showDeleteRomDialog", (romsToDelete) => {
roms.value = romsToDelete;
show.value = true;
});
async function deleteRom() {
await deleteRomApi(rom.value, deleteFromFs.value)
async function deleteRoms() {
await deleteRomsApi(roms.value, deleteFromFs.value)
.then((response) => {
emitter.emit("snackbarShow", {
msg: response.data.msg,
icon: "mdi-check-bold",
color: "green",
});
storeRoms.reset();
})
.catch((error) => {
console.log(error);
@@ -34,9 +37,12 @@ async function deleteRom() {
});
return;
});
await router.push(`/platform/${rom.value.p_slug}`);
emitter.emit("refreshGallery");
emitter.emit("refreshPlatforms");
await router.push({
name: "platform",
params: { platform: roms.value[0].p_slug },
});
emitter.emit("refreshView");
emitter.emit("refreshDrawer");
show.value = false;
}
</script>
@@ -58,7 +64,7 @@ async function deleteRom() {
'delete-content-mobile': xs,
}"
>
<v-toolbar density="compact" class="bg-primary">
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
<v-col cols="9" xs="9" sm="10" md="10" lg="11">
<v-icon icon="mdi-delete" class="ml-5" />
@@ -66,7 +72,7 @@ async function deleteRom() {
<v-col>
<v-btn
@click="show = false"
class="bg-primary"
class="bg-terciary"
rounded="0"
variant="text"
icon="mdi-close"
@@ -76,19 +82,36 @@ async function deleteRom() {
</v-row>
</v-toolbar>
<v-divider class="border-opacity-25" :thickness="1" />
<v-card-text class="bg-secondary">
<v-card-text>
<v-row class="justify-center pa-2" no-gutters>
<span>Deleting {{ rom.file_name }}. Do you confirm?</span>
<span>Deleting the following</span>
<span class="text-romm-accent-2 mx-1">{{ roms.length }}</span>
<span>games. Do you confirm?</span>
</v-row>
</v-card-text>
<v-card-text class="scroll bg-terciary py-0">
<v-row class="justify-center pa-2" no-gutters>
<v-btn @click="show = false">Cancel</v-btn>
<v-btn @click="deleteRom()" class="text-red ml-5">Confirm</v-btn>
<v-list class="bg-terciary py-0">
<v-list-item v-for="rom in roms" class="justify-center bg-terciary"
>{{ rom.r_name }} - [<span class="text-romm-accent-1">{{
rom.file_name
}}</span
>]</v-list-item
>
</v-list>
</v-row>
</v-card-text>
<v-card-text>
<v-row class="justify-center pa-2" no-gutters>
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
<v-btn @click="deleteRoms()" class="text-romm-red bg-terciary ml-5"
>Confirm</v-btn
>
</v-row>
</v-card-text>
<v-divider class="border-opacity-25" :thickness="1" />
<v-toolbar class="bg-primary" density="compact">
<v-toolbar class="bg-terciary" density="compact">
<v-checkbox
v-model="deleteFromFs"
label="Remove from filesystem"
@@ -103,13 +126,19 @@ async function deleteRom() {
<style scoped>
.delete-content {
width: 900px;
max-height: 600px;
}
.delete-content-tablet {
width: 570px;
max-height: 600px;
}
.delete-content-mobile {
width: 85vw;
max-height: 600px;
}
.scroll {
overflow-y: scroll;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, inject } from "vue";
import { useDisplay } from "vuetify";
import { updateRomApi } from "@/services/api.js";
import { updateRomApi } from "@/services/api";
const { xs, mdAndDown, lgAndUp } = useDisplay();
const show = ref(false);
@@ -13,7 +13,7 @@ const fileNameInputRules = {
};
const emitter = inject("emitter");
emitter.on("showEditDialog", (romToEdit) => {
emitter.on("showEditRomDialog", (romToEdit) => {
show.value = true;
rom.value = romToEdit;
});
@@ -45,7 +45,7 @@ async function updateRom(updatedData = { ...rom.value }) {
icon: "mdi-check-bold",
color: "green",
});
emitter.emit("refreshGallery");
emitter.emit("refreshView");
})
.catch((error) => {
emitter.emit("snackbarShow", {
@@ -79,7 +79,7 @@ async function updateRom(updatedData = { ...rom.value }) {
'edit-content-mobile': xs,
}"
>
<v-toolbar density="compact" class="bg-primary">
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
<v-col cols="9" xs="9" sm="10" md="10" lg="11">
<v-icon icon="mdi-pencil-box" class="ml-5" />
@@ -87,7 +87,7 @@ async function updateRom(updatedData = { ...rom.value }) {
<v-col>
<v-btn
@click="show = false"
class="bg-primary"
class="bg-terciary"
rounded="0"
variant="text"
icon="mdi-close"
@@ -98,8 +98,8 @@ async function updateRom(updatedData = { ...rom.value }) {
</v-toolbar>
<v-divider class="border-opacity-25" :thickness="1" />
<v-card-text class="bg-secondary scroll">
<v-row class="justify-center pa-2" no-gutters>
<v-card-text>
<v-row class="pa-2" no-gutters>
<v-text-field
@keyup.enter="updateRom()"
v-model="rom.r_name"
@@ -109,7 +109,7 @@ async function updateRom(updatedData = { ...rom.value }) {
hide-details
/>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-row class="pa-2" no-gutters>
<v-text-field
@keyup.enter="updateRom()"
v-model="rom.file_name"
@@ -123,7 +123,7 @@ async function updateRom(updatedData = { ...rom.value }) {
hide-details
/>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-row class="pa-2" no-gutters>
<v-textarea
@keyup.enter="updateRom()"
v-model="rom.summary"
@@ -133,7 +133,7 @@ async function updateRom(updatedData = { ...rom.value }) {
hide-details
/>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-row class="pa-2" no-gutters>
<v-file-input
@keyup.enter="updateRom()"
label="Custom cover [Coming soon]"
@@ -145,8 +145,12 @@ async function updateRom(updatedData = { ...rom.value }) {
/>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-btn @click="show = false">Cancel</v-btn>
<v-btn @click="updateRom()" class="text-rommGreen ml-5">Apply</v-btn>
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
<v-btn
@click="updateRom()"
class="text-romm-green ml-5 bg-terciary"
>Apply</v-btn
>
</v-row>
</v-card-text>
</v-card>

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, inject, onBeforeUnmount } from "vue";
import { useDisplay } from "vuetify";
import { updateRomApi, searchRomIGDBApi } from "@/services/api.js";
import { updateRomApi, searchRomIGDBApi } from "@/services/api";
const { xs, mdAndDown, lgAndUp } = useDisplay();
const show = ref(false);
@@ -11,9 +11,10 @@ const searching = ref(false);
const searchTerm = ref("");
const searchBy = ref("Name");
const matchedRoms = ref([]);
const selectedScrapSource = ref(0);
const emitter = inject("emitter");
emitter.on("showSearchDialog", (romToSearch) => {
emitter.on("showSearchRomDialog", (romToSearch) => {
rom.value = romToSearch;
searchTerm.value = romToSearch.file_name_no_tags;
show.value = true;
@@ -47,7 +48,7 @@ async function updateRom(updatedData = { ...rom.value }) {
icon: "mdi-check-bold",
color: "green",
});
emitter.emit("refreshGallery");
emitter.emit("refreshView");
})
.catch((error) => {
emitter.emit("snackbarShow", {
@@ -62,7 +63,7 @@ async function updateRom(updatedData = { ...rom.value }) {
}
onBeforeUnmount(() => {
emitter.off("showSearchDialog");
emitter.off("showSearchRomDialog");
});
</script>
@@ -85,26 +86,57 @@ onBeforeUnmount(() => {
}"
rounded="0"
>
<v-toolbar density="compact" class="bg-primary">
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
<v-col cols="9" xs="9" sm="10" md="10" lg="11">
<v-col cols="2" xs="2" sm="1" md="1" lg="1">
<v-icon icon="mdi-search-web" class="ml-5" />
<v-chip class="ml-5 text-rommAccent1" variant="outlined" label
>IGDB</v-chip
>
</v-col>
<v-col>
<v-col cols="8" xs="8" sm="9" md="9" lg="10">
<v-item-group mandatory v-model="selectedScrapSource">
<v-item v-slot="{ isSelected, toggle }">
<v-chip
class="mx-1"
:color="isSelected ? 'romm-accent-1' : 'romm-gray'"
variant="outlined"
label
@click="toggle"
>IGDB</v-chip
>
</v-item>
<!-- TODO: Ready item group to scrape from different sources -->
<!-- <v-item v-slot="{ isSelected, toggle }" disabled>
<v-chip class="mx-1" :color="isSelected ? 'romm-accent-1' : 'romm-gray'" variant="outlined" label @click="toggle"
>ScreenScraper</v-chip
>
</v-item>
<v-item v-slot="{ isSelected, toggle }" disabled>
<v-chip class="mx-1" :color="isSelected ? 'romm-accent-1' : 'romm-gray'" variant="outlined" label @click="toggle"
>MobyGames</v-chip
>
</v-item>
<v-item v-slot="{ isSelected, toggle }" disabled>
<v-chip class="mx-1" :color="isSelected ? 'romm-accent-1' : 'romm-gray'" variant="outlined" label @click="toggle"
>RAWG</v-chip
>
</v-item> -->
</v-item-group>
</v-col>
<v-col cols="2" xs="2" sm="2" md="2" lg="1">
<v-btn
@click="show = false"
class="bg-primary"
rounded="0"
variant="text"
icon="mdi-close"
block
/>
</v-col>
</v-row>
</v-toolbar>
<v-divider class="border-opacity-25" :thickness="1" />
<v-toolbar density="compact" class="bg-primary">
@@ -113,6 +145,7 @@ onBeforeUnmount(() => {
<v-text-field
@keyup.enter="searchRomIGDB()"
@click:clear="searchTerm = ''"
class="bg-terciary"
v-model="searchTerm"
label="search"
hide-details
@@ -122,6 +155,7 @@ onBeforeUnmount(() => {
<v-col cols="3" xs="3" sm="2" md="2" lg="2">
<v-select
label="by"
class="bg-terciary"
:items="['ID', 'Name']"
v-model="searchBy"
hide-details
@@ -131,7 +165,7 @@ onBeforeUnmount(() => {
<v-btn
type="submit"
@click="searchRomIGDB()"
class="bg-primary"
class="bg-terciary"
rounded="0"
variant="text"
icon="mdi-search-web"
@@ -142,7 +176,7 @@ onBeforeUnmount(() => {
</v-row>
</v-toolbar>
<v-card-text class="pa-1 scroll bg-secondary">
<v-card-text class="pa-1 scroll">
<v-row
class="justify-center loader-searching"
v-show="searching"
@@ -151,7 +185,7 @@ onBeforeUnmount(() => {
<v-progress-circular
:width="2"
:size="40"
color="rommAccent1"
color="romm-accent-1"
indeterminate
/>
</v-row>
@@ -199,7 +233,8 @@ onBeforeUnmount(() => {
</v-card-text>
<v-divider class="border-opacity-25" :thickness="1" />
<v-toolbar class="bg-primary" density="compact">
<v-toolbar class="bg-terciary" density="compact">
<v-checkbox
v-model="renameAsIGDB"
label="Rename rom"

View File

@@ -0,0 +1,107 @@
<script setup>
import { ref, inject } from "vue";
import { createUserApi } from "@/services/api";
const user = ref({
username: "",
password: "",
role: "viewer",
});
const show = ref(false);
const emitter = inject("emitter");
emitter.on("showCreateUserDialog", () => {
show.value = true;
});
async function createUser() {
await createUserApi(user.value).catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to create user: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
});
});
show.value = false;
emitter.emit("refreshView");
}
</script>
<template>
<v-dialog v-model="show" max-width="500px" :scrim="false">
<v-card>
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
<v-col cols="10">
<v-icon icon="mdi-account" class="ml-5 mr-2" />
</v-col>
<v-col>
<v-btn
@click="show = false"
class="bg-terciary"
rounded="0"
variant="text"
icon="mdi-close"
block
/>
</v-col>
</v-row>
</v-toolbar>
<v-divider class="border-opacity-25" :thickness="1" />
<v-card-text>
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.username"
label="username"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.password"
label="Password"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-select
v-model="user.role"
rounded="0"
variant="outlined"
:items="['viewer', 'editor', 'admin']"
label="Role"
required
hide-details
/>
</v-col>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
<v-btn
:disabled="!user.username || !user.password"
class="text-romm-green bg-terciary ml-5"
@click="createUser()"
>
Create
</v-btn>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref, inject } from "vue";
import { deleteUserApi } from "@/services/api";
const user = ref();
const show = ref(false);
const emitter = inject("emitter");
emitter.on("showDeleteUserDialog", (userToDelete) => {
user.value = userToDelete;
show.value = true;
});
async function deleteUser() {
await deleteUserApi(user.value).catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to delete user: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
});
});
show.value = false;
emitter.emit("refreshView");
}
</script>
<template>
<v-dialog v-model="show" max-width="500px" :scrim="true">
<v-card>
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
<v-col cols="10">
<v-icon icon="mdi-delete" class="ml-5 mr-2" />
</v-col>
<v-col>
<v-btn
@click="show = false"
class="bg-terciary"
rounded="0"
variant="text"
icon="mdi-close"
block
/>
</v-col>
</v-row>
</v-toolbar>
<v-divider class="border-opacity-25" :thickness="1" />
<v-card-text>
<v-row class="justify-center pa-2" no-gutters>
<span class="mr-1">Deleting</span
><span class="text-romm-accent-1">{{ user.username }}</span
>.<span class="ml-1">Do you confirm?</span>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
<v-btn class="bg-terciary text-romm-red ml-5" @click="deleteUser()"
>Confirm</v-btn
>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,143 @@
<script setup>
import { ref, inject } from "vue";
import { updateUserApi } from "@/services/api";
import { defaultAvatarPath } from "@/utils/utils"
const user = ref();
const show = ref(false);
const avatarFile = ref();
const emitter = inject("emitter");
emitter.on("showEditUserDialog", (userToEdit) => {
user.value = userToEdit;
show.value = true;
});
function editUser() {
updateUserApi(user.value)
.then((response) => {
emitter.emit("snackbarShow", {
msg: `User ${response.data.username} updated successfully`,
icon: "mdi-check-bold",
color: "green",
timeout: 5000
});
})
.catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to edit user: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
timeout: 5000
});
});
show.value = false;
emitter.emit("refreshView");
emitter.emit("refreshDrawer");
}
</script>
<template>
<v-dialog v-model="show" max-width="700px" :scrim="false">
<v-card>
<v-toolbar density="compact" class="bg-terciary">
<v-row class="align-center" no-gutters>
<v-col cols="10">
<v-icon icon="mdi-pencil-box" class="ml-5 mr-2" />
</v-col>
<v-col>
<v-btn
@click="show = false"
class="bg-terciary"
rounded="0"
variant="text"
icon="mdi-close"
block
/>
</v-col>
</v-row>
</v-toolbar>
<v-divider class="border-opacity-25" :thickness="1" />
<v-card-text>
<v-row no-gutters>
<v-col cols="12" lg="9">
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.username"
label="username"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-text-field
rounded="0"
variant="outlined"
v-model="user.password"
label="Password"
required
hide-details
clearable
></v-text-field>
</v-col>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-select
v-model="user.role"
rounded="0"
variant="outlined"
:items="['viewer', 'editor', 'admin']"
label="Role"
required
hide-details
></v-select>
</v-col>
</v-row>
</v-col>
<v-col cols="12" lg="3">
<v-row class="pa-2 justify-center" no-gutters>
<v-avatar size="128" class="">
<v-img
:src="
user.avatar_path
? `/assets/romm/resources/${user.avatar_path}`
: defaultAvatarPath
"
/>
</v-avatar>
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-file-input
class="text-truncate"
@keyup.enter="updateRom()"
v-model="user.avatar"
label="Avatar"
prepend-inner-icon="mdi-image"
prepend-icon=""
variant="outlined"
hide-details
/>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-btn @click="show = false" class="bg-terciary">Cancel</v-btn>
<v-btn class="text-romm-green bg-terciary ml-5" @click="editUser()"
>Apply</v-btn
>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>

View File

@@ -1,11 +1,14 @@
<script setup>
import { ref, inject } from "vue";
import storePlatforms from "@/stores/platforms";
import storeAuth from "@/stores/auth";
import DrawerHeader from "@/components/Drawer/Header.vue";
import PlatformListItem from "@/components/Platform/PlatformListItem.vue";
import RailBtn from "@/components/Drawer/RailBtn.vue";
import RailFooter from "@/components/Drawer/Footer.vue";
// Props
const platforms = storePlatforms();
const auth = storeAuth();
const drawer = ref(undefined);
const open = ref(["Platforms", "Library", "Settings"]);
const rail = ref(localStorage.getItem("rail") == "true");
@@ -25,25 +28,20 @@ emitter.on("toggleDrawerRail", () => {
<v-navigation-drawer
v-model="drawer"
:rail="rail"
width="300"
rail-width="145"
width="270"
rail-width="70"
elevation="0"
>
<v-list v-model:opened="open">
<router-link to="/">
<v-list-item class="justify-center pa-0">
<v-img src="/assets/isotipo.svg" width="70" class="home-btn" />
</v-list-item>
</router-link>
<template v-slot:prepend>
<drawer-header :rail="rail" />
</template>
<v-list v-model:opened="open" class="pa-0">
<v-divider />
<v-divider></v-divider>
<v-list-group value="Platforms">
<v-list-group value="Platforms" fluid>
<template v-slot:activator="{ props }">
<v-list-item v-bind="props">
<span class="text-body-1 text-truncate">{{
rail ? "" : "Platforms"
}}</span>
<span v-if="!rail" class="text-body-1 text-truncate">Platforms</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40"
><v-icon>mdi-controller</v-icon></v-avatar
@@ -52,7 +50,6 @@ emitter.on("toggleDrawerRail", () => {
</v-list-item>
</template>
<platform-list-item
class="drawer-item"
v-for="platform in platforms.value"
:platform="platform"
:rail="rail"
@@ -60,12 +57,10 @@ emitter.on("toggleDrawerRail", () => {
/>
</v-list-group>
<v-list-group value="Library">
<v-list-group value="Library" v-if="auth.scopes.includes('roms.write')" fluid>
<template v-slot:activator="{ props }">
<v-list-item v-bind="props">
<span class="text-body-1 text-truncate">{{
rail ? "" : "Library"
}}</span>
<span v-if="!rail" class="text-body-1 text-truncate">Library</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40"
><v-icon>mdi-animation-outline</v-icon></v-avatar
@@ -73,23 +68,17 @@ emitter.on("toggleDrawerRail", () => {
</template>
</v-list-item>
</template>
<v-list-item class="drawer-item bg-terciary" to="/library/scan">
<span class="text-body-2 text-truncate">{{
rail ? "" : "Scan"
}}</span>
<v-list-item class="bg-terciary" to="/library/scan">
<span v-if="!rail" class="text-body-2 text-truncate">Scan</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40"
><v-icon>mdi-magnify-scan</v-icon></v-avatar
>
</template>
</v-list-item>
<v-list-item class="drawer-item bg-terciary" disabled>
<span class="text-body-2 text-truncate">{{
rail ? "" : "Upload"
}}</span>
<span class="text-caption text-truncate ml-1">{{
rail ? "" : "[coming soon]"
}}</span>
<v-list-item class="bg-terciary" disabled>
<span v-if="!rail" class="text-body-2 text-truncate">Upload</span>
<span v-if="!rail" class="text-caption text-truncate ml-1">[coming soon]</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40"
><v-icon>mdi-upload</v-icon></v-avatar
@@ -98,12 +87,10 @@ emitter.on("toggleDrawerRail", () => {
</v-list-item>
</v-list-group>
<v-list-group value="Settings">
<v-list-group value="Settings" fluid>
<template v-slot:activator="{ props }">
<v-list-item v-bind="props">
<span class="text-body-1 text-truncate">{{
rail ? "" : "Settings"
}}</span>
<span v-if="!rail" class="text-body-1 text-truncate">Settings</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40"
><v-icon>mdi-cog</v-icon></v-avatar
@@ -112,12 +99,10 @@ emitter.on("toggleDrawerRail", () => {
</v-list-item>
</template>
<v-list-item
class="drawer-item bg-terciary"
class="bg-terciary"
to="/settings/control-panel"
>
<span class="text-body-2 text-truncate">{{
rail ? "" : "Control panel"
}}</span>
<span v-if="!rail" class="text-body-2 text-truncate">Control Panel</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40"
><v-icon>mdi-view-dashboard</v-icon></v-avatar
@@ -127,21 +112,9 @@ emitter.on("toggleDrawerRail", () => {
</v-list-group>
</v-list>
<template v-slot:append>
<template v-if="auth.enabled" v-slot:append>
<v-divider class="border-opacity-25" :thickness="1" />
<rail-btn :rail="rail" />
<rail-footer :rail="rail" />
</template>
</v-navigation-drawer>
</template>
<style scoped>
.home-btn {
width: 100px;
height: 100px;
cursor: pointer;
}
.drawer-item {
padding-inline-start: 30px !important;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup>
import { inject } from "vue";
import { useRouter } from "vue-router";
import storeAuth from "@/stores/auth";
import { defaultAvatarPath } from "@/utils/utils"
import { api } from "@/services/api";
// Props
const props = defineProps(["rail"]);
const router = useRouter();
const emitter = inject("emitter");
const auth = storeAuth();
// Functions
async function logout() {
api
.post("/logout", {})
.then(({ data }) => {
emitter.emit("snackbarShow", {
msg: data.message,
icon: "mdi-check-bold",
color: "green",
});
router.push("/login");
})
.catch(() => {
router.push("/login");
})
.finally(() => {
auth.setUser(null);
});
}
</script>
<template>
<v-list-item height="60" class="bg-primary text-button" rounded="0">
<template v-if="!rail">
<div class="text-no-wrap text-truncate text-subtitle-1">{{ auth.user?.username }}</div>
<div class="text-no-wrap text-truncate text-caption">{{ auth.user?.role }}</div>
</template>
<template v-slot:prepend>
<v-avatar :class="{ 'my-2': rail }">
<v-img
:src="
auth.user?.avatar_path
? `/assets/romm/resources/${auth.user?.avatar_path}`
: defaultAvatarPath
"
/>
</v-avatar>
</template>
<template v-slot:append>
<v-btn
v-if="!rail"
variant="text"
icon="mdi-location-exit"
@click="logout()"
></v-btn>
</template>
</v-list-item>
<v-btn
v-if="rail"
rounded="0"
variant="text"
icon="mdi-location-exit"
block
@click="logout()"
></v-btn>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
import RailBtn from "@/components/Drawer/RailBtn.vue";
const props = defineProps(["rail"]);
</script>
<template>
<v-list-item :class="{ 'ml-9': !rail, 'ml-0': rail }" class="my-2">
<template v-slot:prepend>
<router-link to="/">
<v-avatar :rounded="0" :size="rail ? 40 : 60" class="mr-3"
><v-img src="/assets/isotipo.svg"
/></v-avatar>
<v-avatar v-if="!rail" :rounded="0" size="60"
><v-img src="/assets/logotipo.svg"
/></v-avatar>
</router-link>
</template>
<template v-if="!rail" v-slot:append>
<rail-btn :rail="rail" />
</template>
</v-list-item>
<v-list-item class="pa-0" v-if="rail">
<rail-btn :rail="rail" />
</v-list-item>
</template>
<style scoped>
.home-btn {
width: 75px;
cursor: pointer;
}
</style>

View File

@@ -11,8 +11,8 @@ const emitter = inject("emitter");
<template>
<v-btn
@click="emitter.emit('toggleDrawerRail')"
rounded="0"
color="primary"
elevation="0"
block
>
<v-icon v-if="rail">mdi-arrow-collapse-right</v-icon>

View File

@@ -0,0 +1,122 @@
<script setup>
import { inject } from "vue";
import { useRoute } from "vue-router";
import storeAuth from "@/stores/auth";
import storeRoms from "@/stores/roms";
import socket from "@/services/socket";
import storeScanning from "@/stores/scanning";
import { downloadRomApi } from "@/services/api";
// Event listeners bus
const emitter = inject("emitter");
// Props
const props = defineProps(["filteredRoms"]);
const auth = storeAuth();
const romsStore = storeRoms();
const scanning = storeScanning();
const route = useRoute();
socket.on("scan:scanning_rom", ({ id }) => {
const rom = romsStore.selected.find((r) => r.id === id);
romsStore.removeSelectedRoms(rom);
});
socket.on("scan:done", () => {
scanning.set(false);
emitter.emit("snackbarShow", {
msg: "Scan completed successfully!",
icon: "mdi-check-bold",
color: "green",
});
socket.disconnect();
});
socket.on("scan:done_ko", (msg) => {
scanning.set(false);
emitter.emit("snackbarShow", {
msg: `Scan couldn't be completed. Something went wrong: ${msg}`,
icon: "mdi-close-circle",
color: "red",
});
socket.disconnect();
});
// Functions
async function onScan() {
scanning.set(true);
emitter.emit("snackbarShow", {
msg: `Scanning ${route.params.platform}...`,
icon: "mdi-loading mdi-spin",
color: "romm-accent-1",
});
if (!socket.connected) socket.connect();
socket.emit("scan", {
platforms: [route.params.platform],
rescan: false,
});
}
function selectAllRoms() {
if (props.filteredRoms.length === romsStore.selected.length) {
romsStore.reset();
emitter.emit("openFabMenu", false);
} else {
romsStore.updateSelectedRoms(props.filteredRoms);
}
emitter.emit("refreshSelected");
}
function onDownload() {
romsStore.selected.forEach((rom) => {
downloadRomApi(rom);
});
}
</script>
<template>
<v-btn
color="terciary"
elevation="8"
:icon="
filteredRoms.length === romsStore.selected.length
? 'mdi-select'
: 'mdi-select-all'
"
class="mb-2 ml-1"
@click.stop="selectAllRoms"
/>
<v-btn
v-if="auth.scopes.includes('roms.write')"
color="terciary"
elevation="8"
icon
class="mb-2 ml-1"
@click="onScan"
>
<v-icon>mdi-magnify-scan</v-icon>
</v-btn>
<v-btn
color="terciary"
elevation="8"
icon
class="mb-2 ml-1"
@click="onDownload"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="auth.scopes.includes('roms.write')"
color="terciary"
elevation="8"
icon
class="mb-3 ml-1"
@click="emitter.emit('showDeleteRomDialog', romsStore.selected)"
>
<v-icon color="romm-red">mdi-delete</v-icon>
</v-btn>
</template>

View File

@@ -1,7 +1,7 @@
<script setup>
import { inject, ref, onMounted } from "vue";
import { debounce } from "lodash";
import storeGalleryFilter from "@/stores/galleryFilter.js";
import storeGalleryFilter from "@/stores/galleryFilter";
// Props
const galleryFilter = storeGalleryFilter();
@@ -31,6 +31,7 @@ const filterRoms = debounce(() => {
v-model="filterValue"
prepend-inner-icon="mdi-magnify"
label="search"
rounded="0"
hide-details
clearable
/>

View File

@@ -1,6 +1,6 @@
<script setup>
import { views } from "@/utils/utils.js";
import storeGalleryView from "@/stores/galleryView.js";
import { views } from "@/utils/utils";
import storeGalleryView from "@/stores/galleryView";
// Props
const galleryView = storeGalleryView();

View File

@@ -1,13 +1,15 @@
<script setup>
import { ref } from "vue";
import { downloadRomApi } from "@/services/api.js";
import useDownloadStore from "@/stores/download.js";
import { downloadRomApi } from "@/services/api";
import storeDownload from "@/stores/download";
import storeAuth from "@/stores/auth";
import AdminMenu from "@/components/AdminMenu/Base.vue";
// Props
const props = defineProps(["rom"]);
const saveFiles = ref(false);
const downloadStore = useDownloadStore();
const auth = storeAuth();
const downloadStore = storeDownload();
const downloadUrl = `${window.location.origin}${props.rom.download_path}`;
</script>
@@ -48,6 +50,7 @@ const downloadUrl = `${window.location.origin}${props.rom.download_path}`;
<template v-slot:activator="{ props }">
<v-btn
@click=""
:disabled="!auth.scopes.includes('roms.write')"
v-bind="props"
icon="mdi-dots-vertical"
size="x-small"

View File

@@ -1,31 +1,62 @@
<script setup>
import { ref, inject } from "vue";
import useRomsStore from "@/stores/roms.js";
import ActionBar from "@/components/Game/Card/ActionBar.vue";
import Cover from "@/components/Game/Card/Cover.vue";
// Props
const props = defineProps(["rom"]);
const props = defineProps(["rom", "index"]);
const emit = defineEmits(["selectRom"]);
const romsStore = useRomsStore();
const selected = ref();
// Functions
function selectRom(event) {
selected.value = !selected.value;
if (selected.value) {
romsStore.addSelectedRoms(props.rom);
} else {
romsStore.removeSelectedRoms(props.rom);
}
emit("selectRom", { event, index: props.index, selected: selected.value });
}
const emitter = inject("emitter");
emitter.on("refreshSelected", () => {
selected.value = romsStore.selected
.map((rom) => rom.id)
.includes(props.rom.id);
});
</script>
<template>
<v-hover v-slot="{ isHovering, props }">
<v-card
v-bind="props"
:class="{ 'on-hover': isHovering }"
class="rom-card"
:class="{ 'on-hover': isHovering, 'rom-selected': selected }"
:elevation="isHovering ? 20 : 3"
>
<v-hover v-slot="{ isHovering, props }" open-delay="800">
<cover :rom="rom" :isHovering="isHovering" :hoverProps="props" />
<action-bar :rom="rom" />
</v-hover>
<cover
:rom="rom"
:isHoveringTop="isHovering"
:selected="selected"
@selectRom="selectRom"
/>
<action-bar :rom="rom" />
</v-card>
</v-hover>
</template>
<style scoped>
<style scoped lang="scss">
.v-card {
opacity: 0.85;
border: 3px solid rgba(var(--v-theme-primary));
}
.v-card.on-hover {
opacity: 1;
}
.v-card:not(.on-hover) {
opacity: 0.85;
.v-card.rom-selected {
border: 3px solid rgba(var(--v-theme-romm-accent-2));
}
</style>

View File

@@ -1,63 +1,104 @@
<script setup>
import useDownloadStore from "@/stores/download.js";
import storeDownload from "@/stores/download";
import storeRoms from "@/stores/roms";
const downloadStore = useDownloadStore();
const downloadStore = storeDownload();
const romsStore = storeRoms();
// Props
const props = defineProps(["rom", "isHovering", "hoverProps", "size"]);
const props = defineProps(["rom", "isHoveringTop", "size", "selected"]);
const emit = defineEmits(["selectRom"]);
// Functions
function selectRom(event) {
if (!event.ctrlKey && !event.shiftKey) {
event.preventDefault();
emit("selectRom", event);
}
}
function onNavigate(event) {
if (event.ctrlKey || event.shiftKey) {
event.preventDefault();
event.stopPropagation();
emit("selectRom", event);
}
}
</script>
<template>
<router-link
style="text-decoration: none; color: inherit"
:to="`/platform/${$route.params.platform}/${rom.id}`"
:to="
romsStore.length > 0
? `#`
: `/platform/${$route.params.platform}/${rom.id}`
"
@click="onNavigate"
>
<v-progress-linear
color="rommAccent1"
color="romm-accent-1"
:active="downloadStore.value.includes(rom.id)"
:indeterminate="true"
absolute
/>
<v-img
:value="rom.id"
:key="rom.id"
v-bind="hoverProps"
:src="`/assets/romm/resources/${rom.path_cover_l}`"
:lazy-src="`/assets/romm/resources/${rom.path_cover_s}`"
class="cover"
cover
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular color="rommAccent1" :width="2" indeterminate />
</div>
</template>
<v-expand-transition>
<div
v-if="isHovering || !rom.has_cover"
class="rom-title d-flex transition-fast-in-fast-out bg-tooltip text-caption"
<v-hover v-slot="{ isHovering, props }" open-delay="800">
<v-img
:value="rom.id"
:key="rom.id"
v-bind="props"
:src="`/assets/romm/resources/${rom.path_cover_l}`"
:lazy-src="`/assets/romm/resources/${rom.path_cover_s}`"
class="cover"
cover
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="romm-accent-1"
:width="2"
indeterminate
/>
</div>
</template>
<v-expand-transition>
<div
v-if="isHovering || !rom.has_cover"
class="rom-title d-flex transition-fast-in-fast-out bg-tooltip text-caption"
>
<v-list-item>{{ rom.file_name }}</v-list-item>
</div>
</v-expand-transition>
<v-chip-group class="pl-1 pt-0">
<v-chip v-show="rom.region" size="x-small" class="bg-chip" label>
{{ rom.region }}
</v-chip>
<v-chip v-show="rom.revision" size="x-small" class="bg-chip" label>
{{ rom.revision }}
</v-chip>
</v-chip-group>
<v-icon
v-show="isHoveringTop"
@click="selectRom"
size="small"
class="position-absolute checkbox"
:class="{ 'checkbox-selected': selected }"
>{{ selected ? "mdi-circle-slice-8" : "mdi-circle-outline" }}</v-icon
>
<v-list-item>{{ rom.file_name }}</v-list-item>
</div>
</v-expand-transition>
<v-chip-group class="pl-1 pt-0">
<v-chip v-show="rom.region" size="x-small" class="bg-chip" label>
{{ rom.region }}
</v-chip>
<v-chip v-show="rom.revision" size="x-small" class="bg-chip" label>
{{ rom.revision }}
</v-chip>
</v-chip-group>
</v-img>
</v-img>
</v-hover>
</router-link>
</template>
<style scoped>
.rom-title {
opacity: 0.85;
}
.rom-title.on-hover {
opacity: 1;
}
.rom-title:not(.on-hover) {
opacity: 0.85;
.checkbox {
bottom: 0.2rem;
right: 0.2rem;
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup>
import { ref, inject } from "vue";
import { useRouter } from "vue-router";
import { downloadRomApi } from "@/services/api";
import storeDownload from "@/stores/download";
import useRomsStore from "@/stores/roms";
import { VDataTable } from "vuetify/labs/VDataTable";
import AdminMenu from "@/components/AdminMenu/Base.vue";
// Props
const emitter = inject("emitter");
const props = defineProps(["filteredRoms"]);
const location = window.location.origin;
const router = useRouter();
const downloadStore = storeDownload();
const romsStore = useRomsStore();
const saveFiles = ref(false);
const romsPerPage = ref(-1);
const HEADERS = [
{
title: "",
align: "start",
sortable: false,
key: "path_cover_s",
},
{
title: "Name",
align: "start",
sortable: true,
key: "r_name",
},
{
title: "File",
align: "start",
sortable: true,
key: "file_name",
},
{
title: "Size",
align: "start",
sortable: true,
key: "file_size",
},
{
title: "Reg",
align: "start",
sortable: true,
key: "region",
},
{
title: "Rev",
align: "start",
sortable: true,
key: "revision",
},
{ align: "end", key: "actions", sortable: false },
];
const PER_PAGE_OPTIONS = [
{ value: -1, title: "$vuetify.dataFooter.itemsPerPageAll" },
];
function rowClick(_, row) {
router.push(
`/platform/${row.item.selectable.p_slug}/${row.item.selectable.id}`
);
}
</script>
<template>
<v-data-table
:items-per-page="romsPerPage"
:items-per-page-options="PER_PAGE_OPTIONS"
items-per-page-text=""
:headers="HEADERS"
:item-value="item => item"
:items="filteredRoms"
@click:row="rowClick"
show-select
v-model="romsStore.selected"
@update:model-value="emitter.emit('refreshSelected')"
>
<template v-slot:item.path_cover_s="{ item }">
<v-avatar :rounded="0">
<v-progress-linear
color="romm-accent-1"
:active="downloadStore.value.includes(item.selectable.id)"
:indeterminate="true"
absolute
/>
<v-img
:src="`/assets/romm/resources/${item.selectable.path_cover_s}`"
:lazy-src="`/assets/romm/resources/${item.selectable.path_cover_s}`"
min-height="150"
/>
</v-avatar>
</template>
<template v-slot:item.file_size="{ item }">
<span
>{{ item.selectable.file_size }}
{{ item.selectable.file_size_units }}</span
>
</template>
<template v-slot:item.actions="{ item }">
<template v-if="item.selectable.multi">
<v-btn
class="my-1"
rounded="0"
@click.stop="downloadRomApi(item.selectable)"
:disabled="downloadStore.value.includes(item.selectable.id)"
download
size="small"
variant="text"
><v-icon>mdi-download</v-icon></v-btn
>
</template>
<template v-else>
<v-btn
class="my-1"
rounded="0"
@click.stop=""
:href="`${location}${item.selectable.download_path}`"
download
size="small"
variant="text"
><v-icon>mdi-download</v-icon></v-btn
>
</template>
<v-btn
size="small"
variant="text"
:disabled="!saveFiles"
class="my-1"
rounded="0"
><v-icon>mdi-content-save-all</v-icon></v-btn
>
<v-menu location="bottom">
<template v-slot:activator="{ props }">
<v-btn
rounded="0"
v-bind="props"
size="small"
variant="text"
class="my-1"
><v-icon>mdi-dots-vertical</v-icon></v-btn
>
</template>
<admin-menu :rom="item.selectable" />
</v-menu>
</template>
</v-data-table>
</template>

View File

@@ -1,38 +0,0 @@
<template>
<thead>
<v-row no-gutters>
<v-col>
<v-list-item>
<v-row class="text-subtitle-2 align-center">
<v-col cols="9" xs="9" sm="6" md="3" lg="3"
><span>Name</span></v-col
>
<v-col md="4" lg="4" class="hidden-sm-and-down"
><span>File</span></v-col
>
<v-col md="1" lg="1" class="hidden-sm-and-down"
><span>Platform</span></v-col
>
<v-col sm="2" md="2" lg="2" class="hidden-xs"
><span>Size</span></v-col
>
<v-col sm="1" md="1" lg="1" class="hidden-xs"
><span>Reg</span></v-col
>
<v-col sm="1" md="1" lg="1" class="hidden-xs"
><span>Rev</span></v-col
>
</v-row>
<template v-slot:prepend>
<v-avatar></v-avatar>
</template>
</v-list-item>
</v-col>
<v-col cols="3" xs="3" sm="1" md="1" lg="1" class="mr-4">
<v-btn size="x-small" variant="text" disabled />
<v-btn size="x-small" variant="text" disabled />
<v-btn size="x-small" variant="text" disabled />
</v-col>
</v-row>
</thead>
</template>

View File

@@ -1,111 +0,0 @@
<script setup>
import { ref, inject } from "vue";
import { downloadRomApi } from "@/services/api.js";
import useDownloadStore from "@/stores/download.js";
import AdminMenu from "@/components/AdminMenu/Base.vue";
// Props
const emitter = inject("emitter");
const props = defineProps(["rom"]);
const saveFiles = ref(false);
const downloadStore = useDownloadStore();
const downloadUrl = `${window.location.origin}${props.rom.download_path}`;
</script>
<template>
<v-row no-gutters>
<v-col>
<v-list-item
:to="`/platform/${$route.params.platform}/${rom.id}`"
:value="rom.id"
:key="rom.id"
>
<v-row class="text-subtitle-2 align-center">
<v-col cols="9" xs="9" sm="6" md="3" lg="3"
><span>{{ rom.r_name }}</span></v-col
>
<v-col md="4" lg="4" class="hidden-sm-and-down"
><span>{{ rom.file_name }}</span></v-col
>
<v-col md="1" lg="1" class="hidden-sm-and-down"
><span>{{ rom.p_slug }}</span></v-col
>
<v-col sm="2" md="2" lg="2" class="hidden-xs"
><span>{{ rom.file_size }} {{ rom.file_size_units }}</span></v-col
>
<v-col sm="1" md="1" lg="1" class="hidden-xs"
><span>{{ rom.region }}</span></v-col
>
<v-col sm="1" md="1" lg="1" class="hidden-xs"
><span>{{ rom.revision }}</span></v-col
>
</v-row>
<template v-slot:prepend>
<v-avatar :rounded="0">
<v-progress-linear
color="rommAccent1"
:active="downloadStore.value.includes(rom.id)"
:indeterminate="true"
absolute
/>
<v-img
:src="`/assets/romm/resources/${rom.path_cover_s}`"
:lazy-src="`/assets/romm/resources/${rom.path_cover_s}`"
min-height="150"
/>
</v-avatar>
</template>
</v-list-item>
</v-col>
<v-col
cols="3"
xs="3"
sm="1"
md="1"
lg="1"
class="d-flex justify-center align-center mr-4"
>
<template v-if="rom.multi">
<v-btn
@click="downloadRomApi(rom)"
:disabled="downloadStore.value.includes(rom.id)"
icon="mdi-download"
size="x-small"
rounded="0"
variant="text"
/>
</template>
<template v-else>
<v-btn
:href="downloadUrl"
download
icon="mdi-download"
size="x-small"
rounded="0"
variant="text"
/>
</template>
<v-btn
icon="mdi-content-save-all"
size="x-small"
rounded="0"
variant="text"
:disabled="!saveFiles"
/>
<v-menu location="bottom">
<template v-slot:activator="{ props }">
<v-btn
@click=""
v-bind="props"
icon="mdi-dots-vertical"
size="x-small"
rounded="0"
variant="text"
/>
</template>
<admin-menu :rom="rom" />
</v-menu>
</v-col>
</v-row>
</template>

View File

@@ -16,14 +16,14 @@ emitter.on("snackbarShow", (snackbar) => {
<template>
<v-snackbar
v-model="snackbarShow"
:timeout="2000"
:timeout="snackbarStatus.timeout ? snackbarStatus.timeout : 2000"
location="top"
color="tooltip"
>
<v-icon
:icon="snackbarStatus.icon"
:color="snackbarStatus.color"
class="ml-2 mr-2"
class="mx-2"
/>
{{ snackbarStatus.msg }}
<template v-slot:actions>

View File

@@ -9,15 +9,16 @@ const props = defineProps(["platform"]);
<v-hover v-slot="{ isHovering, props }">
<v-card
v-bind="props"
class="bg-terciary"
:class="{ 'on-hover': isHovering }"
:elevation="isHovering ? 20 : 3"
>
<v-card-text>
<v-row class="pa-1 justify-center bg-secondary">
<span class="text-truncate text-overline">{{ platform.slug }}</span>
<v-row class="pa-1 justify-center bg-primary">
<div class="px-2 text-truncate text-overline">{{ platform.slug }}</div>
</v-row>
<v-row class="pa-1 justify-center">
<v-avatar :rounded="0" size="100%" class="mt-2">
<v-avatar :rounded="0" size="105" class="mt-2">
<platform-icon :platform="platform"></platform-icon>
</v-avatar>
<v-chip

View File

@@ -2,7 +2,9 @@
import { ref } from "vue";
const props = defineProps(["platform"]);
const platformIconUrl = ref(`/assets/platforms/${props.platform.slug.toLowerCase()}.ico`);
const platformIconUrl = ref(
`/assets/platforms/${props.platform.slug.toLowerCase()}.ico`
);
function onImageError() {
platformIconUrl.value = "/assets/platforms/default.ico";
@@ -10,5 +12,5 @@ function onImageError() {
</script>
<template>
<v-img :src="platformIconUrl" @error="onImageError"></v-img>
<v-img :src="platformIconUrl" @error="onImageError" />
</template>

View File

@@ -11,20 +11,16 @@ import PlatformIcon from "./PlatformIcon.vue";
:key="platform"
class="pt-4 pb-4 bg-terciary"
>
<span class="text-body-2 text-truncate">{{
rail ? "" : platform.name
}}</span>
<span v-if="!rail" class="text-body-2">{{ platform.name }}</span>
<template v-slot:prepend>
<v-avatar :rounded="0" size="40">
<platform-icon :platform="platform"></platform-icon>
</v-avatar>
</template>
<template v-slot:append>
<v-chip class="ml-4 bg-chip" size="x-small" label>{{
<v-chip v-if="!rail" class="ml-4 bg-chip" size="x-small" label>{{
platform.n_roms
}}</v-chip>
</template>
</v-list-item>
</template>
<style scoped></style>

View File

@@ -1,12 +1,20 @@
import { createApp } from "vue";
import { createVuetify } from "vuetify";
import { registerPlugins } from "@/plugins";
import App from "./App.vue";
// Event bus
import mitt from "mitt";
const emitter = mitt();
export default createVuetify({
defaults: {
VBtn: {
rounded: 0, // TODO: Vuetify global configuration not working
},
},
});
const app = createApp(App);
registerPlugins(app);

View File

@@ -2,31 +2,51 @@
import { createRouter, createWebHistory } from "vue-router";
const routes = [
{
path: "/login",
name: "login",
component: () => import("@/views/Login.vue"),
},
{
path: "/",
name: "home",
component: () => import("@/views/Home.vue"),
},
{
path: "/platform/:platform",
component: () => import("@/views/Gallery.vue"),
},
{
path: "/platform/:platform/:rom",
component: () => import("@/views/Details.vue"),
},
{
path: "/library/scan",
component: () => import("@/views/library/Scan.vue"),
},
{
path: "/settings/control-panel",
component: () => import("@/views/settings/ControlPanel.vue"),
children: [
{
path: "/",
name: "dashboard",
component: () => import("@/views/Dashboard/Base.vue"),
},
{
path: "/platform/:platform",
name: "platform",
component: () => import("@/views/Gallery/Base.vue"),
},
{
path: "/platform/:platform/:rom",
name: "rom",
component: () => import("@/views/Details/Base.vue"),
},
{
path: "/library/scan",
name: "scan",
component: () => import("@/views/Library/Scan.vue"),
},
{
path: "/settings/control-panel",
name: "controlPanel",
component: () => import("@/views/Settings/ControlPanel.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "noMatch",
component: () => import("@/views/Dashboard/Base.vue"),
},
],
},
];
const router = createRouter({
export default createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});
export default router;

View File

@@ -7,7 +7,7 @@
// Styles
import "@mdi/font/css/materialdesignicons.css";
import "vuetify/styles";
import { rommDark, rommLight } from "@/styles/themes.js";
import { rommDark, rommLight } from "@/styles/themes";
// Composables
import { createVuetify } from "vuetify";

View File

@@ -1,9 +1,22 @@
import axios from "axios";
import useDownloadStore from "@/stores/download.js";
import socket from "@/services/socket.js";
import storeDownload from "@/stores/download";
import socket from "@/services/socket";
import router from "@/plugins/router";
export const api = axios.create({ baseURL: "/api", timeout: 120000 });
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response.status === 403) {
router.push(`/login?next=${router.currentRoute.value.path}`);
}
return Promise.reject(error);
}
);
export async function fetchPlatformsApi() {
return axios.get("/api/platforms");
return api.get("/platforms");
}
export async function fetchRomsApi({
@@ -12,17 +25,17 @@ export async function fetchRomsApi({
size = 60,
searchTerm = "",
}) {
return axios.get(
`/api/platforms/${platform}/roms?cursor=${cursor}&size=${size}&search_term=${searchTerm}`
return api.get(
`/platforms/${platform}/roms?cursor=${cursor}&size=${size}&search_term=${searchTerm}`
);
}
export async function fetchRomApi(platform, rom) {
return axios.get(`/api/platforms/${platform}/roms/${rom}`);
return api.get(`/platforms/${platform}/roms/${rom}`);
}
function clearRomFromDownloads({ id }) {
const downloadStore = useDownloadStore();
const downloadStore = storeDownload();
downloadStore.remove(id);
// Disconnect socket when no more downloads are in progress
@@ -46,13 +59,16 @@ export async function downloadRomApi(rom, files) {
a.download = `${rom.r_name}.zip`;
a.click();
if (!socket.connected) socket.connect();
useDownloadStore().add(rom.id);
// Only connect socket if multi-file download
if (rom.multi) {
if (!socket.connected) socket.connect();
storeDownload().add(rom.id);
// Clear download state after 60 seconds in case error/timeout
setTimeout(() => {
clearRomFromDownloads(rom);
}, 60 * 1000);
// Clear download state after 60 seconds in case error/timeout
setTimeout(() => {
clearRomFromDownloads(rom);
}, 60 * 1000);
}
}
export async function updateRomApi(rom, updatedData, renameAsIGDB) {
@@ -67,20 +83,70 @@ export async function updateRomApi(rom, updatedData, renameAsIGDB) {
? rom.file_name.replace(rom.file_name_no_tags, updatedData.r_name)
: updatedData.file_name,
};
return axios.patch(`/api/platforms/${rom.p_slug}/roms/${rom.id}`, {
return api.patch(`/platforms/${rom.p_slug}/roms/${rom.id}`, {
updatedRom,
});
}
export async function deleteRomApi(rom, deleteFromFs) {
return axios.delete(
`/api/platforms/${rom.p_slug}/roms/${rom.id}?filesystem=${deleteFromFs}`
return api.delete(
`/platforms/${rom.p_slug}/roms/${rom.id}?filesystem=${deleteFromFs}`
);
}
export async function deleteRomsApi(roms, deleteFromFs) {
return api.post(
`/platforms/${roms[0].p_slug}/roms/delete?filesystem=${deleteFromFs}`,
{ roms: roms.map((r) => r.id) }
);
}
export async function searchRomIGDBApi(searchTerm, searchBy, rom) {
return axios.put(
`/api/search/roms/igdb?search_term=${searchTerm}&search_by=${searchBy}`,
return api.put(
`/search/roms/igdb?search_term=${searchTerm}&search_by=${searchBy}`,
{ rom: rom }
);
}
export async function fetchCurrentUserApi() {
return api.get("/users/me");
}
export async function fetchUsersApi() {
return api.get("/users");
}
export async function fetchUserApi(user) {
return api.get(`/users/${user.id}`);
}
export async function createUserApi({ username, password, role }) {
return api.post("/users", {}, { params: { username, password, role } });
}
export async function updateUserApi({
id,
username,
password,
role,
enabled,
avatar,
}) {
return api.put(
`/users/${id}`,
{
avatar: avatar ? avatar[0] : null,
},
{
headers: {
"Content-Type": avatar ? "multipart/form-data" : "text/text",
},
params: { username, password, role, enabled },
}
);
}
export async function deleteUserApi(user) {
return api.delete(`/users/${user.id}`);
}

View File

@@ -0,0 +1,32 @@
import { defineStore } from "pinia";
const FULL_SCOPES_LIST = [
"me.read",
"me.write",
"roms.read",
"roms.write",
"platforms.read",
"platforms.write",
"users.read",
"users.write",
];
export default defineStore("auth", {
state: () => ({ enabled: false, user: null, oauth_scopes: [] }),
getters: {
scopes: (state) => {
if (!state.enabled) return FULL_SCOPES_LIST;
return state.user?.oauth_scopes ?? [];
},
},
actions: {
setUser(user) {
this.user = user;
},
setEnabled(enabled) {
this.enabled = enabled;
},
},
});

View File

@@ -1,5 +1,5 @@
import { defineStore } from "pinia";
import { normalizeString } from "@/utils/utils.js";
import { normalizeString } from "@/utils/utils";
export default defineStore("galleryFilter", {
state: () => ({ value: "" }),

View File

@@ -0,0 +1,26 @@
import { defineStore } from "pinia";
export default defineStore("roms", {
state: () => ({ selected: [], lastSelectedIndex: -1 }),
actions: {
updateSelectedRoms(roms) {
this.selected = roms;
},
addSelectedRoms(rom) {
this.selected.push(rom);
},
removeSelectedRoms(rom) {
this.selected = this.selected.filter(function (value) {
return value.id != rom.id;
});
},
updateLastSelectedRom(index) {
this.lastSelectedIndex = index;
},
reset(){
this.selected = [];
this.lastSelectedIndex = -1;
}
},
});

View File

@@ -1,12 +1,12 @@
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #6e6e6e rgba(0, 0, 0, 0);
scrollbar-color: #202832 rgba(0, 0, 0, 0);
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 3px;
width: 1px;
}
*::-webkit-scrollbar-track {
@@ -15,5 +15,5 @@
*::-webkit-scrollbar-thumb {
background-color: #808080;
border-radius: 5px;
border-radius: 1px;
}

View File

@@ -1,3 +1,15 @@
const commonColors = {
"romm-accent-1": "#a452fe",
"romm-accent-2": "#c400f7",
"romm-accent-3": "#3808a4",
"romm-red": "#da3633",
"romm-green": "#3FB950",
"romm-white": "#fefdfe",
"romm-gray": "#5D5D5D",
"romm-black": "#000000",
};
export const rommDark = {
dark: true,
colors: {
@@ -10,14 +22,7 @@ export const rommDark = {
tooltip: "#202832",
chip: "#161b22",
rommAccent1: "#a452fe",
rommAccent2: "#c400f7",
rommAccent3: "#3808a4",
rommRed: "#da3633",
rommGreen: "#3FB950",
rommWhite: "#fefdfe",
rommBlack: "#000000",
...commonColors,
},
};
@@ -33,13 +38,11 @@ export const rommLight = {
tooltip: "#fefdfe",
chip: "#fefdfe",
rommAccent1: "#a452fe",
rommAccent2: "#c400f7",
rommAccent3: "#3808a4",
rommRed: "#da3633",
rommGreen: "#3FB950",
rommWhite: "#fefdfe",
rommBlack: "#000000",
...commonColors,
},
};
export const themes = {
0: "rommDark",
1: "rommLight",
};

View File

@@ -34,3 +34,5 @@ export function normalizeString(s) {
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export const defaultAvatarPath = "/assets/default_avatar.png";

View File

@@ -0,0 +1,26 @@
<script setup>
import storePlatforms from "@/stores/platforms";
import dashboardSummary from "@/views/Dashboard/Summary.vue"
import dashboardPlatforms from "@/views/Dashboard/Platforms.vue"
// Props
const platforms = storePlatforms();
</script>
<template>
<!-- Summary -->
<v-row class="pa-1" no-gutters>
<v-col>
<dashboard-summary />
</v-col>
</v-row>
<!-- Platforms -->
<template v-if="platforms.value.length > 0">
<v-row class="pa-1" no-gutters>
<v-col>
<dashboard-platforms />
</v-col>
</v-row>
</template>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import { views } from "@/utils/utils";
import PlatformCard from "@/components/Platform/PlatformCard.vue";
import storePlatforms from "@/stores/platforms";
// Props
const platforms = storePlatforms();
</script>
<template>
<v-card rounded="0">
<v-toolbar class="bg-terciary" density="compact"
><v-toolbar-title class="text-button"
><v-icon class="mr-3">mdi-controller</v-icon>Platforms</v-toolbar-title
></v-toolbar
>
<v-divider class="border-opacity-25" />
<v-card-text>
<v-row>
<v-col
v-for="platform in platforms.value"
class="pa-1"
:key="platform.slug"
:cols="views[0]['size-cols']"
:xs="views[0]['size-xs']"
:sm="views[0]['size-sm']"
:md="views[0]['size-md']"
:lg="views[0]['size-lg']"
>
<platform-card :platform="platform" :key="platform.slug" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import storePlatforms from "@/stores/platforms";
// Props
const platforms = storePlatforms();
</script>
<template>
<v-card rounded="0">
<v-card-text class="py-0 px-2">
<v-chip-group>
<v-chip class="text-overline" variant="text" label>
<v-icon class="mr-2">mdi-controller</v-icon
>{{ platforms.value.length }} Platforms
</v-chip>
<v-chip class="text-overline" variant="text" label>
<v-icon class="mr-2">mdi-disc</v-icon>{{ platforms.totalGames }} Games
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</template>

View File

@@ -2,18 +2,20 @@
import { ref, inject, onBeforeMount } from "vue";
import { useRoute } from "vue-router";
import { useDisplay } from "vuetify";
import { fetchRomApi, downloadRomApi } from "@/services/api.js";
import useDownloadStore from "@/stores/download.js";
import { fetchRomApi, downloadRomApi } from "@/services/api";
import storeDownload from "@/stores/download";
import storeAuth from "@/stores/auth";
import BackgroundHeader from "@/components/Game/Details/BackgroundHeader.vue";
import AdminMenu from "@/components/AdminMenu/Base.vue";
import SearchRomDialog from "@/components/Dialog/SearchRom.vue";
import EditRomDialog from "@/components/Dialog/EditRom.vue";
import DeleteRomDialog from "@/components/Dialog/DeleteRom.vue";
import SearchRomDialog from "@/components/Dialog/Rom/SearchRom.vue";
import EditRomDialog from "@/components/Dialog/Rom/EditRom.vue";
import DeleteRomDialog from "@/components/Dialog/Rom/DeleteRom.vue";
import LoadingDialog from "@/components/Dialog/Loading.vue";
// Props
const route = useRoute();
const downloadStore = useDownloadStore();
const downloadStore = storeDownload();
const auth = storeAuth();
const rom = ref();
const updatedRom = ref();
const saveFiles = ref(false);
@@ -68,7 +70,7 @@ onBeforeMount(async () => {
elevation="2"
:loading="
downloadStore.value.includes(rom.id)
? 'rommAccent1'
? 'romm-accent-1'
: null
"
>
@@ -80,7 +82,7 @@ onBeforeMount(async () => {
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="rommAccent1"
color="romm-accent-1"
:width="2"
:size="20"
indeterminate
@@ -91,7 +93,7 @@ onBeforeMount(async () => {
</v-card>
</v-col>
</v-row>
<v-row class="pl-3 pr-3 action-buttons">
<v-row class="px-3 action-buttons">
<v-col class="pa-0">
<template v-if="rom.multi">
<v-btn
@@ -124,7 +126,7 @@ onBeforeMount(async () => {
<v-col class="pa-0">
<v-menu location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" rounded="0" block>
<v-btn :disabled="!auth.scopes.includes('roms.write')" v-bind="props" rounded="0" block>
<v-icon icon="mdi-dots-vertical" size="large" />
</v-btn>
</template>
@@ -188,7 +190,7 @@ onBeforeMount(async () => {
'details-content-mobile': xs,
}"
>
<v-tabs v-model="tab" slider-color="rommAccent1" rounded="0">
<v-tabs v-model="tab" slider-color="romm-accent-1" rounded="0">
<v-tab value="details" rounded="0">Details</v-tab>
<v-tab value="saves" rounded="0" disabled
>Saves<span class="text-caption text-truncate ml-1"
@@ -240,7 +242,7 @@ onBeforeMount(async () => {
item-title="file_name"
v-model="filesToDownload"
:items="rom.files"
class="mt-2 mb-2"
class="my-2"
density="compact"
variant="outlined"
return-object
@@ -324,7 +326,7 @@ onBeforeMount(async () => {
<v-carousel
hide-delimiter-background
delimiter-icon="mdi-square"
class="bg-rommBlack"
class="bg-romm-black"
show-arrows="hover"
height="400"
>

View File

@@ -1,258 +0,0 @@
<script setup>
import { ref, inject, onMounted } from "vue";
import { onBeforeRouteUpdate, useRoute } from "vue-router";
import { fetchRomsApi } from "@/services/api.js";
import socket from "@/services/socket.js";
import { views, normalizeString } from "@/utils/utils.js";
import storeGalleryFilter from "@/stores/galleryFilter.js";
import storeGalleryView from "@/stores/galleryView.js";
import storeScanning from "@/stores/scanning.js";
import FilterBar from "@/components/GalleryAppBar/FilterBar.vue";
import GalleryViewBtn from "@/components/GalleryAppBar/GalleryViewBtn.vue";
import GameCard from "@/components/Game/Card/Base.vue";
import GameListHeader from "@/components/Game/ListItem/Header.vue";
import GameListItem from "@/components/Game/ListItem/Item.vue";
import SearchRomDialog from "@/components/Dialog/SearchRom.vue";
import EditRomDialog from "@/components/Dialog/EditRom.vue";
import DeleteRomDialog from "@/components/Dialog/DeleteRom.vue";
import LoadingDialog from "@/components/Dialog/Loading.vue";
// Props
const route = useRoute();
const roms = ref([]);
const updatedRom = ref([]);
const searchRoms = ref([]);
const filteredRoms = ref([]);
const galleryView = storeGalleryView();
const galleryFilter = storeGalleryFilter();
const gettingRoms = ref(false);
const scanning = storeScanning();
const cursor = ref("");
const searchCursor = ref("");
// Event listeners bus
const emitter = inject("emitter");
emitter.on("filter", onFilterChange);
socket.on("scan:done", () => {
scanning.set(false);
emitter.emit("snackbarShow", {
msg: "Scan completed successfully!",
icon: "mdi-check-bold",
color: "green",
});
socket.disconnect();
emitter.emit("refreshPlatforms");
emitter.emit("refreshGallery");
});
socket.on("scan:done_ko", (msg) => {
scanning.set(false);
emitter.emit("snackbarShow", {
msg: `Scan couldn't be completed. Something went wrong: ${msg}`,
icon: "mdi-close-circle",
color: "red",
});
socket.disconnect();
});
// Functions
async function scan() {
scanning.set(true);
emitter.emit("snackbarShow", {
msg: `Scanning ${route.params.platform}...`,
icon: "mdi-loading mdi-spin",
color: "rommAccent1",
});
if (!socket.connected) socket.connect();
socket.emit("scan", route.params.platform, false);
}
async function fetchMoreSearch() {
if (searchCursor.value === null || gettingRoms.value) return;
gettingRoms.value = true;
emitter.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
await fetchRomsApi({
platform: route.params.platform,
cursor: searchCursor.value,
searchTerm: normalizeString(galleryFilter.value),
})
.then((response) => {
searchRoms.value = [...searchRoms.value, ...response.data.items];
filteredRoms.value = searchRoms.value;
searchCursor.value = response.data.next_page;
})
.catch((error) => {
console.error(
`Couldn't fetch roms for ${route.params.platform}: ${error}`
);
})
.finally(() => {
gettingRoms.value = false;
emitter.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
});
}
async function fetchMoreRoms(platform) {
if (cursor.value === null || gettingRoms.value) return;
gettingRoms.value = true;
emitter.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
await fetchRomsApi({ platform, cursor: cursor.value })
.then((response) => {
roms.value = [...roms.value, ...response.data.items];
filteredRoms.value = roms.value;
cursor.value = response.data.next_page;
})
.catch((error) => {
console.error(`Couldn't fetch roms for ${platform}: ${error}`);
})
.finally(() => {
gettingRoms.value = false;
emitter.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
});
}
function onFilterChange() {
searchCursor.value = "";
searchRoms.value = [];
if (galleryFilter.value === "") {
filteredRoms.value = roms.value;
return;
}
fetchMoreSearch();
}
function onGridScroll() {
if (cursor.value === null && searchCursor.value === null) return;
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const scrollOffset = 60;
// If we are close at the bottom of the page, fetch more roms
if (scrollTop + clientHeight + scrollOffset >= scrollHeight) {
galleryFilter.value
? fetchMoreSearch()
: fetchMoreRoms(route.params.platform);
}
}
onMounted(async () => {
fetchMoreRoms(route.params.platform);
});
onBeforeRouteUpdate(async (to, _) => {
cursor.value = "";
searchCursor.value = "";
roms.value = [];
searchRoms.value = [];
filteredRoms.value = [];
fetchMoreRoms(to.params.platform);
});
</script>
<template>
<v-app-bar id="gallery-app-bar" elevation="0" density="compact">
<filter-bar />
<gallery-view-btn />
<v-btn
@click="scan"
rounded="0"
variant="text"
class="mr-0"
icon="mdi-magnify-scan"
/>
</v-app-bar>
<template v-if="filteredRoms.length > 0 || gettingRoms">
<!-- Gallery cards view -->
<v-row
id="grid-view"
v-show="galleryView.value != 2"
no-gutters
v-scroll="onGridScroll"
>
<v-col
v-for="rom in filteredRoms"
class="pa-1"
:key="rom.id"
:cols="views[galleryView.value]['size-cols']"
:xs="views[galleryView.value]['size-xs']"
:sm="views[galleryView.value]['size-sm']"
:md="views[galleryView.value]['size-md']"
:lg="views[galleryView.value]['size-lg']"
>
<game-card :rom="rom" />
</v-col>
</v-row>
<!-- Gallery list view -->
<v-row v-show="galleryView.value == 2" no-gutters>
<v-col
:cols="views[galleryView.value]['size-cols']"
:xs="views[galleryView.value]['size-xs']"
:sm="views[galleryView.value]['size-sm']"
:md="views[galleryView.value]['size-md']"
:lg="views[galleryView.value]['size-lg']"
>
<v-table class="bg-secondary">
<game-list-header />
<v-divider
class="border-opacity-100 mb-4 ml-2 mr-2"
color="rommAccent1"
:thickness="1"
/>
<tbody>
<v-list class="bg-secondary">
<v-list-item
v-for="item in filteredRoms"
:key="item.id"
:value="item.id"
>
<game-list-item :rom="item" />
</v-list-item>
</v-list>
</tbody>
</v-table>
</v-col>
</v-row>
</template>
<!-- Empty gallery message -->
<template v-else>
<v-row class="fill-height justify-center align-center" no-gutters>
<div class="text-h6">
Feels empty here... <v-icon>mdi-emoticon-sad</v-icon>
</div>
</v-row>
</template>
<search-rom-dialog />
<edit-rom-dialog />
<delete-rom-dialog />
<loading-dialog />
</template>
<style scoped>
#gallery-app-bar {
z-index: 999 !important;
}
</style>

View File

@@ -0,0 +1,316 @@
<script setup>
import { ref, inject, onMounted, onBeforeUnmount } from "vue";
import { onBeforeRouteUpdate, useRoute } from "vue-router";
import { fetchRomsApi } from "@/services/api";
import socket from "@/services/socket";
import { views, normalizeString } from "@/utils/utils";
import storeGalleryFilter from "@/stores/galleryFilter";
import storeGalleryView from "@/stores/galleryView";
import storeRoms from "@/stores/roms";
import storeScanning from "@/stores/scanning";
import FilterBar from "@/components/GalleryAppBar/FilterBar.vue";
import GalleryViewBtn from "@/components/GalleryAppBar/GalleryViewBtn.vue";
import GameCard from "@/components/Game/Card/Base.vue";
import GameDataTable from "@/components/Game/DataTable/Base.vue";
import SearchRomDialog from "@/components/Dialog/Rom/SearchRom.vue";
import EditRomDialog from "@/components/Dialog/Rom/EditRom.vue";
import DeleteRomDialog from "@/components/Dialog/Rom/DeleteRom.vue";
import LoadingDialog from "@/components/Dialog/Loading.vue";
import FabMenu from "@/components/FabMenu/Base.vue";
// Props
const route = useRoute();
const roms = ref([]);
const searchRoms = ref([]);
const filteredRoms = ref([]);
const galleryView = storeGalleryView();
const galleryFilter = storeGalleryFilter();
const gettingRoms = ref(false);
const scanning = storeScanning();
const cursor = ref("");
const searchCursor = ref("");
const romsStore = storeRoms();
const fabMenu = ref(false);
const scrolledToTop = ref(true);
// Event listeners bus
const emitter = inject("emitter");
emitter.on("filter", onFilterChange);
emitter.on("openFabMenu", (open) => {
fabMenu.value = open;
});
socket.on("scan:done", () => {
scanning.set(false);
emitter.emit("snackbarShow", {
msg: "Scan completed successfully!",
icon: "mdi-check-bold",
color: "green",
});
socket.disconnect();
emitter.emit("refreshDrawer");
emitter.emit("refreshView");
});
socket.on("scan:done_ko", (msg) => {
scanning.set(false);
emitter.emit("snackbarShow", {
msg: `Scan couldn't be completed. Something went wrong: ${msg}`,
icon: "mdi-close-circle",
color: "red",
});
socket.disconnect();
});
// Functions
async function scan() {
scanning.set(true);
emitter.emit("snackbarShow", {
msg: `Scanning ${route.params.platform}...`,
icon: "mdi-loading mdi-spin",
color: "romm-accent-1",
});
if (!socket.connected) socket.connect();
socket.emit("scan", {
platforms: [route.params.platform],
rescan: false,
});
}
async function fetchRoms(platform) {
const isFiltered = normalizeString(galleryFilter.value).trim() != "";
if (
(searchCursor.value === null && isFiltered) ||
(cursor.value === null && !isFiltered) ||
gettingRoms.value
)
return;
gettingRoms.value = true;
emitter.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
await fetchRomsApi({
platform: platform,
cursor: isFiltered ? searchCursor.value : cursor.value,
searchTerm: normalizeString(galleryFilter.value),
})
.then((response) => {
if (isFiltered) {
searchCursor.value = response.data.next_page;
searchRoms.value = [...searchRoms.value, ...response.data.items];
filteredRoms.value = searchRoms.value;
} else {
cursor.value = response.data.next_page;
roms.value = [...roms.value, ...response.data.items];
filteredRoms.value = roms.value;
}
})
.catch((error) => {
console.error(`Couldn't fetch roms for ${platform}: ${error}`);
})
.finally(() => {
gettingRoms.value = false;
emitter.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
});
}
function onFilterChange() {
searchCursor.value = "";
searchRoms.value = [];
if (galleryFilter.value === "") {
filteredRoms.value = roms.value;
return;
}
fetchRoms(route.params.platform);
}
function onScroll() {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
scrolledToTop.value = scrollTop === 0;
if (!cursor.value && !searchCursor.value) return;
const scrollOffset = 60;
if (scrollTop + clientHeight + scrollOffset >= scrollHeight) {
fetchRoms(route.params.platform);
}
}
function toTop() {
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}
function selectRom({ event, index, selected }) {
if (event.shiftKey) {
const [start, end] = [romsStore.lastSelectedIndex, index].sort(
(a, b) => a - b
);
if (selected) {
for (let i = start + 1; i < end; i++) {
romsStore.addSelectedRoms(filteredRoms.value[i]);
}
} else {
for (let i = start; i <= end; i++) {
romsStore.removeSelectedRoms(filteredRoms.value[i]);
}
}
romsStore.updateLastSelectedRom(selected ? index : index - 1);
} else {
romsStore.updateLastSelectedRom(index);
}
emitter.emit("refreshSelected");
}
onMounted(async () => {
fetchRoms(route.params.platform);
});
onBeforeUnmount(() => {
socket.off("scan:scanning_rom");
socket.off("scan:done");
socket.off("scan:done_ko");
romsStore.reset();
});
onBeforeRouteUpdate(async (to, _) => {
cursor.value = "";
searchCursor.value = "";
roms.value = [];
searchRoms.value = [];
filteredRoms.value = [];
romsStore.reset();
fetchRoms(to.params.platform);
});
</script>
<template>
<v-app-bar id="gallery-app-bar" elevation="0" density="compact">
<filter-bar />
<gallery-view-btn />
<v-btn
@click="scan"
rounded="0"
variant="text"
class="mr-0"
icon="mdi-magnify-scan"
/>
</v-app-bar>
<template v-if="filteredRoms.length > 0">
<v-row no-gutters v-scroll="onScroll">
<!-- Gallery cards view -->
<v-col
v-show="galleryView.value != 2"
v-for="rom in filteredRoms"
class="pa-1"
:key="rom.id"
:cols="views[galleryView.value]['size-cols']"
:xs="views[galleryView.value]['size-xs']"
:sm="views[galleryView.value]['size-sm']"
:md="views[galleryView.value]['size-md']"
:lg="views[galleryView.value]['size-lg']"
>
<game-card
:rom="rom"
:index="filteredRoms.indexOf(rom)"
@selectRom="selectRom"
/>
</v-col>
<!-- Gallery list view -->
<v-col v-show="galleryView.value == 2">
<game-data-table :filteredRoms="filteredRoms" />
</v-col>
</v-row>
</template>
<!-- Empty gallery message -->
<template v-if="filteredRoms.length == 0 && !gettingRoms">
<v-row class="align-center justify-center" no-gutters>
<v-col cols="6" md="2">
<div class="mt-16">
Feels empty here... <v-icon>mdi-emoticon-sad</v-icon>
</div>
</v-col>
</v-row>
</template>
<v-layout-item
v-scroll="onScroll"
class="text-end"
:model-value="true"
position="bottom"
size="88"
>
<div class="ma-4">
<v-scroll-y-reverse-transition>
<v-btn
id="scrollToTop"
v-show="!scrolledToTop"
color="primary"
elevation="8"
icon
class="mr-2"
size="large"
@click="toTop"
><v-icon color="romm-accent-2">mdi-chevron-up</v-icon></v-btn
>
</v-scroll-y-reverse-transition>
<v-menu
location="top"
v-model="fabMenu"
:transition="
fabMenu ? 'scroll-y-reverse-transition' : 'scroll-y-transition'
"
>
<template v-slot:activator="{ props }">
<v-fab-transition>
<v-btn
v-show="romsStore.selected.length > 0"
color="romm-accent-1"
v-bind="props"
elevation="8"
icon
size="large"
>{{ romsStore.selected.length }}</v-btn
>
</v-fab-transition>
</template>
<fab-menu :filteredRoms="filteredRoms" />
</v-menu>
</div>
</v-layout-item>
<search-rom-dialog />
<edit-rom-dialog />
<delete-rom-dialog />
<loading-dialog />
</template>
<style scoped>
#gallery-app-bar {
z-index: 999 !important;
}
.game-card.game-selected {
border: 2px solid rgba(var(--v-theme-romm-accent-2));
padding: 0;
}
#scrollToTop {
border: 1px solid rgba(var(--v-theme-romm-accent-2));
}
</style>

View File

@@ -1,78 +1,65 @@
<script setup>
import { ref, inject, onMounted } from "vue";
import { useDisplay } from "vuetify";
import { views } from "@/utils/utils.js";
import storePlatforms from "@/stores/platforms.js";
import PlatformCard from "@/components/Platform/PlatformCard.vue";
import { fetchPlatformsApi, fetchCurrentUserApi } from "@/services/api";
import storePlatforms from "@/stores/platforms";
import storeScanning from "@/stores/scanning";
import storeAuth from "@/stores/auth";
import Drawer from "@/components/Drawer/Base.vue";
import AppBar from "@/components/AppBar/Base.vue";
// Props
const { mdAndDown } = useDisplay();
const platforms = storePlatforms();
const totalGames = platforms.totalGames;
const { lgAndUp } = useDisplay();
const scanning = storeScanning();
const auth = storeAuth();
const refreshView = ref(false);
const refreshDrawer = ref(false);
// Event listeners bus
const emitter = inject("emitter");
emitter.on("refreshView", () => {
refreshView.value = !refreshView.value;
});
emitter.on("refreshDrawer", () => {
refreshDrawer.value = !refreshDrawer.value;
});
// Functions
onMounted(async () => {
try {
const { data: platformData } = await fetchPlatformsApi();
platforms.set(platformData);
const { data: userData } = await fetchCurrentUserApi();
if (userData) auth.setUser(userData);
emitter.emit("refreshDrawer");
} catch (error) {
console.error(error);
}
});
</script>
<template>
<!-- Header logo -->
<v-row class="pa-2" no-gutters>
<v-spacer />
<v-col cols="12" xs="12" sm="10" md="10" lg="10">
<v-img
:height="lgAndUp ? 200 : 150"
src="/assets/romm_complete.svg"
cover
/>
</v-col>
<v-spacer />
</v-row>
<v-progress-linear
id="scan-progress-bar"
color="romm-accent-1"
:active="scanning.value"
:indeterminate="true"
absolute
fixed
/>
<!-- Info chips -->
<v-row class="pa-2" no-gutters>
<v-spacer />
<v-col
cols="12"
xs="12"
sm="10"
md="10"
lg="10"
class="d-flex justify-center"
>
<v-chip-group>
<v-chip class="bg-chip" label>
<span class="text-overline">
{{ platforms.value.length }} platforms
</span>
</v-chip>
<v-chip class="bg-chip" label>
<span class="text-overline">{{ totalGames }} games</span>
</v-chip>
</v-chip-group>
</v-col>
<v-spacer />
</v-row>
<drawer :key="refreshDrawer" />
<template v-if="platforms.value.length > 0">
<!-- Platforms section title -->
<v-row class="pa-2" no-gutters>
<v-avatar :rounded="0" size="auto">
<v-icon>mdi-controller</v-icon>
</v-avatar>
<span class="text-h6 ml-2">Platforms</span>
<v-divider class="border-opacity-25" />
</v-row>
<app-bar v-if="mdAndDown" />
<!-- Platforms cards -->
<v-row class="pa-2" no-gutters>
<v-col
v-for="platform in platforms.value"
class="pa-1"
:key="platform.slug"
:cols="views[0]['size-cols']"
:xs="views[0]['size-xs']"
:sm="views[0]['size-sm']"
:md="views[0]['size-md']"
:lg="views[0]['size-lg']"
>
<platform-card :platform="platform" :key="platform.slug" />
</v-col>
</v-row>
</template>
<v-container class="pa-1" fluid>
<router-view :key="refreshView" />
</v-container>
</template>
<style scoped>
#scan-progress-bar {
z-index: 1000 !important;
}
</style>

View File

@@ -1,8 +1,8 @@
<script setup>
import { ref, inject } from "vue";
import socket from "@/services/socket.js";
import storePlatforms from "@/stores/platforms.js";
import storeScanning from "@/stores/scanning.js";
import { ref, inject, onBeforeUnmount } from "vue";
import socket from "@/services/socket";
import storePlatforms from "@/stores/platforms";
import storeScanning from "@/stores/scanning";
import PlatformIcon from "@/components/Platform/PlatformIcon.vue";
// Props
@@ -15,16 +15,8 @@ const completeRescan = ref(false);
// Event listeners bus
const emitter = inject("emitter");
function scrollToBottom() {
window.scrollTo(0, document.body.scrollHeight);
}
socket.on("scan:scanning_platform", ({ p_name, p_slug }) => {
scannedPlatforms.value.push({
name: p_name,
slug: p_slug,
roms: [],
});
socket.on("scan:scanning_platform", ({ name, slug }) => {
scannedPlatforms.value.push({ name, slug, roms: [] });
window.setTimeout(scrollToBottom, 100);
});
@@ -48,8 +40,9 @@ socket.on("scan:scanning_rom", ({ p_slug, p_name, ...rom }) => {
socket.on("scan:done", () => {
scanning.set(false);
emitter.emit("refreshPlatforms");
emitter.emit("refreshDrawer");
emitter.emit("refreshView");
emitter.emit("snackbarShow", {
msg: "Scan completed successfully!",
icon: "mdi-check-bold",
@@ -70,18 +63,28 @@ socket.on("scan:done_ko", (msg) => {
});
// Functions
async function scan() {
function scrollToBottom() {
window.scrollTo(0, document.body.scrollHeight);
}
async function onScan() {
scanning.set(true);
scannedPlatforms.value = [];
if (!socket.connected) socket.connect();
socket.emit(
"scan",
platformsToScan.value.map((p) => p.fs_slug).join(","),
completeRescan.value
);
socket.emit("scan", {
platforms: platformsToScan.value.map((p) => p.fs_slug),
rescan: completeRescan.value,
});
}
onBeforeUnmount(() => {
socket.off("scan:scanning_platform");
socket.off("scan:scanning_rom");
socket.off("scan:done");
socket.off("scan:done_ko");
});
</script>
<template>
@@ -117,26 +120,26 @@ async function scan() {
<!-- Scan button -->
<v-row class="pa-4" no-gutters>
<v-btn
@click="scan()"
@click="onScan()"
:disabled="scanning.value"
prepend-icon="mdi-magnify-scan"
rounded="0"
>
<span v-if="!scanning.value">Scan</span>
<v-progress-circular
v-show="scanning.value"
color="rommAccent1"
class="ml-3 mr-2"
:width="2"
:size="20"
indeterminate
/>
:loading="scanning.value"
>Scan
<template v-slot:loader>
<v-progress-circular
color="romm-accent-1"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</v-row>
<v-divider
class="border-opacity-100 ma-4"
color="rommAccent1"
color="romm-accent-1"
:thickness="1"
/>

View File

@@ -0,0 +1,118 @@
<script setup>
import { ref, inject, onBeforeMount } from "vue";
import { useRouter } from "vue-router";
import storeAuth from "@/stores/auth";
import { api } from "@/services/api";
// Props
const auth = storeAuth();
const emitter = inject("emitter");
const router = useRouter();
const username = ref();
const password = ref();
const visiblePassword = ref(false);
function login() {
api
.post(
"/login",
{},
{
auth: {
username: username.value,
password: password.value,
},
}
)
.then(() => {
const next = router.currentRoute.value.query?.next || "/";
router.push(next);
})
.catch(({ response, message }) => {
emitter.emit("snackbarShow", {
msg: `Unable to login: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
});
});
}
onBeforeMount(async () => {
// Check if authentication is enabled
if (!auth.enabled) {
return router.push("/");
}
});
</script>
<template>
<span id="bg"></span>
<v-container class="fill-height justify-center">
<v-card id="card" class="py-8 px-5" width="500">
<v-row>
<v-col>
<v-img
src="/assets/isotipo.svg"
class="mx-auto"
width="200"
height="200"
/>
<v-row class="justify-center">
<v-col cols="10" md="8">
<v-text-field
@keyup.enter="login()"
prepend-inner-icon="mdi-account"
type="text"
v-model="username"
label="Username"
variant="underlined"
></v-text-field>
<v-text-field
@keyup.enter="login()"
prepend-inner-icon="mdi-lock"
:type="visiblePassword ? 'text' : 'password'"
v-model="password"
label="Password"
variant="underlined"
:append-inner-icon="visiblePassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="visiblePassword = !visiblePassword"
></v-text-field>
</v-col>
</v-row>
<v-row class="justify-center">
<v-col cols="10" md="8">
<v-btn
@click="login()"
append-icon="mdi-chevron-right-circle-outline"
block
>Login</v-btn
>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card>
</v-container>
</template>
<style>
#bg {
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
background: url("/assets/login_bg.png") center center;
background-size: cover;
-webkit-filter: blur(3px);
filter: blur(3px);
}
#card {
background-color: rgba(0, 0, 0, 0.4);
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
import { ref } from "vue";
import storeAuth from "@/stores/auth";
import Users from "@/views/Settings/Users/Users.vue";
import Theme from "@/views/Settings/UserInterface/Theme.vue";
import version from "../../../package";
// Props
const auth = storeAuth();
const tab = ref(auth.scopes.includes("users.read") ? "users" : "ui");
const ROMM_VERSION = version.version;
</script>
<template>
<!-- Settings tabs -->
<v-app-bar elevation="0" density="compact">
<v-tabs v-model="tab" slider-color="romm-accent-1" class="bg-primary">
<v-tab
:disabled="!auth.scopes.includes('users.read')"
value="users"
rounded="0"
>
Users
</v-tab>
<v-tab value="ui" rounded="0">User Interface</v-tab>
</v-tabs>
</v-app-bar>
<v-window v-model="tab">
<!-- Users tab -->
<v-window-item value="users">
<v-row class="pa-1">
<v-col>
<users />
</v-col>
</v-row>
</v-window-item>
<!-- User Interface tab -->
<v-window-item value="ui">
<v-row class="pa-1">
<v-col>
<theme />
</v-col>
</v-row>
</v-window-item>
</v-window>
<v-bottom-navigation :elevation="0" height="36" class="text-caption">
<v-row class="align-center justify-center" no-gutters>
<span class="text-romm-accent-1">RomM</span>
<span class="ml-1">{{ ROMM_VERSION }}</span>
</v-row>
</v-bottom-navigation>
</template>

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