[feat] user assets (#3504)

This commit is contained in:
Vlad Stan
2025-11-12 14:30:27 +02:00
committed by GitHub
parent c89721223f
commit 39c33699af
28 changed files with 1125 additions and 309 deletions
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
@@ -44,6 +45,7 @@ def init_core_routers(app: FastAPI):
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
+107
View File
@@ -0,0 +1,107 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * from assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)
+19
View File
@@ -759,3 +759,22 @@ async def m036_add_shared_wallet_column(db: Connection):
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
"""
)
async def m037_create_assets_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None
+56
View File
@@ -0,0 +1,56 @@
import base64
import io
from uuid import uuid4
from fastapi import UploadFile
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if user_id not in settings.lnbits_assets_no_limit_users:
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
)
contents = await file.read()
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
raise ValueError(
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=file.content_type,
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
data=contents,
)
await create_asset(asset)
return asset
+2 -98
View File
@@ -1,19 +1,14 @@
import os
import time
from http import HTTPStatus
from pathlib import Path
from shutil import make_archive, move
from shutil import make_archive
from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO
from urllib.parse import urlparse
import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File
from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_admin_notification,
@@ -23,7 +18,6 @@ from lnbits.core.services import (
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
@@ -172,93 +166,3 @@ async def api_download_backup() -> FileResponse:
return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
)
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+174
View File
@@ -0,0 +1,174 @@
import base64
from http import HTTPStatus
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from lnbits.core.crud.assets import (
delete_user_asset,
get_asset_info,
get_public_asset,
get_public_asset_info,
get_user_asset,
get_user_asset_info,
get_user_assets,
update_user_asset_info,
)
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import User
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_user_exists,
optional_user_id,
parse_filters,
)
asset_router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
upload_file_param = File(...)
@asset_router.get(
"/paginated",
name="Get user assets",
summary="Get paginated list user assets",
)
async def api_get_user_assets(
user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]:
return await get_user_assets(user.id, filters=filters)
@asset_router.get(
"/{asset_id}",
name="Get user asset",
summary="Get user asset by ID",
)
async def api_get_asset(
asset_id: str,
user: User = Depends(check_user_exists),
) -> AssetInfo:
asset_info = await get_user_asset_info(user.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_info
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
)
async def api_get_asset_binary(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset = None
if user_id:
asset = await get_user_asset(user_id, asset_id)
if not asset:
asset = await get_public_asset(asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
"/{asset_id}/thumbnail",
name="Get user asset thumbnail",
summary="Get user asset thumbnail data by ID",
)
async def api_get_asset_thumbnail(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset_info = None
if user_id:
asset_info = await get_user_asset_info(user_id, asset_id)
if not asset_info:
asset_info = await get_public_asset_info(asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@asset_router.put(
"/{asset_id}",
name="Update user asset",
summary="Update user asset by ID",
)
async def api_update_asset(
asset_id: str,
data: AssetUpdate,
user: User = Depends(check_user_exists),
) -> AssetInfo:
if user.admin:
asset_info = await get_asset_info(asset_id)
else:
asset_info = await get_user_asset_info(user.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
asset_info.name = data.name or asset_info.name
asset_info.is_public = (
asset_info.is_public if data.is_public is None else data.is_public
)
await update_user_asset_info(asset_info)
return asset_info
@asset_router.post(
"",
name="Upload",
summary="Upload user assets",
)
async def api_upload_asset(
user: User = Depends(check_user_exists),
file: UploadFile = upload_file_param,
public_asset: bool = False,
) -> AssetInfo:
asset = await create_user_asset(user.id, file, public_asset)
asset_info = await get_user_asset_info(user.id, asset.id)
if not asset_info:
raise ValueError("Failed to retrieve asset info after upload.")
return asset_info
@asset_router.delete(
"/{asset_id}",
name="Delete user asset",
summary="Delete user asset by ID",
)
async def api_delete_asset(
asset_id: str,
user: User = Depends(check_user_exists),
) -> SimpleStatus:
asset = await get_user_asset(user.id, asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
await delete_user_asset(user.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")