Merge remote-tracking branch 'origin/main' into keynav_timeline

This commit is contained in:
Min Idzelis 2025-05-16 00:52:16 +00:00
commit b9ff2a2e2e
176 changed files with 2669 additions and 884 deletions

View File

@ -17,6 +17,7 @@ jobs:
permissions:
contents: read
outputs:
should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
@ -36,6 +37,8 @@ jobs:
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
i18n:
- 'i18n/**'
web:
- 'web/**'
- 'i18n/**'
@ -262,6 +265,46 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
i18n-tests:
name: Test i18n
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
- name: Install dependencies
run: npm --prefix=web ci
- name: Format
run: npm --prefix=web run format:i18n
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
i18n/**
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "ERROR: i18n files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
exit 1
e2e-tests-lint:
name: End-to-End Lint
needs: pre-job

View File

@ -121,6 +121,6 @@ Once this is done, you can continue to step 3 of "Basic Setup".
[hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml
[nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
[jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux
[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations
[jellyfin-lp]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#low-power-encoding
[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#known-issues-and-limitations-on-linux
[libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases

View File

@ -202,7 +202,6 @@ describe('/asset', () => {
{
name: 'Marie Curie',
birthDate: null,
thumbnailPath: '',
isHidden: false,
faces: [
{
@ -219,7 +218,6 @@ describe('/asset', () => {
{
name: 'Pierre Curie',
birthDate: null,
thumbnailPath: '',
isHidden: false,
faces: [
{

View File

@ -1,17 +1,4 @@
{
"user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code",
"current_pin_code": "Current PIN code",
"new_pin_code": "New PIN code",
"setup_pin_code": "Setup a PIN code",
"confirm_new_pin_code": "Confirm new PIN code",
"change_pin_code": "Change PIN code",
"unable_to_change_pin_code": "Unable to change PIN code",
"unable_to_setup_pin_code": "Unable to setup PIN code",
"pin_code_changed_successfully": "Successfully changed PIN code",
"pin_code_setup_successfully": "Successfully setup a PIN code",
"pin_code_reset_successfully": "Successfully reset PIN code",
"reset_pin_code": "Reset PIN code",
"about": "About",
"account": "Account",
"account_settings": "Account Settings",
@ -39,6 +26,7 @@
"add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_locked_folder": "Add to Locked Folder",
"add_to_shared_album": "Add to shared album",
"add_url": "Add URL",
"added_to_archive": "Added to archive",
@ -625,6 +613,7 @@
"change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
"check_all": "Check All",
@ -665,6 +654,7 @@
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
"confirm_delete_shared_link": "Are you sure you want to delete this shared link?",
"confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?",
"confirm_new_pin_code": "Confirm new PIN code",
"confirm_password": "Confirm password",
"contain": "Contain",
"context": "Context",
@ -707,9 +697,11 @@
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user",
"created": "Created",
"created_at": "Created",
"crop": "Crop",
"curated_object_page_title": "Things",
"current_device": "Current device",
"current_pin_code": "Current PIN code",
"current_server_address": "Current server address",
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
@ -822,6 +814,7 @@
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
"empty_trash": "Empty trash",
"empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!",
@ -830,6 +823,8 @@
"end_date": "End date",
"enqueued": "Enqueued",
"enter_wifi_name": "Enter Wi-Fi name",
"enter_your_pin_code": "Enter your PIN code",
"enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder",
"error": "Error",
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
@ -924,6 +919,7 @@
"unable_to_log_out_all_devices": "Unable to log out all devices",
"unable_to_log_out_device": "Unable to log out device",
"unable_to_login_with_oauth": "Unable to login with OAuth",
"unable_to_move_to_locked_folder": "Unable to move to locked folder",
"unable_to_play_video": "Unable to play video",
"unable_to_reassign_assets_existing_person": "Unable to reassign assets to {name, select, null {an existing person} other {{name}}}",
"unable_to_reassign_assets_new_person": "Unable to reassign assets to a new person",
@ -1064,6 +1060,7 @@
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"host": "Host",
"hour": "Hour",
"id": "ID",
"ignore_icloud_photos": "Ignore iCloud photos",
"ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server",
"image": "Image",
@ -1145,6 +1142,8 @@
"location_picker_latitude_hint": "Enter your latitude here",
"location_picker_longitude_error": "Enter a valid longitude",
"location_picker_longitude_hint": "Enter your longitude here",
"lock": "Lock",
"locked_folder": "Locked Folder",
"log_out": "Log out",
"log_out_all_devices": "Log Out All Devices",
"logged_out_all_devices": "Logged out all devices",
@ -1213,8 +1212,8 @@
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_theme_settings": "Map Theme",
"map_zoom_to_see_photos": "Zoom out to see photos",
"mark_as_read": "Mark as read",
"mark_all_as_read": "Mark all as read",
"mark_as_read": "Mark as read",
"marked_all_as_read": "Marked all as read",
"matches": "Matches",
"media_type": "Media type",
@ -1242,6 +1241,10 @@
"month": "Month",
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"move": "Move",
"move_off_locked_folder": "Move out of Locked Folder",
"move_to_locked_folder": "Move to Locked Folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash",
@ -1258,6 +1261,8 @@
"new_api_key": "New API Key",
"new_password": "New password",
"new_person": "New person",
"new_pin_code": "New PIN code",
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
"new_user_created": "New user created",
"new_version_available": "NEW VERSION AVAILABLE",
"newest_first": "Newest first",
@ -1275,23 +1280,24 @@
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.",
"no_name": "No Name",
"no_notifications": "No notifications",
"no_people_found": "No matching people found",
"no_places": "No places",
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
"no_notifications": "No notifications",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
"notification_permission_list_tile_title": "Notification Permission",
"notification_toggle_setting_description": "Enable email notifications",
"email_notifications": "Email notifications",
"notifications": "Notifications",
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
@ -1379,6 +1385,10 @@
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
"photos_from_previous_years": "Photos from previous years",
"pick_a_location": "Pick a location",
"pin_code_changed_successfully": "Successfully changed PIN code",
"pin_code_reset_successfully": "Successfully reset PIN code",
"pin_code_setup_successfully": "Successfully setup a PIN code",
"pin_verification": "PIN code verification",
"place": "Place",
"places": "Places",
"places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
@ -1476,6 +1486,8 @@
"remove_deleted_assets": "Remove Deleted Assets",
"remove_from_album": "Remove from album",
"remove_from_favorites": "Remove from favorites",
"remove_from_locked_folder": "Remove from Locked Folder",
"remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library",
"remove_from_shared_link": "Remove from shared link",
"remove_memory": "Remove memory",
"remove_photo_from_memory": "Remove photo from this memory",
@ -1499,6 +1511,7 @@
"reset": "Reset",
"reset_password": "Reset password",
"reset_people_visibility": "Reset people visibility",
"reset_pin_code": "Reset PIN code",
"reset_to_default": "Reset to default",
"resolve_duplicates": "Resolve duplicates",
"resolved_all_duplicates": "Resolved all duplicates",
@ -1639,6 +1652,7 @@
"settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting",
"settings_saved": "Settings saved",
"setup_pin_code": "Setup a PIN code",
"share": "Share",
"share_add_photos": "Add photos",
"share_assets_selected": "{count} selected",
@ -1755,8 +1769,8 @@
"stop_sharing_photos_with_user": "Stop sharing your photos with this user",
"storage": "Storage space",
"storage_label": "Storage label",
"storage_usage": "{used} of {available} used",
"storage_quota": "Storage Quota",
"storage_usage": "{used} of {available} used",
"submit": "Submit",
"suggestions": "Suggestions",
"sunrise_on_the_beach": "Sunrise on the beach",
@ -1824,6 +1838,8 @@
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code",
"unable_to_setup_pin_code": "Unable to setup PIN code",
"unarchive": "Unarchive",
"unarchived_count": "{count, plural, other {Unarchived #}}",
"unfavorite": "Unfavorite",
@ -1847,6 +1863,7 @@
"untracked_files": "Untracked files",
"untracked_files_decription": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug",
"up_next": "Up next",
"updated_at": "Updated",
"updated_password": "Updated password",
"upload": "Upload",
"upload_concurrency": "Upload concurrency",
@ -1861,7 +1878,6 @@
"upload_success": "Upload success, refresh the page to see new upload assets.",
"upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading",
"id": "ID",
"url": "URL",
"usage": "Usage",
"use_current_connection": "use current connection",
@ -1870,8 +1886,8 @@
"user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID",
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
"created_at": "Created",
"updated_at": "Updated",
"user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code",
"user_purchase_settings": "Purchase",
"user_purchase_settings_description": "Manage your purchase",
"user_role_set": "Set {user} as {role}",
@ -1921,6 +1937,7 @@
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"wifi_name": "Wi-Fi Name",
"wrong_pin_code": "Wrong PIN code",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",

View File

@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
addDefault(value, 'visibility', AssetVisibility.timeline);
}
break;
case 'UserAdminResponseDto':

View File

@ -111,11 +111,13 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |
*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code |
*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code |
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
@ -193,9 +195,11 @@ Class | Method | HTTP request | Description
*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history |
*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping |
*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license |
*SessionsApi* | [**createSession**](doc//SessionsApi.md#createsession) | **POST** /sessions |
*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions |
*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} |
*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions |
*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock |
*SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets |
*SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links |
*SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links |
@ -390,6 +394,7 @@ Class | Method | HTTP request | Description
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- [PinCodeChangeDto](doc//PinCodeChangeDto.md)
- [PinCodeResetDto](doc//PinCodeResetDto.md)
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
@ -419,7 +424,10 @@ Class | Method | HTTP request | Description
- [ServerThemeDto](doc//ServerThemeDto.md)
- [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md)
- [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
- [SessionCreateDto](doc//SessionCreateDto.md)
- [SessionCreateResponseDto](doc//SessionCreateResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SessionUnlockDto](doc//SessionUnlockDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

View File

@ -189,6 +189,7 @@ part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart';
part 'model/pin_code_change_dto.dart';
part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/purchase_response.dart';
@ -218,7 +219,10 @@ part 'model/server_storage_response_dto.dart';
part 'model/server_theme_dto.dart';
part 'model/server_version_history_response_dto.dart';
part 'model/server_version_response_dto.dart';
part 'model/session_create_dto.dart';
part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/session_unlock_dto.dart';
part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart';

View File

@ -143,6 +143,39 @@ class AuthenticationApi {
return null;
}
/// Performs an HTTP 'POST /auth/session/lock' operation and returns the [Response].
Future<Response> lockAuthSessionWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/session/lock';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<void> lockAuthSession() async {
final response = await lockAuthSessionWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
/// Parameters:
///
@ -234,13 +267,13 @@ class AuthenticationApi {
/// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response].
/// Parameters:
///
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
Future<Response> resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
/// * [PinCodeResetDto] pinCodeResetDto (required):
Future<Response> resetPinCodeWithHttpInfo(PinCodeResetDto pinCodeResetDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/pin-code';
// ignore: prefer_final_locals
Object? postBody = pinCodeChangeDto;
Object? postBody = pinCodeResetDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
@ -262,9 +295,9 @@ class AuthenticationApi {
/// Parameters:
///
/// * [PinCodeChangeDto] pinCodeChangeDto (required):
Future<void> resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async {
final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,);
/// * [PinCodeResetDto] pinCodeResetDto (required):
Future<void> resetPinCode(PinCodeResetDto pinCodeResetDto,) async {
final response = await resetPinCodeWithHttpInfo(pinCodeResetDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -356,6 +389,45 @@ class AuthenticationApi {
return null;
}
/// Performs an HTTP 'POST /auth/session/unlock' operation and returns the [Response].
/// Parameters:
///
/// * [SessionUnlockDto] sessionUnlockDto (required):
Future<Response> unlockAuthSessionWithHttpInfo(SessionUnlockDto sessionUnlockDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/auth/session/unlock';
// ignore: prefer_final_locals
Object? postBody = sessionUnlockDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [SessionUnlockDto] sessionUnlockDto (required):
Future<void> unlockAuthSession(SessionUnlockDto sessionUnlockDto,) async {
final response = await unlockAuthSessionWithHttpInfo(sessionUnlockDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response].
Future<Response> validateAccessTokenWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@ -16,6 +16,53 @@ class SessionsApi {
final ApiClient apiClient;
/// Performs an HTTP 'POST /sessions' operation and returns the [Response].
/// Parameters:
///
/// * [SessionCreateDto] sessionCreateDto (required):
Future<Response> createSessionWithHttpInfo(SessionCreateDto sessionCreateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/sessions';
// ignore: prefer_final_locals
Object? postBody = sessionCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [SessionCreateDto] sessionCreateDto (required):
Future<SessionCreateResponseDto?> createSession(SessionCreateDto sessionCreateDto,) async {
final response = await createSessionWithHttpInfo(sessionCreateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SessionCreateResponseDto',) as SessionCreateResponseDto;
}
return null;
}
/// Performs an HTTP 'DELETE /sessions' operation and returns the [Response].
Future<Response> deleteAllSessionsWithHttpInfo() async {
// ignore: prefer_const_declarations
@ -132,4 +179,44 @@ class SessionsApi {
}
return null;
}
/// Performs an HTTP 'POST /sessions/{id}/lock' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> lockSessionWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/sessions/{id}/lock'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> lockSession(String id,) async {
final response = await lockSessionWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -434,6 +434,8 @@ class ApiClient {
return PersonWithFacesResponseDto.fromJson(value);
case 'PinCodeChangeDto':
return PinCodeChangeDto.fromJson(value);
case 'PinCodeResetDto':
return PinCodeResetDto.fromJson(value);
case 'PinCodeSetupDto':
return PinCodeSetupDto.fromJson(value);
case 'PlacesResponseDto':
@ -492,8 +494,14 @@ class ApiClient {
return ServerVersionHistoryResponseDto.fromJson(value);
case 'ServerVersionResponseDto':
return ServerVersionResponseDto.fromJson(value);
case 'SessionCreateDto':
return SessionCreateDto.fromJson(value);
case 'SessionCreateResponseDto':
return SessionCreateResponseDto.fromJson(value);
case 'SessionResponseDto':
return SessionResponseDto.fromJson(value);
case 'SessionUnlockDto':
return SessionUnlockDto.fromJson(value);
case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':

View File

@ -43,6 +43,7 @@ class AssetResponseDto {
required this.type,
this.unassignedFaces = const [],
required this.updatedAt,
required this.visibility,
});
/// base64 encoded sha1 hash
@ -132,6 +133,8 @@ class AssetResponseDto {
DateTime updatedAt;
AssetResponseDtoVisibilityEnum visibility;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.checksum == checksum &&
@ -163,7 +166,8 @@ class AssetResponseDto {
other.thumbhash == thumbhash &&
other.type == type &&
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
other.updatedAt == updatedAt;
other.updatedAt == updatedAt &&
other.visibility == visibility;
@override
int get hashCode =>
@ -197,10 +201,11 @@ class AssetResponseDto {
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(unassignedFaces.hashCode) +
(updatedAt.hashCode);
(updatedAt.hashCode) +
(visibility.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -270,6 +275,7 @@ class AssetResponseDto {
json[r'type'] = this.type;
json[r'unassignedFaces'] = this.unassignedFaces;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'visibility'] = this.visibility;
return json;
}
@ -312,6 +318,7 @@ class AssetResponseDto {
type: AssetTypeEnum.fromJson(json[r'type'])!,
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!,
);
}
return null;
@ -378,6 +385,87 @@ class AssetResponseDto {
'thumbhash',
'type',
'updatedAt',
'visibility',
};
}
class AssetResponseDtoVisibilityEnum {
/// Instantiate a new enum with the provided [value].
const AssetResponseDtoVisibilityEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const archive = AssetResponseDtoVisibilityEnum._(r'archive');
static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline');
static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden');
static const locked = AssetResponseDtoVisibilityEnum._(r'locked');
/// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum].
static const values = <AssetResponseDtoVisibilityEnum>[
archive,
timeline,
hidden,
locked,
];
static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value);
static List<AssetResponseDtoVisibilityEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetResponseDtoVisibilityEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetResponseDtoVisibilityEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String,
/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum].
class AssetResponseDtoVisibilityEnumTypeTransformer {
factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._();
const AssetResponseDtoVisibilityEnumTypeTransformer._();
String encode(AssetResponseDtoVisibilityEnum data) => data.value;
/// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'archive': return AssetResponseDtoVisibilityEnum.archive;
case r'timeline': return AssetResponseDtoVisibilityEnum.timeline;
case r'hidden': return AssetResponseDtoVisibilityEnum.hidden;
case r'locked': return AssetResponseDtoVisibilityEnum.locked;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance.
static AssetResponseDtoVisibilityEnumTypeTransformer? _instance;
}

View File

@ -26,12 +26,14 @@ class AssetVisibility {
static const archive = AssetVisibility._(r'archive');
static const timeline = AssetVisibility._(r'timeline');
static const hidden = AssetVisibility._(r'hidden');
static const locked = AssetVisibility._(r'locked');
/// List of all possible values in this [enum][AssetVisibility].
static const values = <AssetVisibility>[
archive,
timeline,
hidden,
locked,
];
static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value);
@ -73,6 +75,7 @@ class AssetVisibilityTypeTransformer {
case r'archive': return AssetVisibility.archive;
case r'timeline': return AssetVisibility.timeline;
case r'hidden': return AssetVisibility.hidden;
case r'locked': return AssetVisibility.locked;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -13,32 +13,70 @@ part of openapi.api;
class AuthStatusResponseDto {
/// Returns a new [AuthStatusResponseDto] instance.
AuthStatusResponseDto({
this.expiresAt,
required this.isElevated,
required this.password,
required this.pinCode,
this.pinExpiresAt,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? expiresAt;
bool isElevated;
bool password;
bool pinCode;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? pinExpiresAt;
@override
bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
other.expiresAt == expiresAt &&
other.isElevated == isElevated &&
other.password == password &&
other.pinCode == pinCode;
other.pinCode == pinCode &&
other.pinExpiresAt == pinExpiresAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(isElevated.hashCode) +
(password.hashCode) +
(pinCode.hashCode);
(pinCode.hashCode) +
(pinExpiresAt == null ? 0 : pinExpiresAt!.hashCode);
@override
String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
String toString() => 'AuthStatusResponseDto[expiresAt=$expiresAt, isElevated=$isElevated, password=$password, pinCode=$pinCode, pinExpiresAt=$pinExpiresAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.expiresAt != null) {
json[r'expiresAt'] = this.expiresAt;
} else {
// json[r'expiresAt'] = null;
}
json[r'isElevated'] = this.isElevated;
json[r'password'] = this.password;
json[r'pinCode'] = this.pinCode;
if (this.pinExpiresAt != null) {
json[r'pinExpiresAt'] = this.pinExpiresAt;
} else {
// json[r'pinExpiresAt'] = null;
}
return json;
}
@ -51,8 +89,11 @@ class AuthStatusResponseDto {
final json = value.cast<String, dynamic>();
return AuthStatusResponseDto(
expiresAt: mapValueOfType<String>(json, r'expiresAt'),
isElevated: mapValueOfType<bool>(json, r'isElevated')!,
password: mapValueOfType<bool>(json, r'password')!,
pinCode: mapValueOfType<bool>(json, r'pinCode')!,
pinExpiresAt: mapValueOfType<String>(json, r'pinExpiresAt'),
);
}
return null;
@ -100,6 +141,7 @@ class AuthStatusResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'isElevated',
'password',
'pinCode',
};

View File

@ -81,9 +81,11 @@ class Permission {
static const personPeriodStatistics = Permission._(r'person.statistics');
static const personPeriodMerge = Permission._(r'person.merge');
static const personPeriodReassign = Permission._(r'person.reassign');
static const sessionPeriodCreate = Permission._(r'session.create');
static const sessionPeriodRead = Permission._(r'session.read');
static const sessionPeriodUpdate = Permission._(r'session.update');
static const sessionPeriodDelete = Permission._(r'session.delete');
static const sessionPeriodLock = Permission._(r'session.lock');
static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create');
static const sharedLinkPeriodRead = Permission._(r'sharedLink.read');
static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update');
@ -166,9 +168,11 @@ class Permission {
personPeriodStatistics,
personPeriodMerge,
personPeriodReassign,
sessionPeriodCreate,
sessionPeriodRead,
sessionPeriodUpdate,
sessionPeriodDelete,
sessionPeriodLock,
sharedLinkPeriodCreate,
sharedLinkPeriodRead,
sharedLinkPeriodUpdate,
@ -286,9 +290,11 @@ class PermissionTypeTransformer {
case r'person.statistics': return Permission.personPeriodStatistics;
case r'person.merge': return Permission.personPeriodMerge;
case r'person.reassign': return Permission.personPeriodReassign;
case r'session.create': return Permission.sessionPeriodCreate;
case r'session.read': return Permission.sessionPeriodRead;
case r'session.update': return Permission.sessionPeriodUpdate;
case r'session.delete': return Permission.sessionPeriodDelete;
case r'session.lock': return Permission.sessionPeriodLock;
case r'sharedLink.create': return Permission.sharedLinkPeriodCreate;
case r'sharedLink.read': return Permission.sharedLinkPeriodRead;
case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate;

View File

@ -0,0 +1,125 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PinCodeResetDto {
/// Returns a new [PinCodeResetDto] instance.
PinCodeResetDto({
this.password,
this.pinCode,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? password;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? pinCode;
@override
bool operator ==(Object other) => identical(this, other) || other is PinCodeResetDto &&
other.password == password &&
other.pinCode == pinCode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(password == null ? 0 : password!.hashCode) +
(pinCode == null ? 0 : pinCode!.hashCode);
@override
String toString() => 'PinCodeResetDto[password=$password, pinCode=$pinCode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.password != null) {
json[r'password'] = this.password;
} else {
// json[r'password'] = null;
}
if (this.pinCode != null) {
json[r'pinCode'] = this.pinCode;
} else {
// json[r'pinCode'] = null;
}
return json;
}
/// Returns a new [PinCodeResetDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PinCodeResetDto? fromJson(dynamic value) {
upgradeDto(value, "PinCodeResetDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PinCodeResetDto(
password: mapValueOfType<String>(json, r'password'),
pinCode: mapValueOfType<String>(json, r'pinCode'),
);
}
return null;
}
static List<PinCodeResetDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PinCodeResetDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PinCodeResetDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PinCodeResetDto> mapFromJson(dynamic json) {
final map = <String, PinCodeResetDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PinCodeResetDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PinCodeResetDto-objects as value to a dart map
static Map<String, List<PinCodeResetDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PinCodeResetDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PinCodeResetDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@ -0,0 +1,145 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SessionCreateDto {
/// Returns a new [SessionCreateDto] instance.
SessionCreateDto({
this.deviceOS,
this.deviceType,
this.duration,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? deviceOS;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? deviceType;
/// session duration, in seconds
///
/// Minimum value: 1
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? duration;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
other.deviceOS == deviceOS &&
other.deviceType == deviceType &&
other.duration == duration;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(deviceOS == null ? 0 : deviceOS!.hashCode) +
(deviceType == null ? 0 : deviceType!.hashCode) +
(duration == null ? 0 : duration!.hashCode);
@override
String toString() => 'SessionCreateDto[deviceOS=$deviceOS, deviceType=$deviceType, duration=$duration]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.deviceOS != null) {
json[r'deviceOS'] = this.deviceOS;
} else {
// json[r'deviceOS'] = null;
}
if (this.deviceType != null) {
json[r'deviceType'] = this.deviceType;
} else {
// json[r'deviceType'] = null;
}
if (this.duration != null) {
json[r'duration'] = this.duration;
} else {
// json[r'duration'] = null;
}
return json;
}
/// Returns a new [SessionCreateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SessionCreateDto? fromJson(dynamic value) {
upgradeDto(value, "SessionCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: num.parse('${json[r'duration']}'),
);
}
return null;
}
static List<SessionCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SessionCreateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SessionCreateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SessionCreateDto> mapFromJson(dynamic json) {
final map = <String, SessionCreateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SessionCreateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SessionCreateDto-objects as value to a dart map
static Map<String, List<SessionCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SessionCreateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SessionCreateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@ -0,0 +1,164 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SessionCreateResponseDto {
/// Returns a new [SessionCreateResponseDto] instance.
SessionCreateResponseDto({
required this.createdAt,
required this.current,
required this.deviceOS,
required this.deviceType,
this.expiresAt,
required this.id,
required this.token,
required this.updatedAt,
});
String createdAt;
bool current;
String deviceOS;
String deviceType;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? expiresAt;
String id;
String token;
String updatedAt;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateResponseDto &&
other.createdAt == createdAt &&
other.current == current &&
other.deviceOS == deviceOS &&
other.deviceType == deviceType &&
other.expiresAt == expiresAt &&
other.id == id &&
other.token == token &&
other.updatedAt == updatedAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(createdAt.hashCode) +
(current.hashCode) +
(deviceOS.hashCode) +
(deviceType.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(id.hashCode) +
(token.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt;
json[r'current'] = this.current;
json[r'deviceOS'] = this.deviceOS;
json[r'deviceType'] = this.deviceType;
if (this.expiresAt != null) {
json[r'expiresAt'] = this.expiresAt;
} else {
// json[r'expiresAt'] = null;
}
json[r'id'] = this.id;
json[r'token'] = this.token;
json[r'updatedAt'] = this.updatedAt;
return json;
}
/// Returns a new [SessionCreateResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SessionCreateResponseDto? fromJson(dynamic value) {
upgradeDto(value, "SessionCreateResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SessionCreateResponseDto(
createdAt: mapValueOfType<String>(json, r'createdAt')!,
current: mapValueOfType<bool>(json, r'current')!,
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
deviceType: mapValueOfType<String>(json, r'deviceType')!,
expiresAt: mapValueOfType<String>(json, r'expiresAt'),
id: mapValueOfType<String>(json, r'id')!,
token: mapValueOfType<String>(json, r'token')!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
);
}
return null;
}
static List<SessionCreateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SessionCreateResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SessionCreateResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SessionCreateResponseDto> mapFromJson(dynamic json) {
final map = <String, SessionCreateResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SessionCreateResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SessionCreateResponseDto-objects as value to a dart map
static Map<String, List<SessionCreateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SessionCreateResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SessionCreateResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'createdAt',
'current',
'deviceOS',
'deviceType',
'id',
'token',
'updatedAt',
};
}

View File

@ -17,6 +17,7 @@ class SessionResponseDto {
required this.current,
required this.deviceOS,
required this.deviceType,
this.expiresAt,
required this.id,
required this.updatedAt,
});
@ -29,6 +30,14 @@ class SessionResponseDto {
String deviceType;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? expiresAt;
String id;
String updatedAt;
@ -39,6 +48,7 @@ class SessionResponseDto {
other.current == current &&
other.deviceOS == deviceOS &&
other.deviceType == deviceType &&
other.expiresAt == expiresAt &&
other.id == id &&
other.updatedAt == updatedAt;
@ -49,11 +59,12 @@ class SessionResponseDto {
(current.hashCode) +
(deviceOS.hashCode) +
(deviceType.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(id.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, updatedAt=$updatedAt]';
String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -61,6 +72,11 @@ class SessionResponseDto {
json[r'current'] = this.current;
json[r'deviceOS'] = this.deviceOS;
json[r'deviceType'] = this.deviceType;
if (this.expiresAt != null) {
json[r'expiresAt'] = this.expiresAt;
} else {
// json[r'expiresAt'] = null;
}
json[r'id'] = this.id;
json[r'updatedAt'] = this.updatedAt;
return json;
@ -79,6 +95,7 @@ class SessionResponseDto {
current: mapValueOfType<bool>(json, r'current')!,
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
deviceType: mapValueOfType<String>(json, r'deviceType')!,
expiresAt: mapValueOfType<String>(json, r'expiresAt'),
id: mapValueOfType<String>(json, r'id')!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
);

View File

@ -0,0 +1,125 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SessionUnlockDto {
/// Returns a new [SessionUnlockDto] instance.
SessionUnlockDto({
this.password,
this.pinCode,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? password;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? pinCode;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionUnlockDto &&
other.password == password &&
other.pinCode == pinCode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(password == null ? 0 : password!.hashCode) +
(pinCode == null ? 0 : pinCode!.hashCode);
@override
String toString() => 'SessionUnlockDto[password=$password, pinCode=$pinCode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.password != null) {
json[r'password'] = this.password;
} else {
// json[r'password'] = null;
}
if (this.pinCode != null) {
json[r'pinCode'] = this.pinCode;
} else {
// json[r'pinCode'] = null;
}
return json;
}
/// Returns a new [SessionUnlockDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SessionUnlockDto? fromJson(dynamic value) {
upgradeDto(value, "SessionUnlockDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SessionUnlockDto(
password: mapValueOfType<String>(json, r'password'),
pinCode: mapValueOfType<String>(json, r'pinCode'),
);
}
return null;
}
static List<SessionUnlockDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SessionUnlockDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SessionUnlockDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SessionUnlockDto> mapFromJson(dynamic json) {
final map = <String, SessionUnlockDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SessionUnlockDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SessionUnlockDto-objects as value to a dart map
static Map<String, List<SessionUnlockDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SessionUnlockDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SessionUnlockDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@ -293,12 +293,14 @@ class SyncAssetV1VisibilityEnum {
static const archive = SyncAssetV1VisibilityEnum._(r'archive');
static const timeline = SyncAssetV1VisibilityEnum._(r'timeline');
static const hidden = SyncAssetV1VisibilityEnum._(r'hidden');
static const locked = SyncAssetV1VisibilityEnum._(r'locked');
/// List of all possible values in this [enum][SyncAssetV1VisibilityEnum].
static const values = <SyncAssetV1VisibilityEnum>[
archive,
timeline,
hidden,
locked,
];
static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value);
@ -340,6 +342,7 @@ class SyncAssetV1VisibilityEnumTypeTransformer {
case r'archive': return SyncAssetV1VisibilityEnum.archive;
case r'timeline': return SyncAssetV1VisibilityEnum.timeline;
case r'hidden': return SyncAssetV1VisibilityEnum.hidden;
case r'locked': return SyncAssetV1VisibilityEnum.locked;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -2377,7 +2377,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeChangeDto"
"$ref": "#/components/schemas/PinCodeResetDto"
}
}
},
@ -2470,6 +2470,66 @@
]
}
},
"/auth/session/lock": {
"post": {
"operationId": "lockAuthSession",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/session/unlock": {
"post": {
"operationId": "unlockAuthSession",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionUnlockDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/status": {
"get": {
"operationId": "getAuthStatus",
@ -5583,6 +5643,46 @@
"tags": [
"Sessions"
]
},
"post": {
"operationId": "createSession",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionCreateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/sessions/{id}": {
@ -5620,6 +5720,41 @@
]
}
},
"/sessions/{id}/lock": {
"post": {
"operationId": "lockSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/shared-links": {
"get": {
"operationId": "getAllSharedLinks",
@ -9150,6 +9285,15 @@
"updatedAt": {
"format": "date-time",
"type": "string"
},
"visibility": {
"enum": [
"archive",
"timeline",
"hidden",
"locked"
],
"type": "string"
}
},
"required": [
@ -9171,7 +9315,8 @@
"ownerId",
"thumbhash",
"type",
"updatedAt"
"updatedAt",
"visibility"
],
"type": "object"
},
@ -9226,7 +9371,8 @@
"enum": [
"archive",
"timeline",
"hidden"
"hidden",
"locked"
],
"type": "string"
},
@ -9241,14 +9387,24 @@
},
"AuthStatusResponseDto": {
"properties": {
"expiresAt": {
"type": "string"
},
"isElevated": {
"type": "boolean"
},
"password": {
"type": "boolean"
},
"pinCode": {
"type": "boolean"
},
"pinExpiresAt": {
"type": "string"
}
},
"required": [
"isElevated",
"password",
"pinCode"
],
@ -11002,9 +11158,11 @@
"person.statistics",
"person.merge",
"person.reassign",
"session.create",
"session.read",
"session.update",
"session.delete",
"session.lock",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
@ -11206,6 +11364,18 @@
],
"type": "object"
},
"PinCodeResetDto": {
"properties": {
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"type": "object"
},
"PinCodeSetupDto": {
"properties": {
"pinCode": {
@ -11988,6 +12158,60 @@
],
"type": "object"
},
"SessionCreateDto": {
"properties": {
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"duration": {
"description": "session duration, in seconds",
"minimum": 1,
"type": "number"
}
},
"type": "object"
},
"SessionCreateResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"expiresAt": {
"type": "string"
},
"id": {
"type": "string"
},
"token": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"token",
"updatedAt"
],
"type": "object"
},
"SessionResponseDto": {
"properties": {
"createdAt": {
@ -12002,6 +12226,9 @@
"deviceType": {
"type": "string"
},
"expiresAt": {
"type": "string"
},
"id": {
"type": "string"
},
@ -12019,6 +12246,18 @@
],
"type": "object"
},
"SessionUnlockDto": {
"properties": {
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"type": "object"
},
"SharedLinkCreateDto": {
"properties": {
"albumId": {
@ -12664,7 +12903,8 @@
"enum": [
"archive",
"timeline",
"hidden"
"hidden",
"locked"
],
"type": "string"
}

View File

@ -329,6 +329,7 @@ export type AssetResponseDto = {
"type": AssetTypeEnum;
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
updatedAt: string;
visibility: Visibility;
};
export type AlbumResponseDto = {
albumName: string;
@ -511,17 +512,28 @@ export type LogoutResponseDto = {
redirectUri: string;
successful: boolean;
};
export type PinCodeChangeDto = {
newPinCode: string;
export type PinCodeResetDto = {
password?: string;
pinCode?: string;
};
export type PinCodeSetupDto = {
pinCode: string;
};
export type PinCodeChangeDto = {
newPinCode: string;
password?: string;
pinCode?: string;
};
export type SessionUnlockDto = {
password?: string;
pinCode?: string;
};
export type AuthStatusResponseDto = {
expiresAt?: string;
isElevated: boolean;
password: boolean;
pinCode: boolean;
pinExpiresAt?: string;
};
export type ValidateAccessTokenResponseDto = {
authStatus: boolean;
@ -1073,9 +1085,26 @@ export type SessionResponseDto = {
current: boolean;
deviceOS: string;
deviceType: string;
expiresAt?: string;
id: string;
updatedAt: string;
};
export type SessionCreateDto = {
deviceOS?: string;
deviceType?: string;
/** session duration, in seconds */
duration?: number;
};
export type SessionCreateResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
expiresAt?: string;
id: string;
token: string;
updatedAt: string;
};
export type SharedLinkResponseDto = {
album?: AlbumResponseDto;
allowDownload: boolean;
@ -2049,13 +2078,13 @@ export function logout(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
export function resetPinCode({ pinCodeChangeDto }: {
pinCodeChangeDto: PinCodeChangeDto;
export function resetPinCode({ pinCodeResetDto }: {
pinCodeResetDto: PinCodeResetDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "DELETE",
body: pinCodeChangeDto
body: pinCodeResetDto
})));
}
export function setupPinCode({ pinCodeSetupDto }: {
@ -2076,6 +2105,21 @@ export function changePinCode({ pinCodeChangeDto }: {
body: pinCodeChangeDto
})));
}
export function lockAuthSession(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", {
...opts,
method: "POST"
}));
}
export function unlockAuthSession({ sessionUnlockDto }: {
sessionUnlockDto: SessionUnlockDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/session/unlock", oazapfts.json({
...opts,
method: "POST",
body: sessionUnlockDto
})));
}
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -2906,6 +2950,18 @@ export function getSessions(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function createSession({ sessionCreateDto }: {
sessionCreateDto: SessionCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: SessionCreateResponseDto;
}>("/sessions", oazapfts.json({
...opts,
method: "POST",
body: sessionCreateDto
})));
}
export function deleteSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
@ -2914,6 +2970,14 @@ export function deleteSession({ id }: {
method: "DELETE"
}));
}
export function lockSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}/lock`, {
...opts,
method: "POST"
}));
}
export function getAllSharedLinks({ albumId }: {
albumId?: string;
}, opts?: Oazapfts.RequestOpts) {
@ -3574,7 +3638,8 @@ export enum UserStatus {
export enum AssetVisibility {
Archive = "archive",
Timeline = "timeline",
Hidden = "hidden"
Hidden = "hidden",
Locked = "locked"
}
export enum AlbumUserRole {
Editor = "editor",
@ -3591,6 +3656,12 @@ export enum AssetTypeEnum {
Audio = "AUDIO",
Other = "OTHER"
}
export enum Visibility {
Archive = "archive",
Timeline = "timeline",
Hidden = "hidden",
Locked = "locked"
}
export enum AssetOrder {
Asc = "asc",
Desc = "desc"
@ -3660,9 +3731,11 @@ export enum Permission {
PersonStatistics = "person.statistics",
PersonMerge = "person.merge",
PersonReassign = "person.reassign",
SessionCreate = "session.create",
SessionRead = "session.read",
SessionUpdate = "session.update",
SessionDelete = "session.delete",
SessionLock = "session.lock",
SharedLinkCreate = "sharedLink.create",
SharedLinkRead = "sharedLink.read",
SharedLinkUpdate = "sharedLink.update",

View File

@ -9,7 +9,9 @@ import {
LoginResponseDto,
LogoutResponseDto,
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
@ -98,7 +100,21 @@ export class AuthController {
@Delete('pin-code')
@Authenticated()
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@Post('session/unlock')
@HttpCode(HttpStatus.OK)
@Authenticated()
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@HttpCode(HttpStatus.OK)
@Authenticated()
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
}
}

View File

@ -66,7 +66,7 @@ describe(SearchController.name, () => {
.send({ visibility: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']),
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']),
);
});

View File

@ -1,7 +1,7 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service';
@ -12,6 +12,12 @@ import { UUIDParamDto } from 'src/validation';
export class SessionController {
constructor(private service: SessionService) {}
@Post()
@Authenticated({ permission: Permission.SESSION_CREATE })
createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.SESSION_READ })
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {
@ -31,4 +37,11 @@ export class SessionController {
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Post(':id/lock')
@Authenticated({ permission: Permission.SESSION_LOCK })
@HttpCode(HttpStatus.NO_CONTENT)
lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.lock(auth, id);
}
}

View File

@ -200,6 +200,7 @@ export type Album = Selectable<Albums> & {
export type AuthSession = {
id: string;
hasElevatedPermission: boolean;
};
export type Partner = {
@ -231,8 +232,10 @@ export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
expiresAt: Date | null;
deviceOS: string;
deviceType: string;
pinExpiresAt: Date | null;
};
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
@ -306,7 +309,7 @@ export const columns = {
'users.quotaSizeInBytes',
],
authApiKey: ['api_keys.id', 'api_keys.permissions'],
authSession: ['sessions.id', 'sessions.updatedAt'],
authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'],
authSharedLink: [
'shared_links.id',
'shared_links.userId',

3
server/src/db.d.ts vendored
View File

@ -343,10 +343,13 @@ export interface Sessions {
deviceOS: Generated<string>;
deviceType: Generated<string>;
id: Generated<string>;
parentId: string | null;
expiresAt: Date | null;
token: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string;
pinExpiresAt: Timestamp | null;
}
export interface SessionSyncCheckpoints {

View File

@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
isArchived!: boolean;
isTrashed!: boolean;
isOffline!: boolean;
visibility!: AssetVisibility;
exifInfo?: ExifResponseDto;
tags?: TagResponseDto[];
people?: PersonWithFacesResponseDto[];
@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
isArchived: entity.visibility === AssetVisibility.ARCHIVE,
isTrashed: !!entity.deletedAt,
visibility: entity.visibility,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,

View File

@ -93,6 +93,8 @@ export class PinCodeResetDto {
password?: string;
}
export class SessionUnlockDto extends PinCodeResetDto {}
export class PinCodeChangeDto extends PinCodeResetDto {
@PinCode()
newPinCode!: string;
@ -138,4 +140,7 @@ export class OAuthAuthorizeResponseDto {
export class AuthStatusResponseDto {
pinCode!: boolean;
password!: boolean;
isElevated!: boolean;
expiresAt?: string;
pinExpiresAt?: string;
}

View File

@ -1,18 +1,44 @@
import { IsInt, IsPositive, IsString } from 'class-validator';
import { Session } from 'src/database';
import { Optional } from 'src/validation';
export class SessionCreateDto {
/**
* session duration, in seconds
*/
@IsInt()
@IsPositive()
@Optional()
duration?: number;
@IsString()
@Optional()
deviceType?: string;
@IsString()
@Optional()
deviceOS?: string;
}
export class SessionResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
expiresAt?: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
}
export class SessionCreateResponseDto extends SessionResponseDto {
token!: string;
}
export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
expiresAt: entity.expiresAt?.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,

View File

@ -144,9 +144,11 @@ export enum Permission {
PERSON_MERGE = 'person.merge',
PERSON_REASSIGN = 'person.reassign',
SESSION_CREATE = 'session.create',
SESSION_READ = 'session.read',
SESSION_UPDATE = 'session.update',
SESSION_DELETE = 'session.delete',
SESSION_LOCK = 'session.lock',
SHARED_LINK_CREATE = 'sharedLink.create',
SHARED_LINK_READ = 'sharedLink.read',
@ -627,4 +629,5 @@ export enum AssetVisibility {
* Video part of the LivePhotos and MotionPhotos
*/
HIDDEN = 'hidden',
LOCKED = 'locked',
}

View File

@ -98,6 +98,7 @@ from
where
"assets"."id" in ($1)
and "assets"."ownerId" = $2
and "assets"."visibility" != $3
-- AccessRepository.asset.checkPartnerAccess
select
@ -198,6 +199,15 @@ where
"partners"."sharedById" in ($1)
and "partners"."sharedWithId" = $2
-- AccessRepository.session.checkOwnerAccess
select
"sessions"."id"
from
"sessions"
where
"sessions"."id" in ($1)
and "sessions"."userId" = $2
-- AccessRepository.stack.checkOwnerAccess
select
"stacks"."id"

View File

@ -392,6 +392,11 @@ where
order by
"albums"."createdAt" desc
-- AlbumRepository.removeAssetsFromAll
delete from "albums_assets_assets"
where
"albums_assets_assets"."assetsId" in ($1)
-- AlbumRepository.getAssetIds
select
*

View File

@ -432,3 +432,34 @@ where
and "assets"."updatedAt" > $3
limit
$4
-- AssetRepository.detectOfflineExternalAssets
update "assets"
set
"isOffline" = $1,
"deletedAt" = $2
where
"isOffline" = $3
and "isExternal" = $4
and "libraryId" = $5::uuid
and (
not "originalPath" like $6
or "originalPath" like $7
)
-- AssetRepository.filterNewExternalAssetPaths
select
"path"
from
unnest(array[$1]::text[]) as "path"
where
not exists (
select
"originalPath"
from
"assets"
where
"assets"."originalPath" = "path"
and "libraryId" = $2::uuid
and "isExternal" = $3
)

View File

@ -14,8 +14,3 @@ order by
"audit"."entityId" desc,
"audit"."entityType" desc,
"audit"."createdAt" desc
-- AuditRepository.removeBefore
delete from "audit"
where
"createdAt" < $1

View File

@ -1,11 +1,5 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MemoryRepository.cleanup
delete from "memories"
where
"createdAt" < $1
and "isSaved" = $2
-- MemoryRepository.search
select
"memories".*,

View File

@ -16,19 +16,6 @@ where
returning
*
-- MoveRepository.cleanMoveHistory
delete from "move_history"
where
"move_history"."entityId" not in (
select
"id"
from
"assets"
where
"assets"."id" = "move_history"."entityId"
)
and "move_history"."pathType" = 'original'
-- MoveRepository.cleanMoveHistorySingle
delete from "move_history"
where

View File

@ -1,23 +1,5 @@
-- NOTE: This file is auto generated by ./sql-generator
-- NotificationRepository.cleanup
delete from "notifications"
where
(
(
"deletedAt" is not null
and "deletedAt" < $1
)
or (
"readAt" > $2
and "createdAt" < $3
)
or (
"readAt" = $4
and "createdAt" < $5
)
)
-- NotificationRepository.search
select
"id",

View File

@ -100,50 +100,6 @@ where
"sharedWithId" = $1
and "sharedById" = $2
-- PartnerRepository.create
insert into
"partners" ("sharedWithId", "sharedById")
values
($1, $2)
returning
*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"users" as "sharedBy"
where
"sharedBy"."id" = "partners"."sharedById"
) as obj
) as "sharedBy",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"users" as "sharedWith"
where
"sharedWith"."id" = "partners"."sharedWithId"
) as obj
) as "sharedWith"
-- PartnerRepository.update
update "partners"
set

View File

@ -7,34 +7,10 @@ set
where
"asset_faces"."personId" = $2
-- PersonRepository.unassignFaces
update "asset_faces"
set
"personId" = $1
where
"asset_faces"."sourceType" = $2
VACUUM
ANALYZE asset_faces,
face_search,
person
REINDEX TABLE asset_faces
REINDEX TABLE person
-- PersonRepository.delete
delete from "person"
where
"person"."id" in $1
-- PersonRepository.deleteFaces
delete from "asset_faces"
where
"asset_faces"."sourceType" = $1
VACUUM
ANALYZE asset_faces,
face_search,
person
REINDEX TABLE asset_faces
REINDEX TABLE person
"person"."id" in ($1)
-- PersonRepository.getAllWithoutFaces
select
@ -145,18 +121,24 @@ select
"asset_faces"."imageHeight" as "oldHeight",
"assets"."type",
"assets"."originalPath",
"asset_files"."path" as "previewPath",
"exif"."orientation" as "exifOrientation"
"exif"."orientation" as "exifOrientation",
(
select
"asset_files"."path"
from
"asset_files"
where
"asset_files"."assetId" = "assets"."id"
and "asset_files"."type" = 'preview'
) as "previewPath"
from
"person"
inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId"
inner join "assets" on "asset_faces"."assetId" = "assets"."id"
left join "exif" on "exif"."assetId" = "assets"."id"
left join "asset_files" on "asset_files"."assetId" = "assets"."id"
where
"person"."id" = $1
and "asset_faces"."deletedAt" is null
and "asset_files"."type" = $2
-- PersonRepository.reassignFace
update "asset_faces"
@ -222,21 +204,6 @@ where
"person"."ownerId" = $3
and "asset_faces"."deletedAt" is null
-- PersonRepository.refreshFaces
with
"added_embeddings" as (
insert into
"face_search" ("faceId", "embedding")
values
($1, $2)
)
select
from
(
select
1
) as "dummy"
-- PersonRepository.getFacesByIds
select
"asset_faces".*,

View File

@ -1,17 +1,20 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.search
-- SessionRepository.get
select
*
"id",
"expiresAt",
"pinExpiresAt"
from
"sessions"
where
"sessions"."updatedAt" <= $1
"id" = $1
-- SessionRepository.getByToken
select
"sessions"."id",
"sessions"."updatedAt",
"sessions"."pinExpiresAt",
(
select
to_json(obj)
@ -35,6 +38,10 @@ from
"sessions"
where
"sessions"."token" = $1
and (
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
-- SessionRepository.getByUserId
select
@ -45,6 +52,10 @@ from
and "users"."deletedAt" is null
where
"sessions"."userId" = $1
and (
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
order by
"sessions"."updatedAt" desc,
"sessions"."createdAt" desc
@ -53,3 +64,10 @@ order by
delete from "sessions"
where
"id" = $1::uuid
-- SessionRepository.lockAll
update "sessions"
set
"pinExpiresAt" = $1
where
"userId" = $2

View File

@ -8,15 +8,6 @@ from
where
"key" = $1
-- SystemMetadataRepository.set
insert into
"system_metadata" ("key", "value")
values
($1, $2)
on conflict ("key") do update
set
"value" = $3
-- SystemMetadataRepository.delete
delete from "system_metadata"
where

View File

@ -58,7 +58,7 @@ from
where
"userId" = $1
order by
"value" asc
"value"
-- TagRepository.create
insert into
@ -94,6 +94,15 @@ where
"tagsId" = $1
and "assetsId" in ($2)
-- TagRepository.upsertAssetIds
insert into
"tag_asset" ("assetId", "tagsIds")
values
($1, $2)
on conflict do nothing
returning
*
-- TagRepository.replaceAssetTags
begin
delete from "tag_asset"
@ -107,17 +116,3 @@ on conflict do nothing
returning
*
rollback
-- TagRepository.deleteEmptyTags
begin
select
"tags"."id",
count("assets"."id") as "count"
from
"assets"
inner join "tag_asset" on "tag_asset"."assetsId" = "assets"."id"
inner join "tags_closure" on "tags_closure"."id_descendant" = "tag_asset"."tagsId"
inner join "tags" on "tags"."id" = "tags_closure"."id_descendant"
group by
"tags"."id"
commit

View File

@ -15,11 +15,3 @@ from
"version_history"
order by
"createdAt" desc
-- VersionHistoryRepository.create
insert into
"version_history" ("version")
values
($1)
returning
*

View File

@ -168,7 +168,7 @@ class AssetAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, assetIds: Set<string>) {
async checkOwnerAccess(userId: string, assetIds: Set<string>, hasElevatedPermission: boolean | undefined) {
if (assetIds.size === 0) {
return new Set<string>();
}
@ -178,6 +178,7 @@ class AssetAccess {
.select('assets.id')
.where('assets.id', 'in', [...assetIds])
.where('assets.ownerId', '=', userId)
.$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED))
.execute()
.then((assets) => new Set(assets.map((asset) => asset.id)));
}
@ -305,6 +306,25 @@ class NotificationAccess {
}
}
class SessionAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, sessionIds: Set<string>) {
if (sessionIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('sessions')
.select('sessions.id')
.where('sessions.id', 'in', [...sessionIds])
.where('sessions.userId', '=', userId)
.execute()
.then((sessions) => new Set(sessions.map((session) => session.id)));
}
}
class StackAccess {
constructor(private db: Kysely<DB>) {}
@ -455,6 +475,7 @@ export class AccessRepository {
notification: NotificationAccess;
person: PersonAccess;
partner: PartnerAccess;
session: SessionAccess;
stack: StackAccess;
tag: TagAccess;
timeline: TimelineAccess;
@ -468,6 +489,7 @@ export class AccessRepository {
this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db);
this.session = new SessionAccess(db);
this.stack = new StackAccess(db);
this.tag = new TagAccess(db);
this.timeline = new TimelineAccess(db);

View File

@ -220,8 +220,10 @@ export class AlbumRepository {
await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
}
async removeAsset(assetId: string): Promise<void> {
await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute();
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
async removeAssetsFromAll(assetIds: string[]): Promise<void> {
await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute();
}
@Chunked({ paramIndex: 1 })

View File

@ -817,9 +817,7 @@ export class AssetRepository {
.execute();
}
@GenerateSql({
params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }],
})
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING], [DummyValue.STRING]] })
async detectOfflineExternalAssets(
libraryId: string,
importPaths: string[],
@ -846,9 +844,7 @@ export class AssetRepository {
.executeTakeFirstOrThrow();
}
@GenerateSql({
params: [{ libraryId: DummyValue.UUID, paths: [DummyValue.STRING] }],
})
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] })
async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise<string[]> {
const result = await this.db
.selectFrom(unnest(paths).as('path'))

View File

@ -38,7 +38,6 @@ export class AuditRepository {
return records.map(({ entityId }) => entityId);
}
@GenerateSql({ params: [DummyValue.DATE] })
async removeBefore(before: Date): Promise<void> {
await this.db.deleteFrom('audit').where('createdAt', '<', before).execute();
}

View File

@ -54,7 +54,7 @@ export class CryptoRepository {
});
}
newPassword(bytes: number) {
randomBytesAsText(bytes: number) {
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
}
}

View File

@ -12,7 +12,6 @@ import { IBulkAsset } from 'src/types';
export class MemoryRepository implements IBulkAsset {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
cleanup() {
return this.db
.deleteFrom('memories')

View File

@ -37,7 +37,6 @@ export class MoveRepository {
return this.db.deleteFrom('move_history').where('id', '=', id).returningAll().executeTakeFirstOrThrow();
}
@GenerateSql()
async cleanMoveHistory(): Promise<void> {
await this.db
.deleteFrom('move_history')
@ -52,7 +51,7 @@ export class MoveRepository {
.execute();
}
@GenerateSql()
@GenerateSql({ params: [DummyValue.UUID] })
async cleanMoveHistorySingle(assetId: string): Promise<void> {
await this.db
.deleteFrom('move_history')

View File

@ -9,7 +9,6 @@ import { NotificationSearchDto } from 'src/dtos/notification.dto';
export class NotificationRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
cleanup() {
return this.db
.deleteFrom('notifications')

View File

@ -47,7 +47,6 @@ export class PartnerRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
create(values: Insertable<Partners>) {
return this.db
.insertInto('partners')

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
@ -98,18 +98,15 @@ export class PersonRepository {
return Number(result.numChangedRows ?? 0);
}
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db
.updateTable('asset_faces')
.set({ personId: null })
.where('asset_faces.sourceType', '=', sourceType)
.execute();
await this.vacuum({ reindexVectors: false });
}
@GenerateSql({ params: [DummyValue.UUID] })
@GenerateSql({ params: [[DummyValue.UUID]] })
async delete(ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
@ -118,11 +115,8 @@ export class PersonRepository {
await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
}
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute();
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
}
getAllFaces(options: GetAllFacesOptions = {}) {
@ -265,7 +259,6 @@ export class PersonRepository {
.innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId')
.innerJoin('assets', 'asset_faces.assetId', 'assets.id')
.leftJoin('exif', 'exif.assetId', 'assets.id')
.leftJoin('asset_files', 'asset_files.assetId', 'assets.id')
.select([
'person.ownerId',
'asset_faces.boundingBoxX1 as x1',
@ -276,13 +269,18 @@ export class PersonRepository {
'asset_faces.imageHeight as oldHeight',
'assets.type',
'assets.originalPath',
'asset_files.path as previewPath',
'exif.orientation as exifOrientation',
])
.select((eb) =>
eb
.selectFrom('asset_files')
.select('asset_files.path')
.whereRef('asset_files.assetId', '=', 'assets.id')
.where('asset_files.type', '=', sql.lit(AssetFileType.PREVIEW))
.as('previewPath'),
)
.where('person.id', '=', id)
.where('asset_faces.deletedAt', 'is', null)
.where('asset_files.type', '=', AssetFileType.PREVIEW)
.$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>()
.executeTakeFirst();
}
@ -400,7 +398,6 @@ export class PersonRepository {
return results.map(({ id }) => id);
}
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
async refreshFaces(
facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
faceIdsToRemove: string[],
@ -519,7 +516,7 @@ export class PersonRepository {
await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute();
}
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
await sql`REINDEX TABLE asset_faces`.execute(this.db);
await sql`REINDEX TABLE person`.execute(this.db);

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, Sessions } from 'src/db';
@ -13,13 +14,26 @@ export type SessionSearchOptions = { updatedBefore: Date };
export class SessionRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions) {
cleanup() {
return this.db
.deleteFrom('sessions')
.where((eb) =>
eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()),
eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]),
]),
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.db
.selectFrom('sessions')
.selectAll()
.where('sessions.updatedAt', '<=', options.updatedBefore)
.execute();
.select(['id', 'expiresAt', 'pinExpiresAt'])
.where('id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.STRING] })
@ -37,6 +51,9 @@ export class SessionRepository {
).as('user'),
])
.where('sessions.token', '=', token)
.where((eb) =>
eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]),
)
.executeTakeFirst();
}
@ -47,6 +64,9 @@ export class SessionRepository {
.innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions')
.where('sessions.userId', '=', userId)
.where((eb) =>
eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]),
)
.orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc')
.execute();
@ -69,4 +89,9 @@ export class SessionRepository {
async delete(id: string) {
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async lockAll(userId: string) {
await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
}
}

View File

@ -26,7 +26,6 @@ export class SystemMetadataRepository {
return metadata.value as SystemMetadata[T];
}
@GenerateSql({ params: ['metadata_key', { foo: 'bar' }] })
async set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void> {
await this.db
.insertInto('system_metadata')

View File

@ -68,7 +68,7 @@ export class TagRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getAll(userId: string) {
return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value asc').execute();
return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value').execute();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] })
@ -126,7 +126,7 @@ export class TagRepository {
await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute();
}
@GenerateSql({ params: [{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }] })
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }]] })
@Chunked()
upsertAssetIds(items: Insertable<TagAsset>[]) {
if (items.length === 0) {
@ -160,7 +160,6 @@ export class TagRepository {
});
}
@GenerateSql()
async deleteEmptyTags() {
// TODO rewrite as a single statement
await this.db.transaction().execute(async (tx) => {

View File

@ -18,7 +18,6 @@ export class VersionHistoryRepository {
return this.db.selectFrom('version_history').selectAll().orderBy('createdAt', 'desc').executeTakeFirst();
}
@GenerateSql({ params: [{ version: 'v1.123.0' }] })
create(version: Insertable<VersionHistory>) {
return this.db.insertInto('version_history').values(version).returningAll().executeTakeFirstOrThrow();
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TYPE "asset_visibility_enum" ADD VALUE IF NOT EXISTS 'locked';`.execute(db);
}
export async function down(): Promise<void> {
// noop
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" ADD "pinExpiresAt" timestamp with time zone;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" DROP COLUMN "pinExpiresAt";`.execute(db);
}

View File

@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" ADD "expiredAt" timestamp with time zone;`.execute(db);
await sql`ALTER TABLE "sessions" ADD "parentId" uuid;`.execute(db);
await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8" FOREIGN KEY ("parentId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_afbbabbd7daf5b91de4dca84de" ON "sessions" ("parentId")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_afbbabbd7daf5b91de4dca84de";`.execute(db);
await sql`ALTER TABLE "sessions" DROP CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8";`.execute(db);
await sql`ALTER TABLE "sessions" DROP COLUMN "expiredAt";`.execute(db);
await sql`ALTER TABLE "sessions" DROP COLUMN "parentId";`.execute(db);
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" RENAME "expiredAt" TO "expiresAt";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" RENAME "expiresAt" TO "expiredAt";`.execute(db);
}

View File

@ -25,9 +25,15 @@ export class SessionTable {
@UpdateDateColumn()
updatedAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
expiresAt!: Date | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
parentId!: string | null;
@Column({ default: '' })
deviceType!: string;
@ -36,4 +42,7 @@ export class SessionTable {
@UpdateIdColumn({ indexName: 'IDX_sessions_update_id' })
updateId!: string;
@Column({ type: 'timestamp with time zone', nullable: true })
pinExpiresAt!: Date | null;
}

View File

@ -163,7 +163,7 @@ describe(AlbumService.name, () => {
);
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
id: albumStub.empty.id,
userId: 'user-id',
@ -207,6 +207,7 @@ describe(AlbumService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1', 'asset-2']),
false,
);
});
});
@ -688,7 +689,11 @@ describe(AlbumService.name, () => {
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1']),
false,
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
});

View File

@ -18,7 +18,7 @@ describe(ApiKeyService.name, () => {
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] });
const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key);
mocks.crypto.randomBytesAsText.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions });
@ -29,7 +29,7 @@ describe(ApiKeyService.name, () => {
permissions: apiKey.permissions,
userId: apiKey.userId,
});
expect(mocks.crypto.newPassword).toHaveBeenCalled();
expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled();
});
@ -38,7 +38,7 @@ describe(ApiKeyService.name, () => {
const apiKey = factory.apiKey({ userId: auth.user.id });
const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key);
mocks.crypto.randomBytesAsText.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { permissions: [Permission.ALL] });
@ -49,7 +49,7 @@ describe(ApiKeyService.name, () => {
permissions: [Permission.ALL],
userId: auth.user.id,
});
expect(mocks.crypto.newPassword).toHaveBeenCalled();
expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled();
});

View File

@ -9,20 +9,21 @@ import { isGranted } from 'src/utils/access';
@Injectable()
export class ApiKeyService extends BaseService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const entity = await this.apiKeyRepository.create({
key: this.cryptoRepository.hashSha256(secret),
key: tokenHashed,
name: dto.name || 'API Key',
userId: auth.user.id,
permissions: dto.permissions,
});
return { secret, apiKey: this.map(entity) };
return { secret: token, apiKey: this.map(entity) };
}
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {

View File

@ -481,7 +481,11 @@ describe(AssetMediaService.name, () => {
it('should require the asset.download permission', async () => {
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1']),
undefined,
);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
});
@ -512,7 +516,7 @@ describe(AssetMediaService.name, () => {
it('should require asset.view permissions', async () => {
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
});
@ -611,7 +615,7 @@ describe(AssetMediaService.name, () => {
it('should require asset.view permissions', async () => {
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
});

View File

@ -122,6 +122,7 @@ describe(AssetService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
undefined,
);
});

View File

@ -14,7 +14,7 @@ import {
mapStats,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@ -125,6 +125,10 @@ export class AssetService extends BaseService {
options.rating !== undefined
) {
await this.assetRepository.updateAll(ids, options);
if (options.visibility === AssetVisibility.LOCKED) {
await this.albumRepository.removeAssetsFromAll(ids);
}
}
}

View File

@ -253,6 +253,7 @@ describe(AuthService.name, () => {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
pinExpiresAt: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@ -265,7 +266,7 @@ describe(AuthService.name, () => {
}),
).resolves.toEqual({
user: sessionWithToken.user,
session: { id: session.id },
session: { id: session.id, hasElevatedPermission: false },
});
});
});
@ -376,6 +377,7 @@ describe(AuthService.name, () => {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
pinExpiresAt: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@ -388,7 +390,7 @@ describe(AuthService.name, () => {
}),
).resolves.toEqual({
user: sessionWithToken.user,
session: { id: session.id },
session: { id: session.id, hasElevatedPermission: false },
});
});
@ -398,6 +400,7 @@ describe(AuthService.name, () => {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
pinExpiresAt: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@ -417,6 +420,7 @@ describe(AuthService.name, () => {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
pinExpiresAt: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@ -916,13 +920,17 @@ describe(AuthService.name, () => {
describe('resetPinCode', () => {
it('should reset the PIN code', async () => {
const currentSession = factory.session();
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
mocks.session.lockAll.mockResolvedValue(void 0);
mocks.session.update.mockResolvedValue(currentSession);
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
});
it('should throw if the PIN code does not match', async () => {

View File

@ -18,6 +18,7 @@ import {
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
mapLoginResponse,
} from 'src/dtos/auth.dto';
@ -123,20 +124,21 @@ export class AuthService extends BaseService {
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
this.validatePinCode(user, dto);
await this.userRepository.update(auth.user.id, { pinCode: null });
await this.sessionRepository.lockAll(auth.user.id);
}
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
this.validatePinCode(user, dto);
const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
private resetPinChecks(
private validatePinCode(
user: { pinCode: string | null; password: string | null },
dto: { pinCode?: string; password?: string },
) {
@ -444,10 +446,25 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
}
// Pin check
let hasElevatedPermission = false;
if (session.pinExpiresAt) {
const pinExpiresAt = DateTime.fromJSDate(session.pinExpiresAt);
hasElevatedPermission = pinExpiresAt > now;
if (hasElevatedPermission && now.plus({ minutes: 5 }) > pinExpiresAt) {
await this.sessionRepository.update(session.id, {
pinExpiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate(),
});
}
}
return {
user: session.user,
session: {
id: session.id,
hasElevatedPermission,
},
};
}
@ -455,18 +472,39 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid user token');
}
async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise<void> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
const user = await this.userRepository.getForPinCode(auth.user.id);
this.validatePinCode(user, { pinCode: dto.pinCode });
await this.sessionRepository.update(auth.session.id, {
pinExpiresAt: DateTime.now().plus({ minutes: 15 }).toJSDate(),
});
}
async lockSession(auth: AuthDto): Promise<void> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key);
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
await this.sessionRepository.create({
token,
token: tokenHashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
userId: user.id,
});
return mapLoginResponse(user, key);
return mapLoginResponse(user, token);
}
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {
@ -490,9 +528,14 @@ export class AuthService extends BaseService {
throw new UnauthorizedException();
}
const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined;
return {
pinCode: !!user.pinCode,
password: !!user.password,
isElevated: !!auth.session?.hasElevatedPermission,
expiresAt: session?.expiresAt?.toISOString(),
pinExpiresAt: session?.pinExpiresAt?.toISOString(),
};
}
}

View File

@ -17,7 +17,7 @@ export class CliService extends BaseService {
}
const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.newPassword(24);
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userRepository.update(admin.id, { password: hashedPassword });

View File

@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
);
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
});
it('should handle not finding a match', async () => {
@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
);
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
});
it('should link photo and video', async () => {
@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => {
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.HIDDEN,
});
expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
});
it('should notify clients on live photo link', async () => {

View File

@ -158,7 +158,7 @@ export class MetadataService extends BaseService {
await Promise.all([
this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }),
this.albumRepository.removeAsset(motionAsset.id),
this.albumRepository.removeAssetsFromAll([motionAsset.id]),
]);
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });

View File

@ -459,6 +459,7 @@ describe(PersonService.name, () => {
await sut.handleQueueDetectFaces({ force: false });
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(false);
expect(mocks.person.vacuum).not.toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.FACE_DETECTION,
@ -475,6 +476,7 @@ describe(PersonService.name, () => {
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
@ -492,6 +494,7 @@ describe(PersonService.name, () => {
expect(mocks.person.delete).not.toHaveBeenCalled();
expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
expect(mocks.person.vacuum).not.toHaveBeenCalled();
expect(mocks.storage.unlink).not.toHaveBeenCalled();
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
@ -521,6 +524,7 @@ describe(PersonService.name, () => {
]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
});
});
@ -584,6 +588,7 @@ describe(PersonService.name, () => {
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, {
lastRun: expect.any(String),
});
expect(mocks.person.vacuum).not.toHaveBeenCalled();
});
it('should queue all assets', async () => {
@ -611,6 +616,7 @@ describe(PersonService.name, () => {
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, {
lastRun: expect.any(String),
});
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
});
it('should run nightly if new face has been added since last run', async () => {
@ -629,11 +635,14 @@ describe(PersonService.name, () => {
mocks.person.getAllWithoutFaces.mockResolvedValue([]);
mocks.person.unassignFaces.mockResolvedValue();
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
await sut.handleQueueRecognizeFaces({ force: false, nightly: true });
expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce();
expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined);
expect(mocks.person.getAllFaces).toHaveBeenCalledWith({
personId: null,
sourceType: SourceType.MACHINE_LEARNING,
});
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.FACIAL_RECOGNITION,
@ -643,6 +652,7 @@ describe(PersonService.name, () => {
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, {
lastRun: expect.any(String),
});
expect(mocks.person.vacuum).not.toHaveBeenCalled();
});
it('should skip nightly if no new face has been added since last run', async () => {
@ -660,6 +670,7 @@ describe(PersonService.name, () => {
expect(mocks.person.getAllFaces).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
expect(mocks.person.vacuum).not.toHaveBeenCalled();
});
it('should delete existing people if forced', async () => {
@ -688,6 +699,7 @@ describe(PersonService.name, () => {
]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
});
});

View File

@ -259,6 +259,7 @@ export class PersonService extends BaseService {
if (force) {
await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup();
await this.personRepository.vacuum({ reindexVectors: true });
}
let jobs: JobItem[] = [];
@ -409,6 +410,7 @@ export class PersonService extends BaseService {
if (force) {
await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup();
await this.personRepository.vacuum({ reindexVectors: false });
} else if (waiting) {
this.logger.debug(
`Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`,

View File

@ -17,29 +17,9 @@ describe('SessionService', () => {
});
describe('handleCleanup', () => {
it('should return skipped if nothing is to be deleted', async () => {
mocks.session.search.mockResolvedValue([]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED);
expect(mocks.session.search).toHaveBeenCalled();
});
it('should delete sessions', async () => {
mocks.session.search.mockResolvedValue([
{
createdAt: new Date('1970-01-01T00:00:00.00Z'),
updatedAt: new Date('1970-01-02T00:00:00.00Z'),
deviceOS: '',
deviceType: '',
id: '123',
token: '420',
userId: '42',
updateId: 'uuid-v7',
},
]);
mocks.session.delete.mockResolvedValue();
it('should clean sessions', async () => {
mocks.session.cleanup.mockResolvedValue([]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.session.delete).toHaveBeenCalledWith('123');
});
});

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
@ -10,16 +10,8 @@ import { BaseService } from 'src/services/base.service';
export class SessionService extends BaseService {
@OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> {
const sessions = await this.sessionRepository.search({
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
});
if (sessions.length === 0) {
return JobStatus.SKIPPED;
}
const sessions = await this.sessionRepository.cleanup();
for (const session of sessions) {
await this.sessionRepository.delete(session.id);
this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`);
}
@ -28,6 +20,25 @@ export class SessionService extends BaseService {
return JobStatus.SUCCESS;
}
async create(auth: AuthDto, dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
const session = await this.sessionRepository.create({
parentId: auth.session.id,
userId: auth.user.id,
expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
deviceType: dto.deviceType,
deviceOS: dto.deviceOS,
token: tokenHashed,
});
return { ...mapSession(session), token };
}
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
return sessions.map((session) => mapSession(session, auth.session?.id));
@ -38,6 +49,11 @@ export class SessionService extends BaseService {
await this.sessionRepository.delete(id);
}
async lock(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] });
await this.sessionRepository.update(id, { pinExpiresAt: null });
}
async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {

View File

@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
false,
);
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,
@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
false,
);
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,

View File

@ -81,7 +81,7 @@ const checkSharedLinkAccess = async (
case Permission.ASSET_SHARE: {
// TODO: fix this to not use sharedLink.userId for access control
return await access.asset.checkOwnerAccess(sharedLink.userId, ids);
return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false);
}
case Permission.ALBUM_READ: {
@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.ASSET_READ: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_SHARE: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.ASSET_VIEW: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_DOWNLOAD: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_UPDATE: {
return await access.asset.checkOwnerAccess(auth.user.id, ids);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.ASSET_DELETE: {
return await access.asset.checkOwnerAccess(auth.user.id, ids);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.ALBUM_READ: {
@ -280,6 +280,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return await access.partner.checkUpdateAccess(auth.user.id, ids);
}
case Permission.SESSION_READ:
case Permission.SESSION_UPDATE:
case Permission.SESSION_DELETE:
case Permission.SESSION_LOCK: {
return access.session.checkOwnerAccess(auth.user.id, ids);
}
case Permission.STACK_READ: {
return access.stack.checkOwnerAccess(auth.user.id, ids);
}

View File

@ -1,4 +1,4 @@
import { Session } from 'src/database';
import { AuthSession } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
const authUser = {
@ -26,7 +26,7 @@ export const authStub = {
user: authUser.user1,
session: {
id: 'token-id',
} as Session,
} as AuthSession,
}),
user2: Object.freeze<AuthDto>({
user: {
@ -39,7 +39,7 @@ export const authStub = {
},
session: {
id: 'token-id',
} as Session,
} as AuthSession,
}),
adminSharedLink: Object.freeze({
user: authUser.admin,

View File

@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = {
isTrashed: false,
libraryId: 'library-id',
hasMetadata: true,
visibility: AssetVisibility.TIMELINE,
};
const assetResponseWithoutMetadata = {

View File

@ -50,6 +50,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()),
},
session: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
stack: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},

View File

@ -12,6 +12,6 @@ export const newCryptoRepositoryMock = (): Mocked<RepositoryInterface<CryptoRepo
verifySha256: vitest.fn().mockImplementation(() => true),
hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)),
hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`),
newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
};
};

View File

@ -33,5 +33,6 @@ export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepo
createAssetFace: vitest.fn(),
deleteAssetFace: vitest.fn(),
softDeleteAssetFaces: vitest.fn(),
vacuum: vitest.fn(),
};
};

View File

@ -58,7 +58,7 @@ const authFactory = ({
}
if (session) {
auth.session = { id: session.id };
auth.session = { id: session.id, hasElevatedPermission: false };
}
if (sharedLink) {
@ -126,7 +126,10 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
deviceOS: 'android',
deviceType: 'mobile',
token: 'abc123',
parentId: null,
expiresAt: null,
userId: newUuid(),
pinExpiresAt: newDate(),
...session,
});

8
web/package-lock.json generated
View File

@ -11,7 +11,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.20.0",
"@immich/ui": "^0.21.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@ -1337,9 +1337,9 @@
"link": true
},
"node_modules/@immich/ui": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.20.0.tgz",
"integrity": "sha512-euK3N0AhQLB28qFteorRKyDUdet3UpA9MEAd8eBLbTtTFZKvZismBGa4J7pHbQrSkuOlbmJD5LJuM575q8zigQ==",
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.21.1.tgz",
"integrity": "sha512-ofDbLMYgM3Bnrv1nCbyPV5Gw9PdWvyhTAJPtojw4C3r2m7CbRW1kJDHt5M79n6xAVgjMOFyre1lOE5cwSSvRQA==",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@mdi/js": "^7.4.47",

View File

@ -18,7 +18,8 @@
"lint:p": "eslint-p . --max-warnings 0 --concurrency=4",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"format:fix": "prettier --write . && npm run format:i18n",
"format:i18n": "npx --yes sort-json ../i18n/*.json",
"test": "vitest --run",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
@ -27,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.20.0",
"@immich/ui": "^0.21.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",

View File

@ -21,34 +21,6 @@
--immich-dark-success: 56 142 60;
--immich-dark-warning: 245 124 0;
}
:root {
/* light */
--immich-ui-primary: 66 80 175;
--immich-ui-dark: 58 58 58;
--immich-ui-light: 255 255 255;
--immich-ui-success: 16 188 99;
--immich-ui-danger: 200 60 60;
--immich-ui-warning: 216 143 64;
--immich-ui-info: 8 111 230;
--immich-ui-gray: 246 246 246;
--immich-ui-default-border: 209 213 219;
}
.dark {
/* dark */
--immich-ui-primary: 172 203 250;
--immich-ui-light: 0 0 0;
--immich-ui-dark: 229 231 235;
--immich-ui-danger: 246 125 125;
--immich-ui-success: 72 237 152;
--immich-ui-warning: 254 197 132;
--immich-ui-info: 121 183 254;
--immich-ui-gray: 33 33 33;
--immich-ui-default-border: 55 65 81;
}
}
@font-face {

View File

@ -13,6 +13,8 @@ type ActionMap = {
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
};
export type Action = {

View File

@ -0,0 +1,60 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
}
let { asset, onAction, preAction }: Props = $props();
const isLocked = asset.visibility === Visibility.Locked;
const toggleLockedVisibility = async () => {
const isConfirmed = await modalManager.showDialog({
title: isLocked ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
confirmText: $t('move'),
confirmColor: isLocked ? 'danger' : 'primary',
});
if (!isConfirmed) {
return;
}
try {
preAction({
type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
asset,
});
await updateAssets({
assetBulkUpdateDto: {
ids: [asset.id],
visibility: isLocked ? AssetVisibility.Timeline : AssetVisibility.Locked,
},
});
onAction({
type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
asset,
});
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
}
};
</script>
<MenuOption
onClick={() => toggleLockedVisibility()}
text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline}
/>

View File

@ -12,6 +12,7 @@
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
@ -27,6 +28,7 @@
import {
AssetJobName,
AssetTypeEnum,
Visibility,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
@ -91,6 +93,7 @@
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
let isLocked = $derived(asset.visibility === Visibility.Locked);
// $: showEditorButton =
// isOwner &&
@ -112,7 +115,7 @@
{/if}
</div>
<div class="flex gap-2 overflow-x-auto text-white" data-testid="asset-viewer-navbar-actions">
{#if !asset.isTrashed && $user}
{#if !asset.isTrashed && $user && !isLocked}
<ShareAction {asset} />
{/if}
{#if asset.isOffline}
@ -159,17 +162,20 @@
<DeleteAction {asset} {onAction} {preAction} />
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow}
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<DownloadAction {asset} menuItem />
{/if}
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{#if !isLocked}
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{/if}
{/if}
{#if isOwner}
@ -183,21 +189,28 @@
{#if person}
<SetFeaturedPhotoAction {asset} {person} />
{/if}
{#if asset.type === AssetTypeEnum.Image}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
{#if !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
text={$t('view_in_timeline')}
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
text={$t('view_in_timeline')}
/>
{/if}
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction {asset} {onAction} {preAction} />
{/if}
<hr />
<MenuOption

View File

@ -1,20 +0,0 @@
import Button from '$lib/components/elements/buttons/button.svelte';
import { render, screen } from '@testing-library/svelte';
describe('Button component', () => {
it('should render as a button', () => {
render(Button);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('type', 'button');
expect(button).not.toHaveAttribute('href');
});
it('should render as a link if href prop is set', () => {
render(Button, { props: { href: '/test' } });
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/test');
expect(link).not.toHaveAttribute('type');
});
});

View File

@ -1,123 +0,0 @@
<script lang="ts" module>
export type Color =
| 'primary'
| 'primary-inversed'
| 'secondary'
| 'transparent-primary'
| 'text-primary'
| 'light-red'
| 'red'
| 'green'
| 'gray'
| 'transparent-gray'
| 'dark-gray'
| 'overlay-primary';
export type Size = 'tiny' | 'icon' | 'link' | 'sm' | 'base' | 'lg';
export type Rounded = 'lg' | '3xl' | 'full' | 'none';
export type Shadow = 'md' | false;
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
type?: string;
href?: string;
color?: Color;
size?: Size;
rounded?: Rounded;
shadow?: Shadow;
fullwidth?: boolean;
border?: boolean;
class?: string;
children?: Snippet;
onclick?: (event: MouseEvent) => void;
onfocus?: () => void;
onblur?: () => void;
form?: string;
disabled?: boolean;
title?: string;
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | undefined | null;
}
let {
type = 'button',
href = undefined,
color = 'primary',
size = 'base',
rounded = '3xl',
shadow = 'md',
fullwidth = false,
border = false,
class: className = '',
children,
onclick,
onfocus,
onblur,
...rest
}: Props = $props();
const colorClasses: Record<Color, string> = {
primary:
'bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/90',
secondary:
'bg-gray-500 dark:bg-gray-200 text-white dark:text-immich-dark-gray hover:bg-gray-500/90 dark:hover:bg-gray-200/90',
'transparent-primary': 'text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700',
'text-primary':
'text-immich-primary dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/10 hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] hover:bg-red-50',
red: 'bg-red-500 text-white hover:bg-red-400',
green: 'bg-green-400 text-gray-800 hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 hover:bg-gray-500/75 dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg hover:bg-immich-primary/5 hover:text-gray-700 hover:dark:text-immich-dark-fg dark:hover:bg-immich-dark-primary/25',
'dark-gray':
'dark:border-immich-dark-gray dark:bg-gray-500 dark:hover:bg-immich-dark-primary/50 hover:bg-immich-primary/10 dark:text-white',
'overlay-primary': 'text-gray-500 hover:bg-gray-100',
'primary-inversed':
'bg-immich-dark-primary dark:bg-immich-primary text-black dark:text-white hover:bg-immich-dark-primary/80 dark:hover:bg-immich-primary/90',
};
const sizeClasses: Record<Size, string> = {
tiny: 'p-0 ms-2 me-0 align-top',
icon: 'p-2.5',
link: 'p-2 font-medium',
sm: 'px-4 py-2 text-sm font-medium',
base: 'px-6 py-3 font-medium',
lg: 'px-6 py-4 font-semibold',
};
const roundedClasses: Record<Rounded, string> = {
none: '',
lg: 'rounded-lg',
'3xl': 'rounded-3xl',
full: 'rounded-full',
};
let computedClass = $derived(
[
className,
colorClasses[color],
sizeClasses[size],
roundedClasses[rounded],
shadow === 'md' && 'shadow-md',
fullwidth && 'w-full',
border && 'border',
]
.filter(Boolean)
.join(' '),
);
</script>
<svelte:element
this={href ? 'a' : 'button'}
type={href ? undefined : type}
{href}
{onclick}
{onfocus}
{onblur}
class="inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 disabled:pointer-events-none {computedClass}"
{...rest}
>
{@render children?.()}
</svelte:element>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { getTabbable } from '$lib/utils/focus-util';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import Button from './button.svelte';
interface Props {
/**
@ -58,8 +58,7 @@
<div class="absolute top-2 start-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
<Button
size="sm"
rounded="none"
size="small"
onclick={moveFocus}
class={getBreakpoint()}
onfocus={() => (isFocused = true)}

View File

@ -74,7 +74,7 @@
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg"
class="absolute top-0 h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
>
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}

View File

@ -1,9 +1,9 @@
<script lang="ts">
import { type PersonResponseDto } from '@immich/sdk';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { type PersonResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
interface Props {
person: PersonResponseDto;
@ -44,6 +44,6 @@
inputClass="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
bind:showLoadingSpinner={isSearchingPeople}
/>
<Button size="sm" type="submit">{$t('done')}</Button>
<Button size="small" shape="round" type="submit">{$t('done')}</Button>
</form>
</div>

View File

@ -1,9 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import {
notificationController,
NotificationType,
@ -13,6 +11,7 @@
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { updatePeople, type PersonResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
@ -126,11 +125,7 @@
<CircleIconButton title={$t('reset_people_visibility')} icon={mdiRestart} onclick={handleResetVisibility} />
<CircleIconButton title={toggleButton.label} icon={toggleButton.icon} onclick={handleToggleVisibility} />
</div>
{#if !showLoadingSpinner}
<Button onclick={handleSaveVisibility} size="sm" rounded="lg">{$t('done')}</Button>
{:else}
<LoadingSpinner />
{/if}
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
</div>
</div>

View File

@ -6,13 +6,13 @@
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
@ -108,10 +108,9 @@
<div></div>
{/snippet}
{#snippet trailing()}
<Button size="sm" disabled={!hasSelection} onclick={handleMerge}>
<Icon path={mdiMerge} size={18} />
<span class="ms-2">{$t('merge')}</span></Button
>
<Button leadingIcon={mdiMerge} size="small" shape="round" disabled={!hasSelection} onclick={handleMerge}>
{$t('merge')}
</Button>
{/snippet}
</ControlAppBar>
<section class="px-[70px] pt-[100px]">

View File

@ -1,5 +1,4 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import {
@ -9,14 +8,13 @@
type AssetFaceUpdateItem,
type PersonResponseDto,
} from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiMerge, mdiPlus } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte';
@ -130,33 +128,27 @@
{#snippet trailing()}
<div class="flex gap-4">
<Button
shape="round"
title={$t('create_new_person_hint')}
size="sm"
leadingIcon={mdiPlus}
loading={showLoadingSpinnerCreate}
size="small"
disabled={disableButtons || hasSelection}
onclick={handleCreate}
>
{#if !showLoadingSpinnerCreate}
<Icon path={mdiPlus} size={18} />
{:else}
<LoadingSpinner />
{/if}
<span class="ms-2"> {$t('create_new_person')}</span></Button
{$t('create_new_person')}</Button
>
<Button
size="sm"
size="small"
shape="round"
title={$t('reassing_hint')}
leadingIcon={mdiMerge}
loading={showLoadingSpinnerReassign}
disabled={disableButtons || !hasSelection}
onclick={handleReassign}
>
{#if !showLoadingSpinnerReassign}
<div>
<Icon path={mdiMerge} size={18} class="rotate-180" />
</div>
{:else}
<LoadingSpinner />
{/if}
<span class="ms-2"> {$t('reassign')}</span></Button
>
{$t('reassign')}
</Button>
</div>
{/snippet}
</ControlAppBar>

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