songhive 0.0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- songhive/__init__.py +11 -0
- songhive/__main__.py +3 -0
- songhive/api/__init__.py +5 -0
- songhive/api/_common.py +100 -0
- songhive/api/_include.py +39 -0
- songhive/api/_sorting.py +35 -0
- songhive/api/app.py +264 -0
- songhive/api/deps.py +209 -0
- songhive/api/errors.py +141 -0
- songhive/api/middleware/__init__.py +0 -0
- songhive/api/middleware/auth.py +84 -0
- songhive/api/middleware/rate_limit.py +141 -0
- songhive/api/responses.py +203 -0
- songhive/api/routes/__init__.py +47 -0
- songhive/api/routes/_common.py +75 -0
- songhive/api/routes/_images.py +77 -0
- songhive/api/routes/admin.py +1173 -0
- songhive/api/routes/albums.py +707 -0
- songhive/api/routes/api_tokens.py +257 -0
- songhive/api/routes/artists.py +546 -0
- songhive/api/routes/auth.py +642 -0
- songhive/api/routes/favorites.py +102 -0
- songhive/api/routes/federation.py +305 -0
- songhive/api/routes/files.py +387 -0
- songhive/api/routes/genres.py +152 -0
- songhive/api/routes/hashtags.py +152 -0
- songhive/api/routes/history.py +91 -0
- songhive/api/routes/instance.py +271 -0
- songhive/api/routes/libraries.py +1111 -0
- songhive/api/routes/playlists.py +774 -0
- songhive/api/routes/radios.py +127 -0
- songhive/api/routes/reports.py +146 -0
- songhive/api/routes/share.py +155 -0
- songhive/api/routes/share_urls.py +139 -0
- songhive/api/routes/shares.py +112 -0
- songhive/api/routes/tracks.py +934 -0
- songhive/api/routes/users.py +344 -0
- songhive/api/share_page.py +577 -0
- songhive/app.py +204 -0
- songhive/cli/__init__.py +3 -0
- songhive/cli/admin.py +577 -0
- songhive/config/__init__.py +4 -0
- songhive/config/loader.py +184 -0
- songhive/config/schema.py +415 -0
- songhive/federation/__init__.py +3 -0
- songhive/federation/_common.py +25 -0
- songhive/federation/activities.py +94 -0
- songhive/federation/actors.py +133 -0
- songhive/federation/serializers.py +93 -0
- songhive/federation/storage.py +99 -0
- songhive/migrations/__init__.py +15 -0
- songhive/migrations/env.py +109 -0
- songhive/migrations/script.py.mako +28 -0
- songhive/migrations/utils.py +158 -0
- songhive/migrations/versions/10f8d202505a_add_track_musicbrainz_enriched_at.py +35 -0
- songhive/migrations/versions/194ec56009fe_add_api_tokens_table.py +64 -0
- songhive/migrations/versions/3a8c9d2e1f45_add_image_enriched_at.py +42 -0
- songhive/migrations/versions/55e5cbed94e8_add_hashtags.py +91 -0
- songhive/migrations/versions/69bc219bdb87_add_metadata_edit_columns.py +100 -0
- songhive/migrations/versions/8ad99ccdcef2_make_library_playlist_radio_owner_.py +134 -0
- songhive/migrations/versions/d49ed7492551_base.py +25 -0
- songhive/migrations/versions/e4307588dfd3_add_genres.py +82 -0
- songhive/migrations/versions/f854f113ac09_make_track_musicbrainz_id_non_unique.py +43 -0
- songhive/migrations/versions/fa56625fc487_add_genre_track_inheritance.py +40 -0
- songhive/models/__init__.py +66 -0
- songhive/models/_enums.py +13 -0
- songhive/models/album.py +71 -0
- songhive/models/api_token.py +63 -0
- songhive/models/artist.py +53 -0
- songhive/models/audit_log.py +30 -0
- songhive/models/base.py +153 -0
- songhive/models/favorite.py +22 -0
- songhive/models/genre.py +79 -0
- songhive/models/hashtag.py +177 -0
- songhive/models/history.py +21 -0
- songhive/models/invite.py +29 -0
- songhive/models/library.py +67 -0
- songhive/models/library_track.py +33 -0
- songhive/models/oauth_client.py +61 -0
- songhive/models/playlist.py +70 -0
- songhive/models/radio.py +32 -0
- songhive/models/report.py +46 -0
- songhive/models/setting.py +28 -0
- songhive/models/share_grant.py +42 -0
- songhive/models/share_token.py +42 -0
- songhive/models/stored_file.py +32 -0
- songhive/models/track.py +93 -0
- songhive/models/transcoded_file.py +54 -0
- songhive/models/upload.py +31 -0
- songhive/models/user.py +91 -0
- songhive/models/user_link.py +45 -0
- songhive/music/__init__.py +3 -0
- songhive/music/importer.py +57 -0
- songhive/music/metadata.py +12 -0
- songhive/services/__init__.py +0 -0
- songhive/services/acl.py +282 -0
- songhive/services/admin_tasks.py +322 -0
- songhive/services/api_token_tracker.py +107 -0
- songhive/services/audit.py +304 -0
- songhive/services/auth.py +114 -0
- songhive/services/celery_admin.py +107 -0
- songhive/services/deletion.py +599 -0
- songhive/services/email.py +97 -0
- songhive/services/federation.py +226 -0
- songhive/services/genres.py +681 -0
- songhive/services/hashtags.py +511 -0
- songhive/services/import_.py +544 -0
- songhive/services/metadata.py +492 -0
- songhive/services/music.py +1110 -0
- songhive/services/musicbrainz.py +1265 -0
- songhive/services/redis.py +51 -0
- songhive/services/reports.py +132 -0
- songhive/services/settings.py +199 -0
- songhive/services/sharing.py +274 -0
- songhive/services/stats.py +197 -0
- songhive/services/storage.py +263 -0
- songhive/services/streaming.py +133 -0
- songhive/static/assets/AboutView-DKieBlea.css +1 -0
- songhive/static/assets/AboutView-bt-_sOm8.js +1 -0
- songhive/static/assets/AddToCollectionDialog-Bm6ATeuE.css +1 -0
- songhive/static/assets/AddToCollectionDialog-DqXKPg1f.js +1 -0
- songhive/static/assets/AlbumCard-B2O7AoLL.js +1 -0
- songhive/static/assets/AlbumCard-BNKCsgwg.css +1 -0
- songhive/static/assets/AlbumEditView-BoPYJPRQ.js +1 -0
- songhive/static/assets/AlbumEditView-DyfNh1Ex.css +1 -0
- songhive/static/assets/AlbumView-BGXsjaY0.css +1 -0
- songhive/static/assets/AlbumView-xJLRHn6o.js +1 -0
- songhive/static/assets/AlbumsView-BzR-1mAx.js +1 -0
- songhive/static/assets/AlbumsView-CFo1-4ck.css +1 -0
- songhive/static/assets/AppBanner-ClY_Bj8y.css +1 -0
- songhive/static/assets/AppBanner-DZ387FGW.js +1 -0
- songhive/static/assets/AppCheckbox-CLvYiYyd.js +1 -0
- songhive/static/assets/AppCheckbox-DfUo5m4X.css +1 -0
- songhive/static/assets/AppInput-BvOfcX7S.css +1 -0
- songhive/static/assets/AppInput-C8UZpDSo.js +1 -0
- songhive/static/assets/AppPagination-BqiOxstb.js +1 -0
- songhive/static/assets/AppPagination-Cg1SqJ7z.css +1 -0
- songhive/static/assets/AppSelect-BHk9zeEd.js +1 -0
- songhive/static/assets/AppSelect-DvRSiWvX.css +1 -0
- songhive/static/assets/AppTable-BJqp_qey.js +1 -0
- songhive/static/assets/AppTable-CANcd2lD.css +1 -0
- songhive/static/assets/ArtistEditView-BZKuZ1Js.js +1 -0
- songhive/static/assets/ArtistEditView-Cvv5wVUE.css +1 -0
- songhive/static/assets/ArtistView-68j-cDyo.css +1 -0
- songhive/static/assets/ArtistView-CmnpHC6h.js +1 -0
- songhive/static/assets/ArtistsView-Dw8Zp9uS.js +1 -0
- songhive/static/assets/ArtistsView-tSuYFfAR.css +1 -0
- songhive/static/assets/AuditView-DdUCFc9L.js +1 -0
- songhive/static/assets/AuditView-N1uoMt56.css +1 -0
- songhive/static/assets/BulkEditableGrid-B7PxH8P_.js +1 -0
- songhive/static/assets/BulkEditableGrid-CfWX9jz3.css +1 -0
- songhive/static/assets/CeleryView-JQFUmMGw.js +1 -0
- songhive/static/assets/CeleryView-LjpkKFnh.css +1 -0
- songhive/static/assets/DashboardView-7VBEirky.css +1 -0
- songhive/static/assets/DashboardView-eDkbDr6l.js +1 -0
- songhive/static/assets/DeleteModal-BBcGxskW.js +1 -0
- songhive/static/assets/DeleteModal-DmBbv5Ee.css +1 -0
- songhive/static/assets/EntityActions-CzbwCPJd.css +1 -0
- songhive/static/assets/EntityActions-DkBvO7Gf.js +1 -0
- songhive/static/assets/FavoritesView-C14k_Ctk.css +1 -0
- songhive/static/assets/FavoritesView-Cgl2eURe.js +1 -0
- songhive/static/assets/FileDetailView-B_tSQMqP.css +1 -0
- songhive/static/assets/FileDetailView-D67ZS61O.js +1 -0
- songhive/static/assets/FilesView-CbKBU4yp.js +1 -0
- songhive/static/assets/FilesView-DsjWX6xi.css +1 -0
- songhive/static/assets/ForbiddenView-CCDcSZDT.js +1 -0
- songhive/static/assets/ForbiddenView-CJFaq6jv.css +1 -0
- songhive/static/assets/GenreInput-BZ7SRQfM.css +1 -0
- songhive/static/assets/GenreInput-DqbN110z.js +1 -0
- songhive/static/assets/GenreList-BsgEnETR.js +1 -0
- songhive/static/assets/GenreList-kGIUP0Li.css +1 -0
- songhive/static/assets/GenreView-BUR2l5o6.js +1 -0
- songhive/static/assets/GenresView-DIbuPjTi.css +1 -0
- songhive/static/assets/GenresView-DgUj_GOx.js +1 -0
- songhive/static/assets/HashtagList-Bw9HIEti.css +1 -0
- songhive/static/assets/HashtagList-DVzMsmjx.js +1 -0
- songhive/static/assets/HashtagView-CI_SZHy4.js +1 -0
- songhive/static/assets/HashtagsView-BpAaUHIB.css +1 -0
- songhive/static/assets/HashtagsView-DB2BpBCf.js +1 -0
- songhive/static/assets/HistoryView-Bd8dkyCW.js +1 -0
- songhive/static/assets/HistoryView-DTz83IWR.css +1 -0
- songhive/static/assets/HomeView-BEo8E8kV.js +1 -0
- songhive/static/assets/ImageUploadField-BCYmyeKn.js +1 -0
- songhive/static/assets/ImageUploadField-DSaVBUYi.css +1 -0
- songhive/static/assets/InvitesView-DZ1vxcaE.js +1 -0
- songhive/static/assets/InvitesView-hmfKk-K5.css +1 -0
- songhive/static/assets/LibraryDetailView-BfwjqROM.css +1 -0
- songhive/static/assets/LibraryDetailView-D3XcFTpJ.js +1 -0
- songhive/static/assets/LibraryEditView-BiEoYPNQ.css +1 -0
- songhive/static/assets/LibraryEditView-CvNr2o2m.js +1 -0
- songhive/static/assets/LibraryView-D1jSssqI.js +1 -0
- songhive/static/assets/LibraryView-TgwqxIND.css +1 -0
- songhive/static/assets/LoginView-A2HLq23j.css +1 -0
- songhive/static/assets/LoginView-BU73yzPT.js +1 -0
- songhive/static/assets/NotFoundView-BxFXOWN-.css +1 -0
- songhive/static/assets/NotFoundView-De84sUhW.js +1 -0
- songhive/static/assets/PasswordResetConfirmView-BcBDhsjX.js +1 -0
- songhive/static/assets/PasswordResetConfirmView-CC_XZybH.css +1 -0
- songhive/static/assets/PasswordResetView-B2uexNh4.css +1 -0
- songhive/static/assets/PasswordResetView-BiDn9hMR.js +1 -0
- songhive/static/assets/PlaylistEditView-CSB3r4AJ.js +1 -0
- songhive/static/assets/PlaylistEditView-DZawW2Gj.css +1 -0
- songhive/static/assets/PlaylistView-BbXNWD9k.css +1 -0
- songhive/static/assets/PlaylistView-D1Uzp7DH.js +1 -0
- songhive/static/assets/PlaylistsView-Bb2ak7c7.css +1 -0
- songhive/static/assets/PlaylistsView-D7mQSRZB.js +1 -0
- songhive/static/assets/ProfileView-CeklF-St.css +1 -0
- songhive/static/assets/ProfileView-DmahCHAi.js +1 -0
- songhive/static/assets/RadioView-BvqHn5HI.js +1 -0
- songhive/static/assets/RadioView-CCydWzZ8.css +1 -0
- songhive/static/assets/RegisterView-CCji7WIi.css +1 -0
- songhive/static/assets/RegisterView-Ygn6omig.js +1 -0
- songhive/static/assets/ReportsView-CxDGqRWH.css +1 -0
- songhive/static/assets/ReportsView-QdqN-gNO.js +1 -0
- songhive/static/assets/SearchBar-D6YONt3S.css +1 -0
- songhive/static/assets/SearchBar-tAozVmFx.js +1 -0
- songhive/static/assets/SettingsView-BrhH6aQx.css +1 -0
- songhive/static/assets/SettingsView-Du5I7H31.js +1 -0
- songhive/static/assets/ShareDialog-BeTAlCxz.js +1 -0
- songhive/static/assets/ShareDialog-e0bm_N6h.css +1 -0
- songhive/static/assets/ShareView-Mo5eAbx-.css +1 -0
- songhive/static/assets/ShareView-uEuU9XgJ.js +1 -0
- songhive/static/assets/SkeletonLoader-BVAjfRPe.js +1 -0
- songhive/static/assets/SkeletonLoader-CDLOQWxb.css +1 -0
- songhive/static/assets/SortControl-ByWpQkTh.js +1 -0
- songhive/static/assets/SortControl-DK9rwXgC.css +1 -0
- songhive/static/assets/TagDetailView-BXVAj4mU.css +1 -0
- songhive/static/assets/TagDetailView-Bes26M_P.js +1 -0
- songhive/static/assets/TasksView-B6qqnHYq.js +1 -0
- songhive/static/assets/TasksView-Cbnf9_sp.css +1 -0
- songhive/static/assets/TrackEditView-CMaEbk5Q.js +1 -0
- songhive/static/assets/TrackEditView-DDROZ9y6.css +1 -0
- songhive/static/assets/TrackList-8bw2b-P5.js +1 -0
- songhive/static/assets/TrackList-CiA36Irj.css +1 -0
- songhive/static/assets/TrackView-BE6UYlJF.css +1 -0
- songhive/static/assets/TrackView-BNVa0oYo.js +1 -0
- songhive/static/assets/TracksView-2dNSYd86.js +1 -0
- songhive/static/assets/TracksView-CsYYiU4K.css +1 -0
- songhive/static/assets/UsersView-BrY7DJ9P.css +1 -0
- songhive/static/assets/UsersView-DR6wkxr5.js +1 -0
- songhive/static/assets/VerifyEmailView--lrF2mRv.js +1 -0
- songhive/static/assets/VerifyEmailView-CNDB5h5C.css +1 -0
- songhive/static/assets/admin-CFiq-f0G.js +1 -0
- songhive/static/assets/albums-B4MvYRjc.js +1 -0
- songhive/static/assets/artists-DKfgHH_Q.js +1 -0
- songhive/static/assets/enrich-Bxwh0z_4.js +1 -0
- songhive/static/assets/entity-DaWPHepj.js +1 -0
- songhive/static/assets/fa-brands-400-D1LuMI3I.ttf +0 -0
- songhive/static/assets/fa-brands-400-D_cYUPeE.woff2 +0 -0
- songhive/static/assets/fa-regular-400-BjRzuEpd.woff2 +0 -0
- songhive/static/assets/fa-regular-400-DZaxPHgR.ttf +0 -0
- songhive/static/assets/fa-solid-900-CTAAxXor.woff2 +0 -0
- songhive/static/assets/fa-solid-900-D0aA9rwL.ttf +0 -0
- songhive/static/assets/fa-v4compatibility-C9RhG_FT.woff2 +0 -0
- songhive/static/assets/fa-v4compatibility-CCth-dXg.ttf +0 -0
- songhive/static/assets/files-D77ujQWP.js +1 -0
- songhive/static/assets/genres-joIx5zmg.js +1 -0
- songhive/static/assets/hashtags-wRn1KQRn.js +1 -0
- songhive/static/assets/index-CxM6uG64.js +49 -0
- songhive/static/assets/index-PD_9JJZo.css +5 -0
- songhive/static/assets/libraries-uXDDcnhn.js +1 -0
- songhive/static/assets/playlists-DrK-KdGh.js +1 -0
- songhive/static/assets/share-Bg_PG2U-.js +1 -0
- songhive/static/assets/tracks-DHXuq6SE.js +1 -0
- songhive/static/assets/useCanManage-BnzH5ISp.js +1 -0
- songhive/static/assets/useChunkList-B4d_-AI4.js +1 -0
- songhive/static/assets/useConfirm-C79C6NEi.js +1 -0
- songhive/static/assets/useDebounce-CJIsE33m.js +1 -0
- songhive/static/assets/useEntityDelete-CGjTY8t8.js +1 -0
- songhive/static/assets/useEntityList-Bph6V0yj.js +1 -0
- songhive/static/assets/useEntityMeta-9Y3jxiSa.js +1 -0
- songhive/static/assets/useMediaQuery-BHGK0rwT.js +1 -0
- songhive/static/favicon.ico +0 -0
- songhive/static/index.html +14 -0
- songhive/static/logo.png +0 -0
- songhive/storage/__init__.py +34 -0
- songhive/storage/base.py +78 -0
- songhive/storage/exc.py +17 -0
- songhive/storage/local.py +84 -0
- songhive/storage/s3.py +218 -0
- songhive/streaming/__init__.py +4 -0
- songhive/streaming/handler.py +465 -0
- songhive/streaming/transcoder.py +161 -0
- songhive/tasks/__init__.py +14 -0
- songhive/tasks/api_tokens.py +38 -0
- songhive/tasks/celery.py +90 -0
- songhive/tasks/email.py +34 -0
- songhive/tasks/federation.py +241 -0
- songhive/tasks/images.py +100 -0
- songhive/tasks/import_.py +180 -0
- songhive/tasks/musicbrainz.py +60 -0
- songhive/tasks/storage.py +114 -0
- songhive/tasks/tags.py +320 -0
- songhive/tasks/transcoding.py +96 -0
- songhive/users/__init__.py +3 -0
- songhive/users/api_tokens.py +171 -0
- songhive/users/invites.py +166 -0
- songhive/users/manager.py +589 -0
- songhive/users/oauth.py +911 -0
- songhive/users/tokens.py +253 -0
- songhive/version.py +1 -0
- songhive/ws/__init__.py +3 -0
- songhive/ws/events.py +162 -0
- songhive-0.0.8.dist-info/METADATA +327 -0
- songhive-0.0.8.dist-info/RECORD +309 -0
- songhive-0.0.8.dist-info/WHEEL +5 -0
- songhive-0.0.8.dist-info/entry_points.txt +2 -0
- songhive-0.0.8.dist-info/licenses/LICENSE +235 -0
- songhive-0.0.8.dist-info/top_level.txt +1 -0
songhive/__init__.py
ADDED
songhive/__main__.py
ADDED
songhive/api/__init__.py
ADDED
songhive/api/_common.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Shared helpers used by both FastAPI routes and middleware."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import Query, Request, Response
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def client_ip( # pylint: disable=too-many-branches
|
|
14
|
+
request: Request,
|
|
15
|
+
*,
|
|
16
|
+
trusted_hops: Optional[int] = None,
|
|
17
|
+
) -> Optional[str]:
|
|
18
|
+
"""
|
|
19
|
+
Return the client IP address, honoring common proxy headers.
|
|
20
|
+
|
|
21
|
+
The lookup order is:
|
|
22
|
+
|
|
23
|
+
1. ``X-Forwarded-For`` if ``trusted_hops`` is configured or the header
|
|
24
|
+
is present (leftmost valid IP is treated as the originating client).
|
|
25
|
+
2. ``X-Real-IP``.
|
|
26
|
+
3. The RFC 7239 ``Forwarded`` header (``for=...`` parameter).
|
|
27
|
+
4. ``request.client.host``.
|
|
28
|
+
|
|
29
|
+
``trusted_hops`` controls how many entries from the right of the
|
|
30
|
+
``X-Forwarded-For`` chain are skipped. A value of ``0`` means the header
|
|
31
|
+
is not trusted and is ignored. If ``None`` (the default), the header is
|
|
32
|
+
used without a configured trust depth and the leftmost valid address is
|
|
33
|
+
returned.
|
|
34
|
+
"""
|
|
35
|
+
forwarded: Optional[str] = request.headers.get("X-Forwarded-For")
|
|
36
|
+
if forwarded and trusted_hops != 0:
|
|
37
|
+
parts = [ip.strip() for ip in forwarded.split(",") if ip.strip()]
|
|
38
|
+
if trusted_hops is not None and trusted_hops > 0:
|
|
39
|
+
if len(parts) > trusted_hops:
|
|
40
|
+
candidate = parts[-(trusted_hops + 1)]
|
|
41
|
+
if _is_valid_ip(candidate):
|
|
42
|
+
return candidate
|
|
43
|
+
else:
|
|
44
|
+
for candidate in parts:
|
|
45
|
+
if _is_valid_ip(candidate):
|
|
46
|
+
return candidate
|
|
47
|
+
|
|
48
|
+
real_ip: Optional[str] = request.headers.get("X-Real-IP")
|
|
49
|
+
if real_ip:
|
|
50
|
+
candidate = real_ip.strip()
|
|
51
|
+
if _is_valid_ip(candidate):
|
|
52
|
+
return candidate
|
|
53
|
+
|
|
54
|
+
forwarded_rfc: Optional[str] = request.headers.get("Forwarded")
|
|
55
|
+
if forwarded_rfc:
|
|
56
|
+
for directive in forwarded_rfc.replace(";", ",").split(","):
|
|
57
|
+
directive = directive.strip()
|
|
58
|
+
if directive.lower().startswith("for="):
|
|
59
|
+
value = directive[4:].strip().strip('"')
|
|
60
|
+
if value.startswith("["):
|
|
61
|
+
value = value.split("]")[0][1:]
|
|
62
|
+
elif ":" in value:
|
|
63
|
+
value = value.rsplit(":", 1)[0]
|
|
64
|
+
if value and value != "_hidden" and not value.startswith("_") and _is_valid_ip(value):
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
if request.client and request.client.host:
|
|
68
|
+
return request.client.host
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_valid_ip(value: str) -> bool:
|
|
74
|
+
"""Return True if ``value`` is a valid IPv4 or IPv6 address."""
|
|
75
|
+
try:
|
|
76
|
+
ipaddress.ip_address(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
logger.debug("Ignoring invalid IP address %r", value)
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Pagination:
|
|
85
|
+
"""Pagination parameters and helper to set the ``X-Total-Count`` header."""
|
|
86
|
+
|
|
87
|
+
limit: int
|
|
88
|
+
offset: int
|
|
89
|
+
|
|
90
|
+
def set_total(self, response: Response, total: int) -> None:
|
|
91
|
+
"""Set the ``X-Total-Count`` header on ``response``."""
|
|
92
|
+
response.headers["X-Total-Count"] = str(total)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def get_pagination(
|
|
96
|
+
limit: int = Query(20, ge=1, le=100),
|
|
97
|
+
offset: int = Query(0, ge=0),
|
|
98
|
+
) -> Pagination:
|
|
99
|
+
"""FastAPI dependency that parses ``limit``/``offset`` query params."""
|
|
100
|
+
return Pagination(limit=limit, offset=offset)
|
songhive/api/_include.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Shared `?include=` query parameter helper."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Set
|
|
5
|
+
|
|
6
|
+
from fastapi import Query
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class IncludeQuery:
|
|
11
|
+
"""Parsed and validated `?include=` query parameter."""
|
|
12
|
+
|
|
13
|
+
values: Set[str]
|
|
14
|
+
|
|
15
|
+
def __contains__(self, item: str) -> bool:
|
|
16
|
+
return item in self.values
|
|
17
|
+
|
|
18
|
+
def has(self, *items: str) -> bool:
|
|
19
|
+
return any(item in self.values for item in items)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_include(allowed: Set[str]):
|
|
23
|
+
"""Return a FastAPI dependency that parses a comma-separated `?include=` list."""
|
|
24
|
+
|
|
25
|
+
def _parse(
|
|
26
|
+
include: Optional[str] = Query(
|
|
27
|
+
None,
|
|
28
|
+
description=f"Comma-separated relations to include. Allowed: {', '.join(sorted(allowed))}",
|
|
29
|
+
)
|
|
30
|
+
) -> IncludeQuery:
|
|
31
|
+
values: Set[str] = set()
|
|
32
|
+
if include:
|
|
33
|
+
for part in include.split(","):
|
|
34
|
+
part = part.strip().lower()
|
|
35
|
+
if part in allowed:
|
|
36
|
+
values.add(part)
|
|
37
|
+
return IncludeQuery(values)
|
|
38
|
+
|
|
39
|
+
return _parse
|
songhive/api/_sorting.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared sort parameter parsing for list endpoints."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable, Optional, Set
|
|
5
|
+
|
|
6
|
+
from fastapi import Query
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SortParams:
|
|
11
|
+
"""Validated sort parameters for a list request."""
|
|
12
|
+
|
|
13
|
+
field: str
|
|
14
|
+
direction: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_sort(
|
|
18
|
+
allowed: Set[str],
|
|
19
|
+
default_field: str,
|
|
20
|
+
default_dir: str = "asc",
|
|
21
|
+
) -> Callable[..., Any]:
|
|
22
|
+
"""
|
|
23
|
+
Build a FastAPI dependency that parses ``sort_by`` / ``sort_dir`` query
|
|
24
|
+
parameters and falls back to safe defaults for unknown values.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
async def _dep(
|
|
28
|
+
sort_by: Optional[str] = Query(None, description="Field to sort by"),
|
|
29
|
+
sort_dir: Optional[str] = Query(None, description="Sort direction (asc or desc)"),
|
|
30
|
+
) -> SortParams:
|
|
31
|
+
field = sort_by if sort_by in allowed else default_field
|
|
32
|
+
direction = sort_dir if sort_dir in ("asc", "desc") else default_dir
|
|
33
|
+
return SortParams(field=field, direction=direction)
|
|
34
|
+
|
|
35
|
+
return _dep
|
songhive/api/app.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application factory.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, AsyncGenerator, MutableMapping
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
from fastapi.responses import FileResponse
|
|
14
|
+
from starlette.types import Receive, Send
|
|
15
|
+
|
|
16
|
+
from ..config.schema import SonghiveConfig
|
|
17
|
+
from ..models.base import get_session, init_db
|
|
18
|
+
from ..services.acl import audit_ownerless_private
|
|
19
|
+
from ..services.redis import close_redis_client, get_redis_client
|
|
20
|
+
from ..services.settings import apply_settings_overrides
|
|
21
|
+
from ..version import __version__
|
|
22
|
+
from .errors import install_error_handlers
|
|
23
|
+
from .routes import (
|
|
24
|
+
admin,
|
|
25
|
+
albums,
|
|
26
|
+
api_tokens,
|
|
27
|
+
artists,
|
|
28
|
+
auth,
|
|
29
|
+
favorites,
|
|
30
|
+
federation,
|
|
31
|
+
files,
|
|
32
|
+
genres,
|
|
33
|
+
hashtags,
|
|
34
|
+
history,
|
|
35
|
+
instance,
|
|
36
|
+
libraries,
|
|
37
|
+
playlists,
|
|
38
|
+
radios,
|
|
39
|
+
reports,
|
|
40
|
+
share,
|
|
41
|
+
share_urls,
|
|
42
|
+
shares,
|
|
43
|
+
tracks,
|
|
44
|
+
users,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sync_settings_overlay(config: SonghiveConfig) -> tuple[SonghiveConfig, bool]:
|
|
51
|
+
"""
|
|
52
|
+
Apply DB settings overrides synchronously when no event loop is running.
|
|
53
|
+
|
|
54
|
+
``create_app`` may be called before an asyncio loop exists (e.g. in a
|
|
55
|
+
worker process or during CLI startup), so this path runs the overlay in a
|
|
56
|
+
temporary event loop. When a loop is already running, it skips and lets the
|
|
57
|
+
``_lifespan`` hook apply the overlay in the real application loop.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
asyncio.get_running_loop()
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
return config, False
|
|
65
|
+
|
|
66
|
+
async def _run() -> SonghiveConfig:
|
|
67
|
+
async with get_session() as session:
|
|
68
|
+
return await apply_settings_overrides(session, config)
|
|
69
|
+
|
|
70
|
+
loop = None
|
|
71
|
+
try:
|
|
72
|
+
loop = asyncio.new_event_loop()
|
|
73
|
+
# ``wait_for`` provides a hard ceiling so a stuck database query cannot
|
|
74
|
+
# block server startup forever. Avoid ``asyncio.run`` here because it
|
|
75
|
+
# sets/unsets the current event loop, which can break Tornado's
|
|
76
|
+
# IOLoop when ``create_app`` is called from an async test context.
|
|
77
|
+
return loop.run_until_complete(asyncio.wait_for(_run(), timeout=10.0)), True
|
|
78
|
+
except asyncio.TimeoutError:
|
|
79
|
+
logger.warning("Settings overlay timed out; using config file/defaults")
|
|
80
|
+
return config, False
|
|
81
|
+
except Exception:
|
|
82
|
+
logger.exception("Failed to apply settings overrides at app creation")
|
|
83
|
+
return config, False
|
|
84
|
+
finally:
|
|
85
|
+
if loop is not None:
|
|
86
|
+
loop.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _setup_static_routes(app: FastAPI):
|
|
90
|
+
"""
|
|
91
|
+
Serve the built frontend SPA as the default handler.
|
|
92
|
+
|
|
93
|
+
API/WebSocket/stream paths are excluded so unknown /api/... /ws/...
|
|
94
|
+
/stream/... routes still 404. The default is only reached when no API route
|
|
95
|
+
(or redirect-slashes partial match) matches, so it never shadows API
|
|
96
|
+
endpoints.
|
|
97
|
+
"""
|
|
98
|
+
static_dir = Path(__file__).resolve().parent.parent / "static"
|
|
99
|
+
if (static_dir / "index.html").is_file():
|
|
100
|
+
|
|
101
|
+
async def _serve_static(scope: MutableMapping[str, Any], receive: Receive, send: Send) -> None:
|
|
102
|
+
if scope["type"] != "http":
|
|
103
|
+
raise HTTPException(status_code=404)
|
|
104
|
+
|
|
105
|
+
path = scope["path"].lstrip("/")
|
|
106
|
+
if path.startswith("api/") or path.startswith("ws/") or path.startswith("stream/"):
|
|
107
|
+
raise HTTPException(status_code=404)
|
|
108
|
+
|
|
109
|
+
# Federation/ActivityPub requests to disabled endpoints should 404,
|
|
110
|
+
# not receive the SPA HTML shell.
|
|
111
|
+
for name, value in scope.get("headers", []):
|
|
112
|
+
if name.lower() == b"accept" and b"application/activity+json" in value:
|
|
113
|
+
raise HTTPException(status_code=404)
|
|
114
|
+
|
|
115
|
+
requested = (static_dir / path).resolve()
|
|
116
|
+
static_root = static_dir.resolve()
|
|
117
|
+
if requested.is_file() and str(requested).startswith(str(static_root)):
|
|
118
|
+
await FileResponse(requested)(scope, receive, send)
|
|
119
|
+
else:
|
|
120
|
+
await FileResponse(static_dir / "index.html")(scope, receive, send)
|
|
121
|
+
|
|
122
|
+
app.router.default = _serve_static
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_app(config: SonghiveConfig) -> FastAPI:
|
|
126
|
+
"""
|
|
127
|
+
Create and configure the FastAPI application.
|
|
128
|
+
|
|
129
|
+
:param config: The application configuration.
|
|
130
|
+
:returns: A configured FastAPI instance.
|
|
131
|
+
"""
|
|
132
|
+
init_db(config.database.url)
|
|
133
|
+
|
|
134
|
+
@asynccontextmanager
|
|
135
|
+
async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
136
|
+
app.state.redis = get_redis_client(config)
|
|
137
|
+
|
|
138
|
+
# If the synchronous overlay in create_app already ran, there is no
|
|
139
|
+
# need to re-read the same settings here. When it was skipped (because
|
|
140
|
+
# a loop was already running), this is the first chance to apply DB
|
|
141
|
+
# settings overrides using the real application loop and Redis.
|
|
142
|
+
if not getattr(app.state, "_sync_overlay_applied", False):
|
|
143
|
+
try:
|
|
144
|
+
async with get_session() as session:
|
|
145
|
+
await audit_ownerless_private(session)
|
|
146
|
+
app.state.config = await apply_settings_overrides(session, app.state.config)
|
|
147
|
+
except Exception:
|
|
148
|
+
logger.exception("Failed to apply settings overrides during startup")
|
|
149
|
+
|
|
150
|
+
yield
|
|
151
|
+
await close_redis_client()
|
|
152
|
+
|
|
153
|
+
app = FastAPI(
|
|
154
|
+
title="Songhive",
|
|
155
|
+
description="A federated and self-hosted music sharing service",
|
|
156
|
+
version=__version__,
|
|
157
|
+
debug=config.server.debug,
|
|
158
|
+
lifespan=_lifespan,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Store config in app state for dependency injection. The synchronous
|
|
162
|
+
# overlay above handles the no-event-loop case; _lifespan handles the
|
|
163
|
+
# rest. Both exist because create_app is called in different contexts.
|
|
164
|
+
config, sync_overlay_applied = _sync_settings_overlay(config)
|
|
165
|
+
app.state.config = config
|
|
166
|
+
app.state._sync_overlay_applied = sync_overlay_applied
|
|
167
|
+
app.state.storage_service = None
|
|
168
|
+
app.state.storage_service_config = None
|
|
169
|
+
|
|
170
|
+
# CORS middleware
|
|
171
|
+
allow_credentials = True
|
|
172
|
+
if "*" in config.server.cors_origins:
|
|
173
|
+
logger.warning(
|
|
174
|
+
"Wildcard CORS origin (['*']) cannot be used with credentials; "
|
|
175
|
+
"disabling allow_credentials. Specify explicit origins to enable credentials."
|
|
176
|
+
)
|
|
177
|
+
allow_credentials = False
|
|
178
|
+
|
|
179
|
+
app.add_middleware(
|
|
180
|
+
CORSMiddleware,
|
|
181
|
+
allow_origins=config.server.cors_origins,
|
|
182
|
+
allow_credentials=allow_credentials,
|
|
183
|
+
allow_methods=["*"],
|
|
184
|
+
allow_headers=["*"],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Register RFC 7807 problem detail exception handlers
|
|
188
|
+
install_error_handlers(app)
|
|
189
|
+
|
|
190
|
+
# Register API routes
|
|
191
|
+
api_prefix = "/api/v1"
|
|
192
|
+
app.include_router(auth.router, prefix=api_prefix, tags=["auth"])
|
|
193
|
+
app.include_router(api_tokens.router, prefix=api_prefix, tags=["api-tokens"])
|
|
194
|
+
app.include_router(users.router, prefix=api_prefix, tags=["users"])
|
|
195
|
+
app.include_router(artists.router, prefix=api_prefix, tags=["artists"])
|
|
196
|
+
app.include_router(albums.router, prefix=api_prefix, tags=["albums"])
|
|
197
|
+
app.include_router(tracks.router, prefix=api_prefix, tags=["tracks"])
|
|
198
|
+
app.include_router(playlists.router, prefix=api_prefix, tags=["playlists"])
|
|
199
|
+
app.include_router(libraries.router, prefix=api_prefix, tags=["libraries"])
|
|
200
|
+
app.include_router(hashtags.router, prefix=api_prefix, tags=["hashtags"])
|
|
201
|
+
app.include_router(genres.router, prefix=api_prefix, tags=["genres"])
|
|
202
|
+
app.include_router(favorites.router, prefix=api_prefix, tags=["favorites"])
|
|
203
|
+
app.include_router(history.router, prefix=api_prefix, tags=["history"])
|
|
204
|
+
app.include_router(radios.router, prefix=api_prefix, tags=["radios"])
|
|
205
|
+
app.include_router(reports.router, prefix=api_prefix, tags=["reports"])
|
|
206
|
+
app.include_router(reports.admin_router, prefix=api_prefix, tags=["reports"])
|
|
207
|
+
app.include_router(admin.router, prefix=api_prefix, tags=["admin"])
|
|
208
|
+
app.include_router(files.router, prefix=api_prefix, tags=["files"])
|
|
209
|
+
app.include_router(shares.router, prefix=api_prefix, tags=["shares"])
|
|
210
|
+
app.include_router(share_urls.router, prefix=api_prefix, tags=["share-urls"])
|
|
211
|
+
app.include_router(share.router, prefix=api_prefix, tags=["share"])
|
|
212
|
+
app.include_router(instance.v1_router, prefix="/api/v1")
|
|
213
|
+
app.include_router(instance.v2_router, prefix="/api/v2")
|
|
214
|
+
|
|
215
|
+
# Federation routes
|
|
216
|
+
if config.federation.enabled and config.federation.instance_domain:
|
|
217
|
+
app.include_router(federation.router)
|
|
218
|
+
_setup_federation(app, config)
|
|
219
|
+
|
|
220
|
+
_setup_static_routes(app)
|
|
221
|
+
|
|
222
|
+
return app
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _setup_federation(app: FastAPI, config: SonghiveConfig):
|
|
226
|
+
"""Set up ActivityPub federation routes via pubby."""
|
|
227
|
+
try:
|
|
228
|
+
from pubby import ActivityPubHandler, ActorConfig
|
|
229
|
+
from pubby.server.adapters.fastapi import bind_activitypub
|
|
230
|
+
from pubby.server.adapters.fastapi_mastodon import bind_mastodon_api
|
|
231
|
+
|
|
232
|
+
from ..federation.storage import (
|
|
233
|
+
create_activitypub_storage,
|
|
234
|
+
get_or_create_private_key,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
actor_config = ActorConfig(
|
|
238
|
+
base_url=f"https://{config.federation.instance_domain}",
|
|
239
|
+
username=config.federation.instance_name.lower().replace(" ", "-"),
|
|
240
|
+
name=config.federation.instance_name,
|
|
241
|
+
summary=config.federation.instance_description,
|
|
242
|
+
actor_path="/ap/actor",
|
|
243
|
+
type="Application",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
storage = create_activitypub_storage(config.database.url)
|
|
247
|
+
private_key_path = get_or_create_private_key(config.federation.private_key_path)
|
|
248
|
+
|
|
249
|
+
handler = ActivityPubHandler(
|
|
250
|
+
storage=storage,
|
|
251
|
+
actor_config=actor_config,
|
|
252
|
+
private_key_path=str(private_key_path),
|
|
253
|
+
)
|
|
254
|
+
bind_activitypub(app, handler, prefix="/ap")
|
|
255
|
+
bind_mastodon_api(
|
|
256
|
+
app,
|
|
257
|
+
handler,
|
|
258
|
+
title=config.federation.instance_name,
|
|
259
|
+
description=config.federation.instance_description,
|
|
260
|
+
software_name="Songhive",
|
|
261
|
+
software_version=__version__,
|
|
262
|
+
)
|
|
263
|
+
except ImportError:
|
|
264
|
+
logger.error("Federation is enabled but pubby is not installed")
|
songhive/api/deps.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI dependency injection helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import AsyncGenerator, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request, Security, status
|
|
8
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
9
|
+
from redis.asyncio import Redis
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from ..config.schema import SonghiveConfig
|
|
13
|
+
from ..models.base import get_session
|
|
14
|
+
from ..models.user import User
|
|
15
|
+
from ..services import acl
|
|
16
|
+
from ..services.auth import get_user_by_id
|
|
17
|
+
from ..services.storage import StorageService
|
|
18
|
+
from ..storage import get_storage
|
|
19
|
+
from ..users.api_tokens import validate_api_token
|
|
20
|
+
from .middleware.auth import decode_access_token, decode_token_payload, extract_token
|
|
21
|
+
|
|
22
|
+
bearer_scheme = HTTPBearer(
|
|
23
|
+
auto_error=False,
|
|
24
|
+
scheme_name="BearerAuth",
|
|
25
|
+
bearerFormat="JWT",
|
|
26
|
+
description="JWT access token obtained from /api/v1/auth/login or /api/v1/auth/refresh",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _token_from_credentials_or_request(
|
|
31
|
+
request: Request,
|
|
32
|
+
credentials: Optional[HTTPAuthorizationCredentials],
|
|
33
|
+
) -> Optional[str]:
|
|
34
|
+
"""Extract the bearer token from resolved credentials or the request header."""
|
|
35
|
+
if isinstance(credentials, HTTPAuthorizationCredentials):
|
|
36
|
+
return credentials.credentials
|
|
37
|
+
return extract_token(request)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config(request: Request) -> SonghiveConfig:
|
|
41
|
+
"""Get the application config from the request state."""
|
|
42
|
+
config: SonghiveConfig = request.app.state.config
|
|
43
|
+
return config
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def get_effective_config(request: Request) -> SonghiveConfig:
|
|
47
|
+
"""Return the effective config as stored in app state."""
|
|
48
|
+
return get_config(request)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_redis(request: Request) -> Redis:
|
|
52
|
+
"""Get the shared async Redis client from the request state."""
|
|
53
|
+
redis: Redis = request.app.state.redis
|
|
54
|
+
return redis
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
58
|
+
"""Get a database session via dependency injection."""
|
|
59
|
+
async with get_session() as session:
|
|
60
|
+
yield session
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _get_current_user(
|
|
64
|
+
request: Request,
|
|
65
|
+
db: AsyncSession,
|
|
66
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
|
|
67
|
+
) -> Optional[User]:
|
|
68
|
+
"""Extract and validate the current user, returning ``None`` on any failure."""
|
|
69
|
+
token = _token_from_credentials_or_request(request, credentials)
|
|
70
|
+
if not token:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
config = get_config(request)
|
|
74
|
+
|
|
75
|
+
# Peek at the payload without enforcing ``exp`` so we can route by token type.
|
|
76
|
+
payload = decode_token_payload(token, config.auth.secret_key)
|
|
77
|
+
if payload is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# Slow path: API-token JWT (exp enforced at DB row level).
|
|
81
|
+
if payload.get("token_type") == "api_token":
|
|
82
|
+
jti = payload.get("jti")
|
|
83
|
+
if not jti:
|
|
84
|
+
return None
|
|
85
|
+
redis = get_redis(request)
|
|
86
|
+
api_token = await validate_api_token(db, jti, redis=redis)
|
|
87
|
+
if api_token is None:
|
|
88
|
+
return None
|
|
89
|
+
user = await get_user_by_id(db, api_token.user_id)
|
|
90
|
+
return user if user is not None and user.is_active else None
|
|
91
|
+
|
|
92
|
+
# Fast path: short-lived access JWT (PyJWT enforces exp).
|
|
93
|
+
user_id = decode_access_token(token, config.auth.secret_key)
|
|
94
|
+
if user_id is not None:
|
|
95
|
+
user = await get_user_by_id(db, user_id)
|
|
96
|
+
return user if user is not None and user.is_active else None
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def get_current_user(
|
|
102
|
+
request: Request,
|
|
103
|
+
db: AsyncSession = Depends(get_db),
|
|
104
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
|
|
105
|
+
) -> User:
|
|
106
|
+
"""
|
|
107
|
+
Extract and validate the current user from the request.
|
|
108
|
+
|
|
109
|
+
Returns the User model instance or raises 401 for missing, invalid,
|
|
110
|
+
inactive, or deleted users.
|
|
111
|
+
"""
|
|
112
|
+
user = await _get_current_user(request, db, credentials)
|
|
113
|
+
if user is None:
|
|
114
|
+
raise HTTPException(
|
|
115
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
116
|
+
detail="Not authenticated",
|
|
117
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
118
|
+
)
|
|
119
|
+
return user
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def get_current_user_optional(
|
|
123
|
+
request: Request,
|
|
124
|
+
db: AsyncSession = Depends(get_db),
|
|
125
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
|
|
126
|
+
) -> Optional[User]:
|
|
127
|
+
"""Extract and validate the current user, returning ``None`` when unauthenticated."""
|
|
128
|
+
return await _get_current_user(request, db, credentials)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _get_share_token(request: Request) -> Optional[str]:
|
|
132
|
+
"""Read a share token from the header, cookie, or query string (legacy)."""
|
|
133
|
+
token = request.headers.get("X-Share-Token")
|
|
134
|
+
if token:
|
|
135
|
+
return token
|
|
136
|
+
token = request.cookies.get("share_token")
|
|
137
|
+
if token:
|
|
138
|
+
return token
|
|
139
|
+
return request.query_params.get("token")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def require_access(item_type: str):
|
|
143
|
+
"""Return a FastAPI dependency that enforces access to ``item_type`` resources."""
|
|
144
|
+
id_key = acl.ITEM_ID_KEYS.get(item_type)
|
|
145
|
+
if id_key is None:
|
|
146
|
+
raise RuntimeError(f"Unknown item type: {item_type!r}")
|
|
147
|
+
|
|
148
|
+
async def _dep(
|
|
149
|
+
request: Request,
|
|
150
|
+
db: AsyncSession = Depends(get_db),
|
|
151
|
+
user: Optional[User] = Depends(get_current_user_optional),
|
|
152
|
+
token: Optional[str] = Depends(_get_share_token),
|
|
153
|
+
) -> bool:
|
|
154
|
+
"""Load the requested item and verify the requester may access it."""
|
|
155
|
+
item_id = request.path_params.get(id_key)
|
|
156
|
+
if not item_id:
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
159
|
+
detail="Not found",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
item = await acl.get_item(db, item_type, item_id)
|
|
163
|
+
if item is None:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
166
|
+
detail="Not found",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not await acl.can_access(db, user, item_type, item_id, share_token=token):
|
|
170
|
+
raise HTTPException(
|
|
171
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
172
|
+
detail="Access denied",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
return _dep
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_storage_service(request: Request) -> StorageService:
|
|
181
|
+
"""
|
|
182
|
+
Get or create a cached StorageService, recreating it when storage config
|
|
183
|
+
changes.
|
|
184
|
+
"""
|
|
185
|
+
config = get_config(request)
|
|
186
|
+
current = config.storage
|
|
187
|
+
cached = getattr(request.app.state, "storage_service", None)
|
|
188
|
+
cached_config = getattr(request.app.state, "storage_service_config", None)
|
|
189
|
+
|
|
190
|
+
# Compare against a deep copy so runtime config mutations (e.g. in tests) force a
|
|
191
|
+
# fresh backend and prevent a cached service from using stale connection state.
|
|
192
|
+
if cached is not None and cached_config == current:
|
|
193
|
+
return cached
|
|
194
|
+
|
|
195
|
+
backend = get_storage(current)
|
|
196
|
+
service = StorageService(backend, current)
|
|
197
|
+
request.app.state.storage_service = service
|
|
198
|
+
request.app.state.storage_service_config = current.model_copy(deep=True)
|
|
199
|
+
return service
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def require_admin(current_user: User = Depends(get_current_user)):
|
|
203
|
+
"""Require admin privileges."""
|
|
204
|
+
if not current_user.is_admin:
|
|
205
|
+
raise HTTPException(
|
|
206
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
207
|
+
detail="Admin access required",
|
|
208
|
+
)
|
|
209
|
+
return current_user
|