Compare commits

...

7 Commits

Author SHA1 Message Date
timonrieger fa4c912863 lint 2026-05-15 22:08:28 +02:00
timonrieger 801ae1b4ee fix: cleanup nestjs-zod properties 2026-05-15 20:59:36 +02:00
Santo Shakil 01d6a244d8 fix(mobile): cronet buffer overflow on compressed thumbnails (#28439)
CronetImageFetcher sized the response buffer from Content-Length, which is
the compressed wire size. Cronet auto-decompresses gzip/br responses and
writes decompressed bytes into the buffer, exceeding it and throwing
IllegalArgumentException: ByteBuffer is already full on the next read. Use
the growable path; Content-Length becomes an initial alloc hint only,
capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or
OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is
needed.
2026-05-15 14:48:23 -04:00
Ben Beckford 21d6755f39 fix(web): recently added ux (#28435) 2026-05-14 22:22:23 -05:00
Robert Deaton e91c017dd0 fix(server): dedupe database backup jobs (#28341)
* fix(server): dedupe database backup jobs via jobId

#27268 shows backup jobs piling up in the queue across upgrades; one pending
backup is always enough.

* fix(tests): Avoid stale backup files from previous test runs being erroneously returned from createBackup

* fix(jobs): Use bullmq's deduplication over jobId to avoid failed jobs from blocking future executions.

---------

Co-authored-by: Robert Deaton <immich@rdeaton.space>
2026-05-14 20:59:15 -04:00
Alex 43687cd8b4 fix: kebab menu icon colors and actions (#28433) 2026-05-14 22:23:50 +00:00
shenlong 06729ee5a5 chore: cleanup unused store keys (#28415)
cleanup unused store keys

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-14 16:21:06 -05:00
10 changed files with 83 additions and 62 deletions
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => {
let cookie: string | undefined;
@@ -13,6 +13,9 @@ describe('/admin/database-backups', () => {
admin = await utils.adminSetup({
onboarding: false,
});
});
beforeEach(async () => {
await utils.resetBackups(admin.accessToken);
});
+2
View File
@@ -568,6 +568,8 @@ export const utils = {
name: ManualJobName.BackupDatabase,
});
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1,
@@ -23,6 +23,8 @@ import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
@@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher {
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
@@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher {
}
try {
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
// may exceed the wire/compressed Content-Length. Always use the growable
// buffer path so we can't overflow.
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
buffer = NativeByteBuffer(initialSize)
request.read(buffer!!.wrapRemaining())
} catch (e: Exception) {
error = e
return request.cancel()
@@ -263,14 +265,18 @@ private class CronetImageFetcher : ImageFetcher {
byteBuffer: ByteBuffer
) {
try {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
val b = buffer!!
b.advance(byteBuffer.position())
// Reuse the caller-supplied ByteBuffer as long as we don't need to grow.
// It already points at our native memory with position advanced past the
// written bytes — Cronet can keep writing into the remaining tail.
// Only when the buffer is full do we grow (which may realloc + move the
// native pointer) and need a fresh wrap.
val buf = if (b.offset == b.capacity) {
b.ensureHeadroom()
b.wrapRemaining()
} else {
wrapped
byteBuffer
}
request.read(buf)
} catch (e: Exception) {
@@ -280,7 +286,6 @@ private class CronetImageFetcher : ImageFetcher {
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
@@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) {
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
@@ -46,17 +47,18 @@ class BaseActionButton extends ConsumerWidget {
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
final iconColor = this.iconColor;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
leadingIcon: Icon(iconData, color: iconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
);
}
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
+2 -2
View File
@@ -330,7 +330,7 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
@@ -346,7 +346,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true));
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
lastGroup = type.kebabMenuGroup;
}
-21
View File
@@ -11,9 +11,6 @@
"required": true,
"in": "query",
"description": "Album ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -25,9 +22,6 @@
"required": false,
"in": "query",
"description": "Asset ID (if activity is for an asset)",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -38,9 +32,6 @@
"name": "level",
"required": false,
"in": "query",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"$ref": "#/components/schemas/ReactionLevel"
}
@@ -49,9 +40,6 @@
"name": "type",
"required": false,
"in": "query",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
@@ -61,9 +49,6 @@
"required": false,
"in": "query",
"description": "Filter by user ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -187,9 +172,6 @@
"required": true,
"in": "query",
"description": "Album ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -201,9 +183,6 @@
"required": false,
"in": "query",
"description": "Asset ID (if activity is for an asset)",
"x-nestjs_zod-parent-metadata": {
"description": "Activity"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
+5 -7
View File
@@ -36,18 +36,16 @@ const ActivityStatisticsResponseSchema = z
})
.meta({ id: 'ActivityStatisticsResponseDto' });
const ActivitySchema = z
.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
})
.describe('Activity');
const ActivitySchema = z.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
});
const ActivitySearchSchema = ActivitySchema.extend({
type: ReactionTypeSchema.optional(),
level: ReactionLevelSchema.optional(),
userId: z.uuidv4().optional().describe('Filter by user ID'),
}).describe('Activity search');
});
const ActivityCreateSchema = ActivitySchema.extend({
type: ReactionTypeSchema,
+7 -4
View File
@@ -171,8 +171,8 @@ export class JobRepository {
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId deduplication
if (job.options?.jobId || job.options?.deduplication) {
// need to use add() instead of addBulk() for jobId/deduplication to take effect
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
@@ -230,10 +230,13 @@ export class JobRepository {
return { priority: 1 };
}
case JobName.FacialRecognitionQueueAll: {
return { jobId: JobName.FacialRecognitionQueueAll };
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
}
case JobName.VersionCheck: {
return { jobId: JobName.VersionCheck };
return { deduplication: { id: JobName.VersionCheck } };
}
case JobName.DatabaseBackup: {
return { deduplication: { id: JobName.DatabaseBackup } };
}
default: {
return null;
+33 -4
View File
@@ -4,15 +4,18 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props {
data: PageData;
@@ -40,6 +43,15 @@
}
}
};
const onViewAsset = async (id: string) => {
const asset = await getAssetInfo({ ...authManager.params, id });
assetViewerManager.setAsset(asset);
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
});
</script>
<OnEvents {onPersonThumbnailReady} />
@@ -122,15 +134,20 @@
draggable="false">{$t('view_all')}</a
>
</div>
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)}
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
<button
type="button"
class="relative h-full flex-auto"
onclick={() => onViewAsset(item.data.id)}
draggable="false"
>
<img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</a>
</button>
{/each}
</div>
</div>
@@ -140,3 +157,15 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if}
</UserPageLayout>
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
cursor={assetCursor}
showNavigation={false}
onClose={() => assetViewerManager.showAssetViewer(false)}
/>
</Portal>
{/await}
{/if}