[feat] user assets (#3504)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user