Files
civitai__civitai/src/server/services/image.service.ts
T
Briant Diehl e7f371ee05 Merge pull request #4906 from civitai/feat/image-scanning-ingestion
feat(ingestion): scan images with imageScanning behind a Flipt flag
2026-09-17 11:06:44 -06:00

8009 lines
291 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Prisma } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import {
assertMediaPresentForPublish,
MediaPresence,
type MediaProbeAnswer,
summarizeProbeError,
} from '@civitai/shared';
import { randomUUID } from 'crypto';
import type { ManipulateType } from 'dayjs';
import dayjs from '~/shared/utils/dayjs';
import { chunk, isEqual, truncate, uniq, uniqBy } from 'lodash-es';
import { MeiliSearch, type SearchParams } from 'meilisearch';
import type { SessionUser } from '~/types/session';
import { v4 as uuid } from 'uuid';
import { isDev, isProd } from '~/env/other';
import { env } from '~/env/server';
import type { VotableTagModel } from '~/libs/tags';
import { clickhouse } from '~/server/clickhouse/client';
import { toClickhouseInt64 } from '~/server/clickhouse/int64';
import { feedRequestCapture } from '~/server/services/feed-request-capture.service';
import { DEEP_OFFSET, feedShadow } from '~/server/services/feed-shadow.service';
import {
feedFliptContext,
feedHydrateQuery,
feedPrimaryAvailable,
fetchFeedPrimary,
serveFromFeed,
} from '~/server/services/feed-primary.service';
import { purgeCache } from '~/server/cloudflare/client';
import {
CacheTTL,
constants,
METRICS_IMAGES_SEARCH_INDEX,
nsfwRestrictedBaseModels,
} from '~/server/common/constants';
import { imageReviewedSql } from '~/server/common/image-visibility';
import {
BlockedReason,
ImageSort,
NotificationCategory,
NsfwLevel,
SearchIndexUpdateQueueAction,
} from '~/server/common/enums';
import { getImageGenerationProcess } from '~/server/common/model-helpers';
import { dbRead, dbWrite } from '~/server/db/client';
import {
getDbWithoutLag,
getDbWithoutLagBatch,
preventReplicationLag,
} from '~/server/db/db-lag-helpers';
import { datapacketDbRead } from '~/server/db/datapacketDb';
import { pgDbRead, pgDbWrite } from '~/server/db/pgDb';
import {
dailyChallengeConfig,
parseJudgeScore,
type JudgeScore,
} from '~/server/games/daily-challenge/daily-challenge.utils';
import { poolCounters } from '~/server/games/new-order/utils';
import { logToAxiom, safeError } from '~/server/logging/client';
import { withSpan } from '~/server/utils/otel-helpers';
import { withTimeoutFallback } from '~/server/utils/timeout-helpers';
import {
FETCH_DOCUMENTS_TIMEOUT_MESSAGE,
MEILI_FETCH_FAILFAST_REASON_CIRCUIT_OPEN,
MeiliCallTimeoutError,
MeilisearchFetchError,
SEARCH_ACTOR_HEADER,
failfastReasonForStatus,
failfastReasonForTransientError,
fetchDocumentsAbortable,
getMetricsSearchClient,
isFailfastStatus,
isTransientMeiliError,
meiliFetchFailfastTotal,
metricsSearchClient,
withMeili,
wrapMeilisearchClientWithLimiter,
} from '~/server/meilisearch/client';
import { postMetrics } from '~/server/metrics';
import {
clickhouseFailSoftCounter,
imageScanSubmittedCounter,
leakingContentCounter,
registerCounter,
registerCounterWithLabels,
} from '~/server/prom/client';
import { getNewCreatorUserIds } from '~/server/services/new-creators.service';
import { imageOnSiteSql, isImageMetaOnSite } from '~/server/utils/image-onsite';
import { stripImageForInfiniteWire } from '~/server/utils/image-infinite-wire';
import { deriveUnmatchedResources } from '~/server/utils/unmatched-resources';
import {
getBaseModelFromResources,
getUserFollows,
imageMetaCache,
imageMetadataCache,
imageResourcesCache,
imageTagsCache,
tagCache,
tagIdsForImagesCache,
thumbnailCache,
} from '~/server/redis/caches';
import type { RedisKeyTemplateSys } from '~/server/redis/client';
import {
redis,
REDIS_KEYS,
REDIS_SYS_KEYS,
sysRedis,
withSysReadDeadline,
} from '~/server/redis/client';
import { logSysRedisFailOpen } from '~/server/redis/fail-open-log';
import { createCachedObject, queryCacheRaw } from '~/server/utils/cache-helpers';
import { createLruCache } from '~/server/utils/lru-cache';
import type { GetByIdInput } from '~/server/schema/base.schema';
import type { CollectionMetadataSchema } from '~/server/schema/collection.schema';
import type {
AddOrRemoveImageTechniquesOutput,
AddOrRemoveImageToolsOutput,
GetEntitiesCoverImage,
GetImageInput,
GetInfiniteImagesOutput,
GetMyImagesInput,
ImageEntityType,
ImageMetaProps,
ImageModerationBlockSchema,
ImageModerationSchema,
ImageModerationUnblockSchema,
ImageSchema,
ImageUploadProps,
IngestImageInput,
RemoveImageResourceSchema,
ReportCsamImagesInput,
SetVideoThumbnailInput,
ToggleImageFlagInput,
UpdateImageAcceptableMinorInput,
UpdateImageNsfwLevelOutput,
UpdateImageTechniqueOutput,
UpdateImageToolsOutput,
} from '~/server/schema/image.schema';
import { imageMetaOutput, ingestImageSchema } from '~/server/schema/image.schema';
import type { ImageMetadata, VideoMetadata } from '~/server/schema/media.schema';
import { imagesMetricsSearchIndex, imagesSearchIndex } from '~/server/search-index';
import type {
ImageMetricsSearchIndexRecord,
MetricsImageFilterableAttribute,
MetricsImageSortableAttribute,
} from '~/server/search-index/metrics-images.search-index';
import type { ContentDecorationCosmetic, WithClaimKey } from '~/server/selectors/cosmetic.selector';
import type { ImageResourceHelperModel } from '~/server/selectors/image.selector';
import { imageSelect } from '~/server/selectors/image.selector';
import type { ImageV2Model } from '~/server/selectors/imagev2.selector';
import { imageTagCompositeSelect, simpleTagSelect } from '~/server/selectors/tag.selector';
import {
getCollectionRandomSeed,
getUserCollectionPermissionsById,
getUserCollectionPermissionsByIds,
} from '~/server/services/collection.service';
import {
enqueueCollectionRebuild,
getCollectionIdsForImages,
} from '~/server/services/collection-media-index';
import { enforceBlockedBrowsingTags } from '~/server/services/blocked-browsing-tags.service';
import { getCosmeticsForEntity } from '~/server/services/cosmetic.service';
import {
getVisibleModel3DIdForPost,
getVisibleModel3DIds,
} from '~/server/services/model3d.service';
import { addImageToQueue } from '~/server/services/games/new-order.service';
import { upsertImageFlag } from '~/server/services/image-flag.service';
import {
deleteImagTagsForReviewByImageIds,
getImagTagsForReviewByImageIds,
} from '~/server/services/image-review.service';
import type { ImageModActivity } from '~/server/services/moderator.service';
import { trackModActivity } from '~/server/services/moderator.service';
import { createNotification } from '~/server/services/notification.service';
import {
queueComicsForPanelImages,
updateModel3DNsfwLevelForThumbnailImage,
} from '~/server/services/nsfwLevels.service';
import { clearAccountDeletionImageMarkers } from '~/server/services/account-deletion-image-markers';
import { bustCachesForPosts, updatePostNsfwLevel } from '~/server/services/post.service';
import { bulkSetReportStatus, resolveEntityAppeal } from '~/server/services/report.service';
import { upsertTagsOnImageNew } from '~/server/services/tagsOnImageNew.service';
import {
getBasicDataForUsers,
getCosmeticsForUsers,
getProfilePicturesForUsers,
} from '~/server/services/user.service';
import { bustFetchThroughCache, fetchThroughCache } from '~/server/utils/cache-helpers';
import { Limiter, limitConcurrency } from '~/server/utils/concurrency-helpers';
import {
isClickHouseConnectionError,
throwAuthorizationError,
throwBadRequestError,
throwDbError,
throwInternalServerError,
throwNotFoundError,
} from '~/server/utils/errorHandling';
import type { RuleDefinition } from '~/server/utils/mod-rules';
import { getCursor } from '~/server/utils/pagination-helpers';
import {
nsfwBrowsingLevelsArray,
nsfwBrowsingLevelsFlag,
onlySelectableLevels,
publicBrowsingLevelsFlag,
sfwBrowsingLevelsFlag,
} from '~/shared/constants/browsingLevel.constants';
import { Flags } from '~/shared/utils/flags';
import type {
CollectionItemRejectionReason,
DomainColor,
ModelType,
ReportReason,
ReviewReactions,
TagType,
} from '~/shared/utils/prisma/enums';
import {
Availability,
BlockImageReason,
CollectionItemStatus,
CollectionMode,
AppealStatus,
EntityType,
ImageIngestionStatus,
JobQueueType,
MediaType,
NewOrderRankType,
ReportStatus,
} from '~/shared/utils/prisma/enums';
import {
classifyImageScanFailure,
ImageScanFailureClass,
} from '~/server/services/image-scan-failure';
import { withRetries } from '~/utils/errorHandling';
import { fetchBlob } from '~/utils/file-utils';
import { getMetadata } from '~/utils/metadata';
import { removeEmpty } from '~/utils/object-helpers';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import {
serverUploadImage,
getB2ImageS3Client,
getImageUploadBackend,
headObject,
} from '~/utils/s3-utils';
import { resolveMediaLocation } from '~/server/services/storage-resolver';
import { isDefined, isNumber } from '~/utils/type-guards';
import { FLIPT_FEATURE_FLAGS, getFliptBoolean, isFlipt } from '../flipt/client';
import { ensureRegisterFeedImageExistenceCheckMetrics } from '../metrics/feed-image-existence-check.metrics';
import client from 'prom-client';
import { getExplainSql, queryWithTimeout } from '~/server/db/db-helpers';
import { ImagesFeed } from '../../../event-engine-common/feeds';
import { MetricService } from '../../../event-engine-common/services/metrics';
import { CacheService } from '../../../event-engine-common/services/cache';
import type { IMeilisearch } from '../../../event-engine-common/types/meilisearch-interface';
import type {
IClickhouseClient,
IDbClient,
IRedisClient,
} from '../../../event-engine-common/types/package-stubs';
import type { FeedQueryInput } from '../../../event-engine-common/feeds/types';
import type { ImageQueryInput } from '../../../event-engine-common/types/image-feed-types';
import {
createImageIngestionRequest,
imageIngestionLogName,
} from '~/server/services/orchestrator/orchestrator.service';
import { getGenerationDisplayKeys } from '~/server/services/orchestrator/legacy-metadata-mapper';
import {
sanitizeProvenance,
storedSourceImageIds,
} from '~/server/services/orchestrator/remix-provenance';
import { probeCreatedImageMedia } from '~/server/utils/created-image-media-probe';
const {
cacheHitRequestsTotal,
ffRequestsTotal,
requestDurationSeconds,
requestTotal,
droppedIdsTotal,
postFilterIterations,
postFilterDocsProcessed,
postFilterFilterRatio,
} = ensureRegisterFeedImageExistenceCheckMetrics(client.register);
// no user should have to see images on the site that haven't been scanned or are queued for removal
/**
* How much of an image's cached variants the invalidation may remove.
*
* - `all` — every cached variant. For a DELETE, where the image must stop serving entirely.
* - `hidden-meta-orphans` — only the variants produced BEFORE a `hideMeta` flip.
*
* The second scope exists because a `hideMeta` false→true flip re-keys the image: the cache key
* includes the hideMeta flag, so after the flip every request derives a fresh, metadata-stripped
* variant under a NEW key and the pre-flip ones become orphans. The image is still LIVE, so
* removing the whole set would evict variants the page is currently serving; removing only the
* orphans clears the metadata-bearing copies without touching anything in use.
*/
export type PurgeResizeCacheScope = 'all' | 'hidden-meta-orphans';
/**
* 🔴 Ask the image-cache service to destroy the SHARED STORED OBJECT too, not just this image's
* derived variants. Off unless a caller says otherwise, and only a moderation takedown may say so.
*
* The stored object is content-addressed, so it is shared by every byte-identical image, of every
* owner. Destroying it removes the full-resolution original for all of them at once — a
* cross-account destructive act, not a cache invalidation, and not reversible from here.
*
* 🔴 KNOWN AND ACCEPTED COLLATERAL. The other images keep their database rows and will serve a
* broken original. The app cannot enumerate them: it does not store the content-hash key per
* image, and the perceptual hash it does store is a similarity signal, NOT a byte-identity key,
* so it cannot stand in for one. Accepted because a byte-identical copy of content that must not
* exist is the same content — the point of a takedown is that no copy survives. The
* `image-blob-retraction-requested` log line below is the only attribution trail there is.
*/
export type PurgeResizeCacheRetraction = { retractPublicBlobs?: boolean };
export async function purgeResizeCache({
url,
scope = 'all',
retractPublicBlobs = false,
}: {
url: string;
scope?: PurgeResizeCacheScope;
} & PurgeResizeCacheRetraction) {
// Invalidate the resized/converted variants for this image. Cache
// invalidation only — a stale variant is self-healing (re-derived on next
// request) and must never fail the caller's mutation.
//
// NOTE: this used to also do a direct S3 listObjects+deleteManyObjects against
// env.S3_IMAGE_CACHE_BUCKET ("civitai-media-cache") via the legacy image S3 client.
// That path was DEAD in prod and has been removed: the cache bucket now lives
// on Backblaze B2 (us-west-004) and is owned by the image-cacher service,
// while that client pointed at the DigitalOcean-Spaces object-read proxy
// (S3_IMAGE_UPLOAD_ENDPOINT). That proxy does not implement ListObjectsV2 — a
// path-style list request returns a plain-text "404 page not found", which the
// AWS SDK's XML error deserializer chokes on (`char '4' is not expected.:1:1`).
// So the listObjects call ALWAYS threw before deleting anything (fire ~9/min,
// fail-soft since #2600) — pure noise + a wasted round-trip on every image
// delete / hideMeta toggle. Invalidation is fully handled by the image-cacher
// /admin/invalidate call below (L2 Redis SCAN+DEL by prefix + Cloudflare tag
// purge), which is the modern owner of the civitai-media-cache bucket.
// Best-effort: tell image-cacher to invalidate its caches for this UUID.
// If this fails (network, image-cacher down, etc.) we accept up to 4d of
// stale L2 entries — no worse than today's behavior. Never block or throw
// the delete flow.
if (env.IMAGE_CACHER_URL && url) {
// `keep=hm` asks the service to retain the post-flip (hideMeta) variants. Omitted entirely for
// the `all` scope so this call is byte-identical to the one that has always been sent.
// Exhaustive on purpose. A ternary here fails OPEN: any value that is not the exact literal
// degrades to the WIDEST blast radius, so adding a third scope later would silently mean
// "delete everything" instead of failing to compile. The counterpart service rejects an
// unrecognised value outright; this is the client-side half of the same stance.
const keepParam = ((): string => {
switch (scope) {
case 'all':
return '';
case 'hidden-meta-orphans':
return '&keep=hm';
default: {
const unreachable: never = scope;
throw new Error(`unhandled purgeResizeCache scope: ${String(unreachable)}`);
}
}
})();
// Retraction is only coherent for a FULL purge. A narrower scope means the image is still
// live — only the pre-flip variants are being cleared — so destroying the shared object there
// would take down an image nobody asked to remove, plus every byte-identical copy of it.
// The combination degrades to NO retraction rather than to the wider blast radius, and says
// so, because it can only mean a caller is confused about what it is asking for.
const retract = retractPublicBlobs === true && scope === 'all';
if (retractPublicBlobs === true && scope !== 'all') {
logToAxiom({
type: 'warning',
name: 'image-blob-retraction-refused',
message: 'blob retraction was requested with a partial scope; not retracting',
imageKey: url,
scope,
}).catch(() => {
// swallow — best effort logging
});
}
// The exact literal the service gates on; it fails closed on anything else.
const retractParam = retract ? '&retractPublicBlobs=true' : '';
const query = `imageKey=${encodeURIComponent(url)}${keepParam}${retractParam}`;
// Sent whenever the secret is configured, rather than only on a retracting call, so THIS
// caller does not have to be redeployed in step with a change on the service side.
//
// 🔴 That is a statement about this caller, not about the endpoint's contract, and it does not
// generalise: `apps/moderator/src/lib/server/image-deletion.ts` open-codes its own POST to the
// same endpoint with no header, no response check and no retraction parameter, so it
// invalidates variants only and is unaffected by any of this. What the service does with a
// missing header is not observable from this repo — the `!res.ok` branch below is what makes a
// rejection visible here, and it is the only thing that would.
const headers: Record<string, string> = {};
if (env.IMAGE_CACHER_ADMIN_SECRET) {
headers['X-Admin-Secret'] = env.IMAGE_CACHER_ADMIN_SECRET;
}
fetch(`${env.IMAGE_CACHER_URL}/admin/invalidate?${query}`, {
method: 'POST',
headers,
// Never follow a redirect while carrying the shared secret. `fetch` strips Authorization and
// Cookie on a cross-origin hop but forwards CUSTOM headers verbatim, so a 30x from this
// endpoint would hand X-Admin-Secret to wherever it pointed. It only ever answers
// 202/400/401, so a redirect here is already anomalous — fail instead of chasing it.
redirect: 'error',
// Invalidation must not slow down the delete flow.
signal: AbortSignal.timeout(2000),
})
.then((res) => {
// 🔴 `fetch` DOES NOT REJECT ON A NON-2xx. Without this branch a 401 (missing/most likely
// stale shared secret), a 409 refusal or a 503 partial failure all land in the success
// path and vanish — so invalidation could stop working COMPLETELY and produce not one log
// line. That is the failure mode this check exists for: whatever the service decides to do
// with a rejected call, this branch is the only place it becomes visible on this side, and
// the first symptom of a stale shared secret would otherwise be stale images with no
// signal anywhere.
if (!res.ok) {
return logToAxiom({
type: 'warning',
name: 'image-cacher-invalidate',
message: 'image-cacher invalidate returned a non-success status',
imageKey: url,
scope,
status: res.status,
}).catch(() => {
// swallow — best effort logging
});
}
})
.catch((err) => {
logToAxiom({
type: 'warning',
name: 'image-cacher-invalidate',
message: 'image-cacher invalidate failed',
imageKey: url,
scope,
error: safeError(err),
}).catch(() => {
// swallow — best effort logging
});
});
}
}
export async function deleteImageFromS3({
id,
url,
// Off unless a moderation flow says otherwise. See `PurgeResizeCacheRetraction` for what it
// destroys and for the collateral that is knowingly accepted along with it.
retractPublicBlobs = false,
}: { id: number; url: string } & PurgeResizeCacheRetraction) {
if (!env.DATABASE_IS_PROD) return;
// Legacy avatar rows hold a full external URL where every other row holds a bucket key.
// Handing one to deleteObject as a Key can only fail, and it is not ours to delete anyway.
if (!url || url.startsWith('http')) {
// Skipping is safe because no row stores a url for a bucket we own — a property of the data,
// not of this code. A first-party url arriving here means that changed, and the skip would
// then be dropping a real delete instead of declining someone else's.
if (/^https?:\/\/[^/]*\bcivitai\.com/i.test(url))
await logToAxiom({
type: 'warning',
name: 'delete-image-from-s3-skipped-first-party-url',
message: 'Image.url holds a first-party url, not a bucket key; nothing was deleted',
imageId: id,
url,
}).catch(() => undefined);
return;
}
try {
const otherImagesWithSameUrl = await dbWrite.image.findFirst({
select: { id: true },
where: {
url: url,
id: { not: id },
},
});
if (!!otherImagesWithSameUrl) return;
// B2 is the only backend an image can be on, so the registry is consulted for observability
// rather than to choose a destination — a miss means "unregistered", never "somewhere else".
// This used to branch to a second backend on a miss, and that branch could not succeed, so
// every miss left the object behind a row that was already deleted.
//
// The `.catch` is belt and braces with the `await` fix inside resolveMediaLocation: no failure
// of the lookup may stop the delete, and inlining the guard keeps that true even if the
// resolver later grows a throwing path again.
//
// The reason is sanitised INSIDE that `.catch`, under its own try, which is what keeps it total.
// `safeError` ends in `String(e)` for a non-Error, and that throws on a value with no primitive
// conversion — `Object.create(null)`, or anything with a throwing `toString`. Calling it in the
// log payload below instead would put that throw inside the OUTER try, skipping the B2 delete:
// the exact failure this `.catch` exists to prevent, reintroduced by the code reporting it.
let resolverError: MixedObject | undefined;
const location = await resolveMediaLocation(url).catch((error: unknown) => {
try {
resolverError = safeError(error);
} catch {
resolverError = { message: 'resolver rejected with a value that could not be serialised' };
}
return null;
});
if (!location) {
// Not a failure — the delete proceeds against B2 below. But an unregistered image is a
// registry gap (or a storage-resolver outage), and silently treating it as B2 is exactly
// what made the gap invisible. Rate of this line is the health signal, so it carries the
// reason too: routing a rejection here instead of to the catch below would otherwise discard
// the only record of WHY the resolver failed.
await logToAxiom({
type: 'warning',
name: 'delete-image-from-s3-unresolved-location',
message: 'storage-resolver returned no location; deleting from B2 anyway',
imageId: id,
url,
...(resolverError !== undefined && { error: resolverError }),
}).catch(() => undefined);
}
const b2Client = getB2ImageS3Client();
await withRetries(() =>
b2Client.send(
new DeleteObjectCommand({
Bucket: env.S3_IMAGE_B2_BUCKET ?? 'civitai-media-uploads',
Key: url,
})
)
);
} catch (error) {
// Nothing retries this: deleteImages drops the DB row first, so a lost object stays
// publicly reachable (CDN urls are unsigned) with only this line to find it by.
await logToAxiom({
type: 'error',
name: 'delete-image-from-s3-failed',
message: 'S3 delete failed; the object may still be public',
imageId: id,
url,
error: safeError(error),
}).catch(() => undefined);
}
// 🔴 The only attribution trail. Retraction destroys the shared stored object for every
// byte-identical image of every owner, and the app cannot enumerate those rows — it stores no
// content-hash key, and the perceptual hash it does store is not a byte-identity key. Their
// rows survive and will serve a broken original. Emitted before the request rather than after,
// so a request that is made but never confirmed still leaves a record.
if (retractPublicBlobs) {
await logToAxiom({
type: 'warning',
name: 'image-blob-retraction-requested',
message: 'moderation requested retraction of the shared stored object for this image',
imageId: id,
url,
}).catch(() => undefined);
}
// Outside the try: a failed object delete is exactly when invalidation matters, because the
// bytes are still in the bucket and a live cache entry keeps serving content whose row is
// already gone. The `otherImagesWithSameUrl` return above still skips this — that url belongs
// to an image that is still live, so its bytes are not ours to retract either.
await purgeResizeCache({ url: url, retractPublicBlobs });
}
export const invalidateManyImageExistence = async (ids: number[]) => {
// Set keys individually to avoid CROSSSLOT errors
await Promise.all(
ids.map((id) =>
sysRedis.packed.set(
`${REDIS_SYS_KEYS.CACHES.IMAGE_EXISTS}:${id}` as RedisKeyTemplateSys,
'false',
{ EX: 60 * 5 }
)
)
);
};
async function getImageTagsForImages(
imageIds: number[]
): Promise<(VotableTagModel & { imageId: number })[]> {
const tagsByImage = await imageTagsCache.fetch(imageIds);
return imageIds.flatMap(
(imageId) =>
tagsByImage[imageId]?.tags.map(({ tagId, tagName, tagType, tagNsfwLevel, ...tag }) => ({
...tag,
imageId,
id: tagId,
type: tagType,
nsfwLevel: tagNsfwLevel as NsfwLevel,
name: tagName,
})) ?? []
);
}
/**
* Associates already-fetched tags to their images in O(N + M).
*
* `getImageTagsForImages` returns the tags for EVERY image in the batch, so a
* per-image `tags.filter(x => x.imageId === i.id)` rescans the whole array once
* per image — O(N x M), and M grows with N. CPU profiles of the production API
* showed that construct dominating multi-second event-loop stalls.
*
* 🔴 The empty case must stay `[]`, NOT `undefined`. `.filter()` returned `[]`
* for an image with no tags and `Map.get()` returns `undefined`; those are
* different values in the API response, and images with no tags are common.
* That is what the `?? []` is for — do not "simplify" it away.
*/
export function attachTagsToImages<TImage extends { id: number }, TTag extends { imageId: number }>(
images: TImage[],
tags: TTag[] | undefined
): (TImage & { tags: TTag[] })[] {
const tagsByImageId = tags?.reduce((acc, tag) => {
const arr = acc.get(tag.imageId);
if (arr) arr.push(tag);
else acc.set(tag.imageId, [tag]);
return acc;
}, new Map<number, TTag[]>());
return images.map((i) => ({ ...i, tags: tagsByImageId?.get(i.id) ?? [] }));
}
export const deleteImageById = async ({
id,
updatePost,
}: GetByIdInput & { updatePost?: boolean }) => {
updatePost ??= true;
try {
// Resolved BEFORE the delete: `CollectionItem.imageId` is `onDelete: Cascade`, so
// once the row is gone there is no way left to learn which collections were
// showing this image. Their documents denormalize it and the collections index
// only sweeps newly created collections, so without this they keep rendering a
// thumbnail for an image that no longer exists. The resolver is non-throwing, so
// a failure here costs the reindex rather than cancelling the delete.
const collectionsToRebuild = await getCollectionIdsForImages({
imageIds: [id],
source: 'image-delete',
});
const image = await dbWrite.image.delete({
where: { id },
select: { url: true, postId: true, nsfwLevel: true, userId: true },
});
if (!image) return;
const invalidateExistence = invalidateManyImageExistence([id]);
await Promise.all([
deleteImageFromS3({ id, url: image.url }),
queueImageSearchIndexUpdate({
ids: [id],
action: SearchIndexUpdateQueueAction.Delete,
}),
...(updatePost && image.postId
? [
updatePostNsfwLevel(image.postId),
bustCachesForPosts(image.postId),
postMetrics.queueUpdate(image.postId),
]
: []),
invalidateExistence,
imageMetaCache.refresh(id),
imageMetadataCache.refresh(id),
enqueueCollectionRebuild({ ...collectionsToRebuild, source: 'image-delete' }),
]);
return image;
} catch (error) {
// The row may already be gone from the DB while cleanup (search-index delete,
// cache busts, S3) failed — swallowing that silently leaves the image visible
// in search/feeds forever, which then 500s anything that FKs to it (reports).
await logToAxiom({
type: 'error',
name: 'delete-image-cleanup-failed',
message: 'deleteImageById failed; image may remain indexed',
imageId: id,
error: safeError(error),
}).catch(() => undefined);
}
};
/**
* Queue an image that has been REPLACED (not deleted) for destruction later, instead of
* destroying it inline.
*
* A replacement is not a deletion: nobody asked for the old picture to stop existing, they
* asked for a new one to start being used. Destroying the old row + stored object at that
* moment turns every reference still holding its url into a 404 — and several such caches
* are legitimate and long-lived (the image CDN's redirect is `max-age=86400`, the
* account-switcher roster in localStorage is durable by design, feeds and embeds hold
* rendered urls). A stale-but-present avatar is invisible to a user; a deleted one is a
* broken image. Deferring the reap makes the whole class self-correcting rather than
* permanently broken until every cache is individually fixed.
*
* The queue row is the clock: `remove-replaced-images` measures the retention window from
* its `createdAt`, exactly as `remove-blocked-images` does for `BlockedImageDelete`.
*
* `DO UPDATE` rather than `DO NOTHING` (which is what `enqueueJobs` uses) is load-bearing.
* A user can re-select a previously-replaced image, so the same id can be queued twice; on
* conflict the row must RESTART its window, not inherit the first replacement's clock, or
* the second replacement gets no retention at all.
*
* Errors are swallowed and logged, like `deleteImageById` above. The one caller runs inside
* the `Promise.all` that decides whether a profile save reports success, and a save that has
* already committed must not surface as "error updating your profile" because a cleanup
* enqueue failed. The failure direction is also the safe one: the image stays fetchable and
* is not reaped, so the worst case is one leaked object with a log line naming it — never a
* broken avatar.
*/
export async function queueReplacedImageDeletion(ids: number[]) {
// Deduped and chunked like `enqueueJobs`, whose statement shape this otherwise copies. The
// dedupe is not hygiene: `DO UPDATE` cannot touch the same row twice in one statement, so a
// repeated id in a single call would fail the whole insert.
const batches = chunk(uniq(ids), 500);
for (const batch of batches) {
try {
await dbWrite.$executeRaw`
INSERT INTO "JobQueue" ("entityId", "entityType", "type")
VALUES ${Prisma.join(
batch.map(
(entityId) =>
Prisma.sql`(${entityId}::integer, ${EntityType.Image}::"EntityType", ${JobQueueType.ReplacedImageDelete}::"JobQueueType")`
)
)}
ON CONFLICT ("entityType", "entityId", "type")
DO UPDATE SET "createdAt" = NOW()
`;
} catch (error) {
await logToAxiom({
type: 'error',
name: 'queue-replaced-image-deletion-failed',
message: 'replaced image was not queued for deletion; it will not be reaped',
imageIds: batch,
error: safeError(error),
}).catch(() => undefined);
}
}
}
/**
* 🔴 A NAMED options object, deliberately not a third positional boolean beside `updatePosts`.
* The thing it turns on is cross-account and irreversible, so it must be impossible to reach by
* getting an argument position wrong. Default is off, stated explicitly at every layer rather
* than left to `undefined` being falsy.
*/
export async function deleteImages(
ids: number[],
updatePosts = true,
{ retractPublicBlobs = false }: PurgeResizeCacheRetraction = {}
) {
const images = await Limiter({ batchSize: 100 }).process(ids, async (ids, batchIndex) => {
// Resolved before the DELETE for the same reason as deleteImageById: the
// membership rows cascade away with the images, taking with them the only record
// of which collection documents now hold a dead thumbnail.
const collectionsToRebuild = await getCollectionIdsForImages({
imageIds: ids,
source: 'image-delete-bulk',
});
const results = await dbWrite.$queryRaw<
{ id: number; url: string; postId: number | null; nsfwLevel: number; userId: number }[]
>`
DELETE FROM "Image"
WHERE id IN (${Prisma.join(ids)})
RETURNING id, url, "postId", "nsfwLevel", "userId"
`;
const imageIds = results.map((x) => x.id);
const idsForPostUpdate = updatePosts ? results.map((x) => x.postId).filter(isDefined) : [];
const invalidateExistence = invalidateManyImageExistence(imageIds);
await Promise.all([
queueImageSearchIndexUpdate({
ids: imageIds,
action: SearchIndexUpdateQueueAction.Delete,
}),
updatePostNsfwLevel(idsForPostUpdate),
bustCachesForPosts(idsForPostUpdate),
postMetrics.queueUpdate(idsForPostUpdate),
invalidateExistence,
imageMetaCache.refresh(imageIds),
imageMetadataCache.refresh(imageIds),
enqueueCollectionRebuild({ ...collectionsToRebuild, source: 'image-delete-bulk' }),
]);
await Limiter({ batchSize: 5 }).process(
results,
async (results) =>
await Promise.all(
results.map(({ id, url }) => deleteImageFromS3({ id, url, retractPublicBlobs }))
)
);
if (isDev) console.log(`Batch ${batchIndex}: Deleted ${results.length} images`);
return results;
});
return images;
}
function getReviewTypeToBlockedReason(reason: string) {
switch (reason) {
case 'csam':
return BlockImageReason.CSAM;
case 'newUser':
return BlockImageReason.Ownership;
case 'minor':
case 'poi':
case 'reported':
case 'blocked':
case 'tag':
case 'appeal':
case 'modRule':
default:
return BlockImageReason.TOS;
}
}
/** Mark remix-source images as mod-reviewed so the audit job won't re-flag them. */
async function markRemixSourceReviewed(images: { id: number; needsReview: string | null }[]) {
const remixSourceIds = images
.filter((img) => img.needsReview === 'remixSource')
.map((img) => img.id);
if (remixSourceIds.length === 0) return;
await dbWrite.$executeRaw`
UPDATE "Image"
SET "metadata" = "metadata" || '{"remixSourceReviewed": true}'::jsonb
WHERE id IN (${Prisma.join(remixSourceIds)})
`;
}
export async function handleUnblockImages({
ids: imageIds,
moderatorId,
removeMinorFlag,
}: ImageModerationUnblockSchema) {
const images = await dbRead.image.findMany({
where: { id: { in: imageIds } },
select: {
id: true,
userId: true,
pHash: true,
postId: true,
nsfwLevel: true,
blockedFor: true,
needsReview: true,
},
});
await Limiter().process(images, async (images) => {
const ids = images.map((x) => x.id);
const grouped = images.reduce<Record<string, number[]>>((acc, image) => {
if (!image.needsReview) image.needsReview = 'null';
if (!acc[image.needsReview]) acc[image.needsReview] = [];
acc[image.needsReview].push(image.id);
return acc;
}, {});
const imageTagsForReview = await getImagTagsForReviewByImageIds(ids);
await Promise.all([
...Object.entries(grouped).map(
([needsReview, groupedIds]) => dbWrite.$queryRaw`
UPDATE "Image" SET
"needsReview" = NULL,
"blockedFor" = NULL,
"metadata" = "metadata" - 'ruleId' - 'ruleReason', -- Remove ruleId and ruleReason from metadata
${needsReview === 'poi' ? Prisma.sql`"poi" = false,` : Prisma.sql``}
${
needsReview === 'minor'
? removeMinorFlag
? Prisma.sql`"minor" = FALSE,`
: Prisma.sql`"minor" = CASE WHEN "nsfwLevel" >= 4 THEN FALSE ELSE TRUE END,`
: Prisma.sql``
}
${
['minor', 'poi', 'newUser', 'bestiality'].includes(needsReview)
? Prisma.sql`"scannedAt" = NOW(),`
: Prisma.sql``
}
"ingestion" = 'Scanned'
WHERE id IN (${Prisma.join(groupedIds)});
`
),
upsertTagsOnImageNew(
imageTagsForReview.map(({ imageId, tagId }) => ({
imageId,
tagId,
disabled: true,
needsReview: false,
}))
),
]);
const postIds = uniq(images.map(({ postId }) => postId).filter(isDefined));
await Promise.all([
resetBlockedNsfwLevel(ids),
dropBlockedImageDeleteQueue(ids),
queueImageSearchIndexUpdate({ ids, action: SearchIndexUpdateQueueAction.Update }),
deleteImagTagsForReviewByImageIds(ids),
bulkRemoveBlockedImages(images.map(({ pHash }) => pHash).filter(isDefined)),
// Comic projects are gated on `Image.needsReview`/`ingestion`/
// `tosViolation`. Unblock flips those, but `processImageScanWorkflow`
// (where the standard re-queue lives) isn't on this code path —
// re-queue here so the search index re-evaluates visibility.
queueComicsForPanelImages(ids),
]);
// Bust after the writes above land so a concurrent reader can't refill the cache from pre-update rows.
if (postIds.length) await bustCachesForPosts(postIds);
if (moderatorId) {
await trackModActivity(moderatorId, {
entityType: 'image',
entityId: ids,
activity: 'review',
});
}
});
// Resolve any pending appeals for images that were in appeal review
const appealImageIds = images.filter((img) => img.needsReview === 'appeal').map((img) => img.id);
if (appealImageIds.length > 0) {
await resolveEntityAppeal({
ids: appealImageIds,
entityType: EntityType.Image,
status: AppealStatus.Approved,
userId: moderatorId,
});
}
// Prevent remix-source audit job from re-flagging accepted images
await markRemixSourceReviewed(images);
return images;
}
export async function handleBlockImages({
ids: imageIds,
userId,
include,
moderatorId,
}: ImageModerationBlockSchema) {
if (!imageIds?.length && !userId) throw new Error('one of "ids" or "userId" is required');
const images = await dbRead.image.findMany({
where: { id: imageIds ? { in: imageIds } : undefined, userId },
select: {
id: true,
userId: true,
pHash: true,
postId: true,
nsfwLevel: true,
blockedFor: true,
needsReview: true,
},
});
await Limiter({ batchSize: 100, limit: 10 }).process(images, async (images) => {
const ids = images.map((x) => x.id);
const postIds = uniq(images.map(({ postId }) => postId).filter(isDefined));
const invalidateExistence = invalidateManyImageExistence(ids);
await Promise.all([
dbWrite.image.updateMany({
where: { id: { in: ids } },
data: {
needsReview: null,
ingestion: 'Blocked',
nsfwLevel: NsfwLevel.Blocked,
blockedFor: BlockedReason.Moderated,
updatedAt: new Date(),
},
}),
queueImageSearchIndexUpdate({ ids, action: SearchIndexUpdateQueueAction.Delete }),
invalidateExistence,
// Same reason as `handleUnblockImages` — moderator block bypasses
// `processImageScanWorkflow`, so we queue the parent comic project
// here directly. Without this the comic stays indexed under its
// pre-block (visible) state.
queueComicsForPanelImages(ids),
]);
// A moderator block outranks an account-deletion grace block: left on, a later account restore
// would read the grace breadcrumbs and un-block what was just moderated. See
// `clearAccountDeletionImageMarkers`.
//
// Sequenced AFTER the batch above rather than joining it: both statements UPDATE the same rows
// by id, and two concurrent row-lock acquisitions over one id set are how you buy a deadlock
// for nothing. Nothing here depends on the order, so it costs a round trip and no risk.
await clearAccountDeletionImageMarkers({ ids });
// Bust after the block write commits so a concurrent reader can't refill with the pre-block state.
if (postIds.length) await bustCachesForPosts(postIds);
if (include?.includes('phash-block')) {
await bulkAddBlockedImages({
data: images
.map(({ pHash, blockedFor }) => {
if (!pHash) return null;
return {
hash: pHash,
reason: getReviewTypeToBlockedReason(blockedFor ?? BlockedReason.Moderated),
};
})
.filter(isDefined),
});
}
if (include?.includes('user-notification')) {
await Promise.all(
images.map((img) =>
createNotification({
userId: img.userId,
type: 'tos-violation',
category: NotificationCategory.System,
key: `tos-violation:image:${uuid()}`,
details: {
modelName: img.postId ? `post #${img.postId}` : 'a post',
entity: 'image',
url: `/images/${img.id ?? ''}`,
},
}).catch()
)
);
}
if (moderatorId && !!imageIds?.length) {
await trackModActivity(moderatorId, {
entityType: 'image',
entityId: ids,
activity: 'review',
});
}
});
if (moderatorId && userId) {
await trackModActivity(moderatorId, {
entityType: 'user',
entityId: userId,
activity: 'removeContent',
});
}
// Prevent remix-source audit job from re-flagging blocked images
await markRemixSourceReviewed(images);
return images;
}
// NOTE: the moderator `image.moderate` verdict (block/unblock from the review queue + inline badges) now
// lives in the moderator spoke app (apps/moderator); the main app delegates via callModAction. The
// handleBlockImages/handleUnblockImages primitives below remain only for the not-yet-migrated consumers
// that call them directly (the Knights-of-New-Order game, the /api/mod/{remove,restore}-images automation
// endpoints). Their `include: ['phash-block' | 'user-notification']` branches are dead until those callers
// migrate — do not add new callers.
export async function updateNsfwLevel(ids: number | number[]) {
if (!Array.isArray(ids)) ids = [ids];
ids = [...new Set(ids)]; // dedupe
if (!ids.length) return;
await dbWrite.$executeRawUnsafe(
`SELECT update_nsfw_levels_new(ARRAY[${ids.join(',')}]::integer[])`
);
await thumbnailCache.refresh(ids);
}
// Single source of truth for restoring an image's rating after it's unblocked.
// Blocking force-sets nsfwLevel=Blocked and may leave the rating lock on; the recompute
// (update_nsfw_levels_new) skips locked rows, so a Blocked-locked row can never be restored
// by updateNsfwLevel alone. Reset+unlock only the Blocked rows (never-corrupted locks are
// preserved), then recompute — untagged rows fall to Unrated and re-derive on rescan.
// Used by both unblock paths (handleUnblockImages and report.service resolveEntityAppeal).
export async function resetBlockedNsfwLevel(ids: number | number[]) {
if (!Array.isArray(ids)) ids = [ids];
ids = [...new Set(ids)];
if (!ids.length) return;
await dbWrite.$executeRaw`
UPDATE "Image"
SET "nsfwLevel" = 0, "nsfwLevelLocked" = FALSE
WHERE id IN (${Prisma.join(ids)}) AND "nsfwLevel" = ${NsfwLevel.Blocked};
`;
await updateNsfwLevel(ids);
}
/**
* Clears a pending blocked-image purge. `create_job_queue_record` is ON CONFLICT DO NOTHING and
* `remove-blocked-images` counts its retention window from the queue row's `createdAt`, so a row
* left behind by an unblock makes a later re-block inherit the *original* block's clock — the
* image can then be deleted with no retention at all. Call this from anything that takes an image
* out of `Blocked` or clears its `blockedFor`.
*/
export async function dropBlockedImageDeleteQueue(ids: number[]) {
if (!ids.length) return;
// ANY over an array rather than IN over a join: callers pass unbounded id lists, and one
// bind parameter can't hit Postgres' 65535 parameter ceiling.
await dbWrite.$executeRaw`
DELETE FROM "JobQueue"
WHERE type = ${JobQueueType.BlockedImageDelete}::"JobQueueType"
AND "entityType" = ${EntityType.Image}::"EntityType"
AND "entityId" = ANY(${ids})
`;
}
export const updateImageReportStatusByReason = ({
id,
reason,
status,
}: {
id: number;
reason: ReportReason;
status: ReportStatus;
}) => {
return dbWrite.$queryRaw<{ id: number; userId: number }[]>`
UPDATE "Report" r SET status = ${status}::"ReportStatus"
FROM "ImageReport" i
WHERE i."reportId" = r.id
AND i."imageId" = ${id}
AND r.reason = ${reason}::"ReportReason"
RETURNING id, "userId"
`;
};
export const getImageDetail = async ({ id }: GetByIdInput) => {
const [resourcesData, tagsData] = await Promise.all([
imageResourcesCache.fetch([id]),
imageTagsCache.fetch([id]),
]);
const resources = (resourcesData[id]?.resources ?? []).map((r) => ({
id: r.modelVersionId, // Use modelVersionId as identifier (ImageResourceNew has no id column)
modelVersion: { id: r.modelVersionId, name: r.versionName },
detected: r.detected,
}));
const tags = (tagsData[id]?.tags ?? []).map((t) => ({
automated: t.automated,
tag: {
id: t.tagId,
name: t.tagName,
isCategory: false, // ImageTag doesn't have isCategory, default to false
},
}));
return { resources, tags };
};
export const getImageById = async ({ id }: GetByIdInput) => {
return await dbRead.image.findUnique({
where: { id },
});
};
export const ingestImageById = async ({ id }: GetByIdInput) => {
const images = await dbWrite.$queryRaw<IngestImageInput[]>`
SELECT id, url, type, width, height, meta->>'prompt' as prompt
FROM "Image"
WHERE id = ${id}
`;
if (!images?.length) throw new TRPCError({ code: 'NOT_FOUND' });
const results = await dbWrite.$queryRaw<{ imageId: number; tagId: number }[]>`
SELECT "imageId", "tagId"
FROM "TagsOnImageDetails"
WHERE "imageId" = ${images[0].id} AND NOT "disabled";
`;
await upsertTagsOnImageNew(
results.map(({ imageId, tagId }) => ({ imageId, tagId, disabled: false }))
);
return await ingestImage({ image: images[0] });
};
function extractSubmitErrorMessage(error: unknown): string | null {
if (!error) return null;
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
if (typeof error === 'object') {
const { errors, title } = error as { errors?: { messages?: unknown }; title?: unknown };
const messages = errors?.messages;
if (Array.isArray(messages) && messages.length) return messages.join('; ');
if (typeof title === 'string') return title;
}
return null;
}
// Permanent terminalizes on the first attempt, so only codes that describe this input
// belong here. A systemic code (401/403/404, or 413 when our own body is the thing that
// grew) would terminalize every image submitted during the outage.
const PERMANENT_SUBMIT_STATUSES = [400, 415, 422];
async function markImageScanSubmitFailure({
dbClient,
imageId,
status,
error,
}: {
dbClient: { $executeRaw: typeof dbWrite.$executeRaw };
imageId: number;
status?: number;
error: unknown;
}) {
const reason = extractSubmitErrorMessage(error);
const failureClass =
!!status && PERMANENT_SUBMIT_STATUSES.includes(status)
? ImageScanFailureClass.Permanent
: classifyImageScanFailure({ reason, failureType: 'send-fail' });
const isPermanent = failureClass === ImageScanFailureClass.Permanent;
const errorJson = JSON.stringify({
failureType: 'send-fail',
responseStatus: status,
reason: reason ?? undefined,
failureClass,
at: new Date().toISOString(),
});
await dbClient.$executeRaw`
UPDATE "Image"
SET
-- Transient keeps its status; Error would demote a fresh upload to the hourly lane.
"ingestion" = CASE
WHEN ${isPermanent}::boolean THEN ${ImageIngestionStatus.Error}::"ImageIngestionStatus"
ELSE "ingestion"
END,
"scanRequestedAt" = ${new Date()},
"scanJobs" = jsonb_set(
jsonb_set(
COALESCE("scanJobs", '{}'),
'{retryCount}',
to_jsonb(COALESCE(("scanJobs"->>'retryCount')::int, 0) + 1)
),
'{error}',
${errorJson}::jsonb
)
WHERE id = ${imageId}
-- Submit retries can outlive the callback, so a late failure must not clobber a verdict.
AND "ingestion" = ANY(ARRAY['Pending','Rescan','Error']::"ImageIngestionStatus"[])
`;
return failureClass;
}
export const ingestImage = async ({
image,
lowPriority,
tx,
userId,
}: {
image: IngestImageInput;
lowPriority?: boolean;
tx?: Prisma.TransactionClient;
userId?: number;
}): Promise<boolean> => {
const scanRequestedAt = new Date();
const dbClient = tx ?? dbWrite;
const parsedImage = ingestImageSchema.safeParse(image);
if (!parsedImage.success) throw new Error('Failed to parse image data');
const { url, id, type } = parsedImage.data;
const callbackUrl =
env.IMAGE_SCANNING_CALLBACK ??
`${env.NEXTAUTH_URL}/api/webhooks/image-scan-result?token=${env.WEBHOOK_TOKEN}`;
const {
data: workflowResponse,
error: submitError,
status: submitStatus,
useImageScanning,
} = await createImageIngestionRequest({
imageId: id,
url,
type,
callbackUrl,
priority: lowPriority ? 'low' : undefined,
});
const lane = useImageScanning ? 'imageScanning' : 'new';
if (!workflowResponse) {
imageScanSubmittedCounter.inc({ lane, result: 'failed' });
const failureClass = await markImageScanSubmitFailure({
dbClient,
imageId: id,
status: submitStatus,
error: submitError,
});
// createImageIngestionRequest's own log carries neither mediaType nor failureClass.
logToAxiom({
name: imageIngestionLogName(useImageScanning),
type: 'error',
reason: 'no-workflow-response',
failureType: 'send-fail',
failureClass,
responseStatus: submitStatus,
imageId: id,
mediaType: type,
}).catch(() => null);
return false;
}
const scanJobsJson = JSON.stringify({ workflowId: workflowResponse.id });
await dbClient.$executeRaw`
UPDATE "Image"
SET
"scanRequestedAt" = ${scanRequestedAt},
"scanJobs" = CASE
WHEN "scanJobs" IS NOT NULL AND "scanJobs" ? 'retryCount' THEN
${scanJobsJson}::jsonb || jsonb_build_object('retryCount', ("scanJobs"->'retryCount'))
ELSE
${scanJobsJson}::jsonb
END
WHERE id = ${id}
`;
imageScanSubmittedCounter.inc({ lane, result: 'success' });
return true;
};
export function enqueueImageIngestion({
images,
name,
userId,
lowPriority,
}: {
images: IngestImageInput[];
name: string;
userId?: number;
lowPriority?: boolean;
}) {
if (!images.length) return;
logToAxiom({
name: `${name}:enqueue`,
type: 'info',
userId,
message: `Enqueuing ${images.length} images for ingestion`,
imageIds: images.map((img) => img.id),
}).catch(() => undefined);
const tasks = images.map(
(img) => () =>
ingestImage({ image: img, lowPriority, userId }).catch((error) => {
logToAxiom({
name,
type: 'error',
userId,
imageId: img.id,
message: error instanceof Error ? error.message : String(error),
}).catch(() => undefined);
})
);
limitConcurrency(tasks, 5).catch(() => undefined);
}
// #region [new service methods]
// export function applyUserPreferencesSql(
// AND: Prisma.Sql[],
// {
// excludedUserIds,
// excludedImageIds,
// excludedTagIds,
// userId,
// hidden,
// }: UserPreferencesInput & { userId?: number; hidden?: boolean }
// ) {
// // Exclude specific users
// if (excludedUserIds?.length)
// AND.push(Prisma.sql`i."userId" NOT IN (${Prisma.join(excludedUserIds)})`);
//
// // Exclude specific images
// if (excludedImageIds?.length) {
// AND.push(
// hidden
// ? Prisma.sql`i."id" IN (${Prisma.join(excludedImageIds)})`
// : Prisma.sql`i."id" NOT IN (${Prisma.join(excludedImageIds)})`
// );
// }
//
// // Exclude specific tags
// if (excludedTagIds?.length) {
// const OR = [
// Prisma.join(
// [
// Prisma.sql`i."ingestion" = ${ImageIngestionStatus.Scanned}::"ImageIngestionStatus"`,
// Prisma.sql`NOT EXISTS (
// SELECT 1 FROM "TagsOnImageDetails" toi
// WHERE toi."imageId" = i.id AND toi."tagId" IN (${Prisma.join([
// ...new Set(excludedTagIds),
// ])}) AND NOT toi.disabled
// )`,
// ],
// ' AND '
// ),
// ];
// if (userId) OR.push(Prisma.sql`i."userId" = ${userId}`);
// AND.push(Prisma.sql`(${Prisma.join(OR, ' OR ')})`);
// }
//
// return AND;
// }
type GetAllImagesRaw = {
id: number;
name: string | null;
url: string;
nsfwLevel: NsfwLevel;
width: number | null;
height: number | null;
hash: string | null;
meta?: ImageMetaProps | null;
hideMeta: boolean;
hasMeta: boolean;
onSite: boolean;
createdAt: Date;
sortAt: Date;
mimeType: string | null;
scannedAt: Date | null;
ingestion: ImageIngestionStatus;
blockedFor: BlockedReason | null;
needsReview: string | null;
userId: number;
index: number | null;
postId: number | null;
postTitle: string | null;
modelVersionId: number | null;
// RAW Post.model3dId — visibility-checked into a nullable output field below
// (the batched `getVisibleModel3DIds` gate) before reaching the client. Drives
// the "Posted to 3D Model" chip on the feed-modal path without an ambient
// `model3d.getByPostId` lookup.
model3dId: number | null;
imageId: number | null;
publishedAt: Date | null;
unpublishedAt?: Date | null;
cursorId?: string;
type: MediaType;
metadata: ImageMetadata | VideoMetadata | null;
baseModel?: string;
availability: Availability;
minor: boolean;
acceptableMinor: boolean;
poi?: boolean;
remixOfId?: number | null;
hasPositivePrompt?: boolean;
collectionItemNote?: string | null;
collectionItemStatus?: CollectionItemStatus | null;
collectionItemAddedById?: number | null;
};
type GetAllImagesInput = GetInfiniteImagesOutput & {
useCombinedNsfwLevel?: boolean;
user?: SessionUser;
// Request color, used to pick which "new & upcoming" board backs `newCreators`.
domain?: DomainColor;
headers?: Record<string, string>; // TODO needed?
dbTarget?: 'read' | 'write' | 'datapacket';
signal?: AbortSignal;
// Caller identity forwarded to Meili via X-Search-Actor for abuse/rate
// correlation. Built upstream via buildSearchActor().
actor?: string;
};
// Derived from `getAllImages`' return type, which applies `stripImageForInfiniteWire`
// to each item — so this shape is already narrowed to
// `Omit<..., IMAGE_INFINITE_DROPPED_FIELDS>`. Any consumer that reads a dropped field
// (client component or internal server caller) is a compile error. See
// `~/server/utils/image-infinite-wire.ts`.
export type ImagesInfiniteModel = AsyncReturnType<typeof getAllImages>['items'][0];
// Per-call ceiling for the image-feed raw query. The `civitai` postgres role
// has statement_timeout=0 (overriding the cluster's 300s), so a single slow
// run of this query (mean 4.2s / max 115.8s, dominant source of replica
// statement-timeout cancellations) can monopolize the replica's parallel-worker
// budget, back up pgbouncer-ro, and cascade into api-primary health-check
// failures. 20s is comfortably above p99 for healthy runs while bounding
// pathological cases. On timeout the caller returns an empty page (graceful)
// rather than surfacing a 500, with a structured axiom log for observability.
const IMAGE_FEED_STATEMENT_TIMEOUT_MS = 20_000;
// Increments when the image-feed query is server-side cancelled by the
// statement_timeout ceiling above. Labelled by dbTarget so we can see
// which pool the slow query landed on. Distinguished from pg_cancel_backend
// or client AbortSignal cancellations by checking the error message — those
// also use SQLSTATE 57014 but should propagate rather than fall back to empty.
const imageFeedStatementTimeoutCounter = registerCounterWithLabels({
name: 'image_feed_statement_timeout_total',
help: 'getAllImages raw query cancelled by server-side statement_timeout',
labelNames: ['dbTarget'] as const,
});
// The feed service hydrates its pages by id, so an empty by-id page is a fallback to Meilisearch;
// the stage names the branch that emptied it.
const imagesByIdsEmptyCounter = registerCounterWithLabels({
name: 'images_by_ids_empty_total',
help: 'getAllImages pages requested by id that came back empty, by the branch that emptied them',
labelNames: ['stage'] as const,
});
const IDS_EMPTY_LOG_INTERVAL_MS = 10_000;
let idsEmptyLoggedAt = 0;
function noteEmptyIdsPage(
input: {
ids?: number[];
sort?: unknown;
browsingLevel?: number;
useCombinedNsfwLevel?: boolean;
user?: { id?: number; isModerator?: boolean };
},
stage: string,
details?: Record<string, unknown>
) {
if (!input.ids?.length) return;
imagesByIdsEmptyCounter.inc({ stage });
const now = Date.now();
if (now - idsEmptyLoggedAt < IDS_EMPTY_LOG_INTERVAL_MS) return;
idsEmptyLoggedAt = now;
logToAxiom(
{
type: 'warning',
name: 'images-by-ids-empty',
stage,
ids: input.ids.length,
firstIds: input.ids.slice(0, 5),
sort: input.sort,
browsingLevel: input.browsingLevel,
useCombinedNsfwLevel: input.useCombinedNsfwLevel,
viewerId: input.user?.id,
isModerator: input.user?.isModerator,
...details,
},
'civitai-prod'
).catch(() => undefined);
}
// getImageMetricsObject soft-fallback rate: incremented whenever the ClickHouse
// image-metrics read exceeds CLICKHOUSE_IMAGE_METRICS_TIMEOUT_MS and we serve
// empty (TRANSIENT-zero) metrics. Makes the otherwise axiom-only fallback rate
// observable in Prometheus. No label dimension — the timeout has no natural one.
const imageMetricsClickhouseTimeoutCounter = registerCounter({
name: 'image_metrics_clickhouse_timeout_total',
help: 'getImageMetricsObject ClickHouse read exceeded the soft-fallback timeout (served empty metrics)',
});
/**
* Who may ask for unpublished content, and over whose work.
*
* A moderator may ask about anyone. Everyone else may ask only about themselves,
* and only when the request is ALREADY scoped to them — the scoping is the
* authorization, not a separate check that could drift from it. An unscoped
* `notPublished` from a non-moderator would otherwise return every draft on the
* site, so a missing `targetUserId` must refuse rather than default.
*
* `targetUserId` is the creator being browsed, NOT the viewer. Those are
* different fields on every path here (`getAllImages` calls the viewer `userId`
* and the creator `targetUserId`; the search builders call the creator `userId`
* and the viewer `currentUserId`), and passing the wrong one turns this into a
* check that always passes.
*/
function canRequestUnpublished({
isModerator,
currentUserId,
targetUserId,
}: {
isModerator?: boolean;
currentUserId?: number | null;
targetUserId?: number | null;
}) {
if (isModerator) return true;
return !!currentUserId && !!targetUserId && targetUserId === currentUserId;
}
/**
* Resolve the `hideChallenges` flag into an `excludedTagIds` entry, in place.
* Mirrors `enforceBlockedBrowsingTags`: the client sends intent, the server owns
* the tag id, and every query path picks it up from `excludedTagIds` unchanged.
* Reads the static config rather than `getChallengeConfig()` so the feed doesn't
* take a sysRedis round-trip per request.
*/
function applyHideChallengesExclusion(input: {
hideChallenges?: boolean;
excludedTagIds?: number[];
}) {
if (!input.hideChallenges) return;
input.excludedTagIds = [
...new Set([...(input.excludedTagIds ?? []), dailyChallengeConfig.challengeTagId]),
];
}
const getAllImagesUncaptured = async (
input: GetAllImagesInput & {
userId?: number;
}
) => {
// Fail loud rather than serve unfiltered. This path has no way to express a
// hub — collection membership lives on the search index, not in a column here —
// so a hubId arriving means the dispatcher routed wrongly. Returning results
// would hand the caller the global feed labelled as their hub.
if (input.hubId)
throw throwInternalServerError(
new Error('getAllImages cannot serve a hub; hub queries must use the index path')
);
// Ahead of every early empty return below: the point of throwing rather than falling back is
// that the misuse is legible, and an empty page from one of those branches hides it.
if (input.sort === ImageSort.RecentlyAdded && !input.collectionId) {
throw throwBadRequestError('Recently Added sort requires a collectionId');
}
const blockedEnforcement = await enforceBlockedBrowsingTags(input, {
id: input.user?.id,
username: input.user?.username,
isModerator: input.user?.isModerator,
});
if (blockedEnforcement.emptyResult) {
noteEmptyIdsPage(input, 'blocked-tags');
return { nextCursor: undefined, items: [] };
}
applyHideChallengesExclusion(input);
const {
limit,
cursor,
skip,
sort,
postId,
postIds,
collectionId, // TODO - call this from separate method?
modelId,
modelVersionId,
model3dId,
imageId, // used in public API
username,
period,
periodMode,
tags,
generation,
reviewId,
newCreators,
domain,
prioritizedUserIds,
include,
// hideAutoResources,
// hideManualResources,
reactions,
includeBaseModel,
types,
hidden,
followed,
fromPlatform,
user,
pending,
publishedOnly,
notPublished,
scheduled,
tools,
techniques,
baseModels,
collectionTagId,
excludedUserIds,
excludedTagIds,
disablePoi,
disableMinor,
poiOnly,
minorOnly,
pendingReviewOnly,
} = input;
let { browsingLevel, userId: targetUserId, ids } = input;
let { dbTarget = 'read' } = input;
// While the DataPacket replica is missing ImageResourceNew backfill, force
// queries that join ImageResourceNew (modelId/modelVersionId/reviewId filter
// or baseModels filter) to the writer. Flipt flag lets us flip off post-backfill.
const joinsImageResourceNew = !!modelId || !!modelVersionId || !!reviewId || !!baseModels?.length;
if (
joinsImageResourceNew &&
dbTarget !== 'write' &&
(await isFlipt(FLIPT_FEATURE_FLAGS.IMAGE_RESOURCE_USE_WRITE))
) {
dbTarget = 'write';
}
const imageDb =
dbTarget === 'write' ? pgDbWrite : dbTarget === 'datapacket' ? datapacketDbRead : pgDbRead;
const AND: Prisma.Sql[] = [Prisma.sql`i."postId" IS NOT NULL`];
const WITH: Prisma.Sql[] = [];
let orderBy: string;
const cacheTags: string[] = [];
// Default-deny: cache only enabled for safe, shareable paths (set explicitly below).
// Personalized branches set isPersonalized = true. Final cacheTime is forced to 0
// at the cache-wrapper site if isPersonalized — this makes the invariant
// ordering-independent so a personalization branch above the modelId/modelVersionId
// enable site (e.g. `hidden`) can't be silently re-enabled by a later branch.
let cacheTime = 0;
let isPersonalized = false;
const userId = user?.id;
const isModerator = user?.isModerator ?? false;
// Moderators opting into the "pending review" collection filter get the same
// unscanned-content visibility as the existing `pending` flag (nsfwLevel = 0 allowed).
const effectivePending = pending || (isModerator && !!pendingReviewOnly);
const includeCosmetics = include?.includes('cosmetics'); // TODO: This must be done similar to user cosmetics.
// Exclude unselectable browsing levels
browsingLevel = onlySelectableLevels(browsingLevel);
// `applyDomainFeature` only backfills an absent `browsingLevel` on capped
// (green) domains, so on red/blue it can arrive undefined and reach the SQL as
// NULL — `(nsfwLevel & NULL)` is NULL, silently dropping every row. Fail closed
// to public rather than to nothing; widening here would serve levels the caller
// never asked for.
if (!browsingLevel) browsingLevel = publicBrowsingLevelsFlag;
// Parse random cursor seed upfront (needed to determine if we need to fetch seed)
let parsedRandomCursorSeed: number | undefined;
if (sort === ImageSort.Random && cursor) {
const cursorStr = String(cursor);
const parts = cursorStr.split(':');
if (parts.length === 3) {
parsedRandomCursorSeed = Number(parts[0]);
}
}
// Prefetch independent async data in parallel
const needsCollectionSeed = collectionId && sort === ImageSort.Random && !parsedRandomCursorSeed;
const [
prefetchedHiddenImages,
prefetchedTargetUser,
prefetchedIsFlipt,
prefetchedUserFollows,
prefetchedNewCreators,
prefetchedCollectionPermissions,
prefetchedCollectionSeed,
] = await Promise.all([
hidden && userId
? dbRead.imageEngagement.findMany({
where: { userId, type: 'Hide' },
select: { imageId: true },
})
: undefined,
username && !targetUserId
? dbRead.user
.findUnique({ where: { username }, select: { id: true } })
.then((u) => u ?? dbWrite.user.findUnique({ where: { username }, select: { id: true } }))
: undefined,
prioritizedUserIds?.length
? isFlipt('use-model-version-cache-for-images', modelVersionId?.toString(), {
isModerator: isModerator.toString(),
userId: userId?.toString() || 'anon',
})
: false,
userId && followed ? getUserFollows(userId) : undefined,
newCreators ? getNewCreatorUserIds({ entity: 'images', domain }) : undefined,
collectionId
? getUserCollectionPermissionsById({ userId, isModerator, id: collectionId })
: undefined,
needsCollectionSeed ? getCollectionRandomSeed() : undefined,
]);
if (hidden) {
if (!userId) throw throwAuthorizationError();
const imageIds = prefetchedHiddenImages?.map((x) => x.imageId) ?? [];
if (imageIds.length) {
isPersonalized = true; // per-user hidden image set
AND.push(Prisma.sql`i."id" IN (${Prisma.join(imageIds)})`);
} else {
noteEmptyIdsPage(input, 'hidden');
return { items: [], nextCursor: undefined };
}
}
if (username && !targetUserId) {
if (!prefetchedTargetUser) throw throwNotFoundError('User not found');
targetUserId = prefetchedTargetUser.id;
}
// Hacked this to use the model version image cache instead
const prioritizeUser = !!prioritizedUserIds?.length;
const useModelVersionCache = prioritizeUser && prefetchedIsFlipt;
if (prioritizeUser && useModelVersionCache) {
if (cursor) throw throwBadRequestError('Cannot use cursor with prioritizedUserIds');
if (!modelVersionId)
throw throwBadRequestError('modelVersionId is required when using prioritizedUserIds');
const cachedData = await imagesForModelVersionsCache.fetch([modelVersionId]);
const versionData = cachedData[modelVersionId];
if (!versionData || !versionData.images?.length) {
noteEmptyIdsPage(input, 'prioritized');
return { items: [], nextCursor: undefined };
}
ids = versionData.images.map((img) => img.id);
}
// [x]
if (ids && ids.length > 0) {
AND.push(Prisma.sql`i."id" = ANY(${ids}::int[])`);
}
// [x]
if (types && types.length > 0) {
AND.push(Prisma.sql`i.type = ANY(ARRAY[${Prisma.join(types)}]::"MediaType"[])`);
}
// [x]
if (include.includes('meta')) {
AND.push(
Prisma.sql`NOT (i.meta IS NULL OR jsonb_typeof(i.meta) = 'null' OR i."hideMeta" = TRUE)`
);
}
// [x]
if (fromPlatform) {
AND.push(Prisma.sql`(i.meta IS NOT NULL AND i.meta ? 'civitaiResources')`);
}
// [x]
if (notPublished && canRequestUnpublished({ isModerator, currentUserId: userId, targetUserId })) {
AND.push(Prisma.sql`(p."publishedAt" IS NULL)`);
} else if (!effectivePending) {
// Strict published-only, with the owner carve-out gated on the `scheduled`
// opt-in — the same rule the two Meili builders apply
// FOR A NON-MODERATOR.
//
// Deliberately not claiming full parity, because there isn't any. A
// moderator's `scheduled` request diverges: Meili emits `publishedAtUnix >
// now` (scheduled content from every creator), while this path emits the
// ordinary published feed plus that one moderator's own scheduled posts. So
// a moderator on a collection or any other DB-pinned view sees a different
// population than they would on the index. Pre-existing and out of scope
// here; recorded so the next person checking parity does not read a
// three-backend claim and stop looking.
//
// A fourth site is out of step too: `getImage` (~:6452) still carries the old
// permissive `OR p."userId" = <viewer>` with no publish predicate.
//
// The carve-out used to be a bare `p."userId" = <viewer>` with no publish
// predicate and no opt-in, so every signed-in caller got their own drafts,
// bounty entry uploads and orphans mixed into EVERY feed whether or not they
// asked. `p."publishedAt" > now()` is what makes it mean scheduled rather
// than unpublished; drafts have a NULL publish time and are reached through
// the Draft toggle instead (`notPublished`, handled above).
//
// Cache keying is NOT carried by this clause — measured, because the obvious
// reading is that removing the bare owner match widened the key. It does not:
// the availability carve-out a few lines below binds `userId` into the SQL
// for every non-moderator, and `queryCacheRaw` hashes the whole statement
// including its values. So the key stays per-user for the same viewers it
// always was. A moderator's key does widen — they skip the availability
// clause too — which shares one key across moderators rather than one each,
// and cannot collide with a non-moderator's differently-shaped SQL.
if (userId && !publishedOnly && scheduled) {
AND.push(
Prisma.sql`(p."publishedAt" < now() OR (p."userId" = ${userId} AND p."publishedAt" > now()))`
);
} else {
AND.push(Prisma.sql`(p."publishedAt" < now())`);
}
}
if (!isModerator) {
AND.push(
Prisma.sql`((p."availability" != ${Availability.Private} AND i."ingestion" != 'Blocked') OR p."userId" = ${userId})`
);
}
if (disablePoi) {
AND.push(Prisma.sql`(i."poi" != TRUE OR p."userId" = ${userId})`);
}
if (disableMinor) {
AND.push(Prisma.sql`(i."minor" != TRUE)`);
}
if (excludedTagIds?.length) {
const notExcluded = Prisma.sql`NOT EXISTS (
SELECT 1 FROM "TagsOnImageDetails" toi
WHERE toi."imageId" = i.id
AND toi."tagId" IN (${Prisma.join([...new Set(excludedTagIds)])})
AND toi."disabled" = FALSE
)`;
AND.push(userId ? Prisma.sql`(${notExcluded} OR i."userId" = ${userId})` : notExcluded);
}
if (isModerator) {
if (poiOnly) {
AND.push(Prisma.sql`(i."poi" = TRUE)`);
}
if (minorOnly) {
AND.push(Prisma.sql`(i."minor" = TRUE)`);
}
}
let from = 'FROM "Image" i';
const joins: string[] = [];
// Filter to specific model/review content
if (!prioritizeUser && (modelId || modelVersionId || reviewId)) {
from = `FROM "ImageResourceNew" irr`;
joins.push(`JOIN "Image" i ON i.id = irr."imageId"`);
if (reviewId) {
joins.push(`JOIN "ResourceReview" re ON re."modelVersionId" = irr."modelVersionId"`);
AND.push(Prisma.sql`re."id" = ${reviewId}`);
// reviewId joins ResourceReview — out of scope for this PR's cache enable.
} else if (modelVersionId) {
AND.push(Prisma.sql`irr."modelVersionId" = ${modelVersionId}`);
// 10 min — model galleries can tolerate brief staleness; bust hooks in
// post.service.ts fire on image upload/update for the matching tag.
cacheTime = CacheTTL.md;
cacheTags.push(`images-modelVersion:${modelVersionId}`);
} else if (modelId) {
joins.push(`JOIN "ModelVersion" mv ON mv.id = irr."modelVersionId"`);
AND.push(Prisma.sql`mv."modelId" = ${modelId}`);
cacheTime = CacheTTL.md;
cacheTags.push(`images-model:${modelId}`);
}
}
// Model3D gallery: posts link to a Model3D via Post.model3dId (no
// ModelVersion / ImageResourceNew chain involved), so this is just a
// direct filter on the always-present Post join below. Mirrors the
// collection / model gallery cache shape — Model3D image uploads are
// captured by the same post.service bust hooks.
if (model3dId) {
AND.push(Prisma.sql`p."model3dId" = ${model3dId}`);
cacheTime = CacheTTL.md;
cacheTags.push(`images-model3d:${model3dId}`);
}
// [x] TODO remove
if (targetUserId) {
// WITH.push(
// Prisma.sql`collaboratingPosts AS (
// SELECT "entityId" id FROM "EntityCollaborator"
// WHERE "userId" = ${targetUserId}
// AND "entityType" = 'Post'
// AND "status" = 'Approved'
// )`
// );
AND.push(
// TOOD: Due to performance reasons we cannot add this here yet. Will need to revise with other teams.
// Prisma.sql`(i."userId" = ${targetUserId} OR i."postId" IN (SELECT id FROM collaboratingPosts))`
Prisma.sql`i."userId" = ${targetUserId}`
);
// user-gallery path: out of scope for this PR; future work could enable
// cache for targetUserId !== userId via an `images-user:${targetUserId}` tag.
isPersonalized = true;
}
// Filter only followed users
// [x]
if (userId && followed && prefetchedUserFollows?.length) {
isPersonalized = true; // per-user follow set
AND.push(Prisma.sql`i."userId" IN (${Prisma.join(prefetchedUserFollows)})`);
}
// Filter to creators on the "new & upcoming" board. Unlike `followed` this set is
// global (per domain, not per viewer), so it deliberately does NOT set
// isPersonalized — the feed stays cacheable, and the id list is part of the query
// text the cache keys on.
if (newCreators) {
// An empty board (never populated, or a failed nightly run) must return nothing
// rather than silently degrading to the unfiltered global feed.
AND.push(
prefetchedNewCreators?.length
? Prisma.sql`i."userId" IN (${Prisma.join(prefetchedNewCreators)})`
: Prisma.sql`1 = 0`
);
}
// Filter to specific tags
if (tags?.length) {
isPersonalized = true; // tag combinations are high-cardinality; skip cache for now
AND.push(Prisma.sql`i.id IN (
SELECT "imageId"
FROM "TagsOnImageDetails"
WHERE "tagId" IN (${Prisma.join(tags)}) AND "disabled" = FALSE
)`);
}
// Filter to specific generation process
// [x]
if (generation?.length) {
AND.push(Prisma.sql`i."generationProcess" IN (${Prisma.join(generation)})`);
}
// Filter to a specific post
// [x]
if (postId) AND.push(Prisma.sql`i."postId" = ${postId}`);
// [x]
if (!!postIds?.length) AND.push(Prisma.sql`i."postId" IN (${Prisma.join(postIds)})`);
// Filter to a specific image
if (imageId) {
isPersonalized = true; // single-image lookups don't benefit from caching
AND.push(Prisma.sql`i.id = ${imageId}`);
}
if (sort === ImageSort.Random && !collectionId) {
throw throwBadRequestError('Random sort requires a collectionId');
}
if (collectionTagId && !collectionId) {
throw throwBadRequestError('collectionTagId requires a collectionId');
}
// Filter to a specific collection and relevant status:
// For random sort, parse seed from cursor (format: "seed:sortKey:id") for pagination continuity
let collectionSeed: number | undefined;
let parsedRandomCursor: { seed?: number; sortKey?: number; id?: number } | undefined;
if (collectionId) {
// Check if user has access to collection (prefetched)
if (!prefetchedCollectionPermissions?.read) {
noteEmptyIdsPage(input, 'collection');
return { nextCursor: undefined, items: [] };
}
const displayOwnedItems = userId
? ` OR (ci."status" <> 'REJECTED' AND ci."addedById" = ${userId})`
: '';
// Moderators can opt into viewing ALL entries still under review for a collection
// (owner-only clause intentionally dropped — mods see every REVIEW item, not just
// their own). Everyone else: accepted items + the requester's own non-rejected items.
const collectionStatusFilter =
isModerator && pendingReviewOnly
? `ci."status" = 'REVIEW'`
: `(ci."status" = 'ACCEPTED'${displayOwnedItems})`;
// For random sort, use prefetched seed or parse from cursor
if (sort === ImageSort.Random) {
if (cursor) {
const cursorStr = String(cursor);
const parts = cursorStr.split(':');
if (parts.length === 3) {
parsedRandomCursor = {
seed: Number(parts[0]),
sortKey: Number(parts[1]),
id: Number(parts[2]),
};
collectionSeed = parsedRandomCursor.seed;
}
}
// Use prefetched seed if not in cursor
if (!collectionSeed) {
collectionSeed = prefetchedCollectionSeed;
}
}
const useRandomCursor = parsedRandomCursor && sort === ImageSort.Random;
const seedStr = collectionSeed?.toString() ?? '';
// Use subquery to compute hash once, then filter/sort on computed value
WITH.push(
Prisma.sql`
ct AS (
SELECT "imageId", note, status, "addedById", "collectionItemId", "sortKey"
FROM (
SELECT
ci."imageId",
ci.note,
ci.status,
ci."addedById",
ci.id as "collectionItemId",
abs(mod(hashtext(concat(ci.id::text, '${Prisma.raw(
seedStr
)}')), 1000000000)) as "sortKey"
FROM "CollectionItem" ci
WHERE ci."collectionId" = ${collectionId}
${Prisma.raw(collectionTagId ? ` AND ci."tagId" = ${collectionTagId}` : ``)}
AND ci."imageId" IS NOT NULL
AND ${Prisma.raw(collectionStatusFilter)}
) sub
${Prisma.raw(
useRandomCursor &&
parsedRandomCursor?.sortKey !== undefined &&
parsedRandomCursor?.id !== undefined
? `WHERE (
"sortKey" < ${parsedRandomCursor.sortKey}
OR (
"sortKey" = ${parsedRandomCursor.sortKey}
AND "imageId" < ${parsedRandomCursor.id}
)
)`
: ''
)}
${Prisma.raw(sort === ImageSort.Random ? `ORDER BY "sortKey" DESC` : '')}
)`
);
}
if (excludedUserIds?.length) {
AND.push(Prisma.sql`i."userId" != ALL(${excludedUserIds}::int[])`);
}
const isGallery = modelId || modelVersionId || model3dId || reviewId || userId;
if (postId && !modelId) {
// a post image query won't include modelId
orderBy = `i."index"`;
} else {
// Sort by selected sort
// if (sort === ImageSort.MostComments) {
// orderBy = `im."commentCount" DESC, im."reactionCount" DESC, im."imageId"`;
// if (!isGallery) AND.push(Prisma.sql`im."commentCount" > 0`);
// } else if (sort === ImageSort.MostReactions) {
// orderBy = `im."reactionCount" DESC, im."heartCount" DESC, im."likeCount" DESC, im."imageId"`;
// if (!isGallery) AND.push(Prisma.sql`im."reactionCount" > 0`);
// } else if (sort === ImageSort.MostCollected) {
// orderBy = `im."collectedCount" DESC, im."reactionCount" DESC, im."imageId"`;
// if (!isGallery) AND.push(Prisma.sql`im."collectedCount" > 0`);
// }
if (sort === ImageSort.Random) {
isPersonalized = true; // random ordering should not be pinned by a cache
orderBy = 'ct."sortKey" DESC, i."id" DESC';
} else if (sort === ImageSort.RecentlyAdded) {
orderBy = 'ct."collectionItemId" DESC';
}
// TODO this causes the app to spike
// else if (sort === ImageSort.Oldest) {
// orderBy = 'i."sortAt" ASC';
// AND.push(Prisma.sql`i."sortAt" <= now()`);
// } else {
// orderBy = 'i."sortAt" DESC';
// AND.push(Prisma.sql`i."sortAt" <= now()`);
// }
else if (sort === ImageSort.Oldest) orderBy = `i."id" ASC`;
else {
if (from.indexOf(`irr`) !== -1) {
// Ensure to sort by irr.imageId when reading from imageResources to maximize index utilization
orderBy = `irr."imageId" DESC`;
} else {
orderBy = `i."id" DESC`;
}
}
}
// if (hidden) {
// cacheTime = 0;
// AND.push(Prisma.sql`i."id" IN (${Prisma.join(excludedImageIds ?? [])})`);
// }
// Limit to images created since period start
if (period && period !== 'AllTime' && periodMode !== 'stats') {
const interval = period.toLowerCase();
AND.push(
Prisma.sql`i."createdAt" >= date_trunc('day', now()) - interval '1 ${Prisma.raw(interval)}'`
);
}
// Handle cursor & skip conflict
if (cursor && skip) throw new Error('Cannot use skip with cursor'); // [x]
// Handle cursor prop - for random sort with collection, don't call getCursor since our cursor format is different
let cursorClause: Prisma.Sql | undefined;
let cursorProp: string | undefined;
if (sort === ImageSort.Random && collectionSeed !== undefined) {
// For random sort, generate compound cursor: seed:sortKey:id
// Cursor handling is done in the CTE above, so we don't need cursorClause here
cursorProp = `concat(${collectionSeed}::text, ':', ct."sortKey"::text, ':', i."id"::text)`;
cursorClause = undefined;
} else if (sort === ImageSort.Random) {
cursorProp = 'i."id"';
cursorClause = undefined;
} else {
// For non-random sort, use the standard getCursor helper
const cursorResult = getCursor(orderBy, cursor);
cursorClause = cursorResult.where;
cursorProp = cursorResult.prop;
}
if (cursorClause) AND.push(cursorClause);
if (prioritizeUser && !useModelVersionCache) {
// [x]
if (cursor) throw throwBadRequestError('Cannot use cursor with prioritizedUserIds');
isPersonalized = true; // prioritizedUserIds reorders/filters per-caller
if (modelVersionId) AND.push(Prisma.sql`p."modelVersionId" = ${modelVersionId}`);
// If system user, show community images
const prioritizseIsSystemUser = prioritizedUserIds.length === 1 && prioritizedUserIds[0] === -1;
// Confirm system user has posts:
// Existence check only — `select: { id }` keeps this from decoding the
// whole Post row (three DateTimes plus the `metadata` Json) to answer a
// boolean.
const hasSystemPosts =
prioritizseIsSystemUser && modelVersionId
? await dbRead.post.findFirst({
where: { userId: -1, modelVersionId },
select: { id: true },
})
: false;
if (prioritizseIsSystemUser && !hasSystemPosts)
orderBy = `IIF(i."userId" IN (${prioritizedUserIds.join(',')}), i.index, 1000), ${orderBy}`;
else {
// For everyone else, only show their images.
AND.push(Prisma.sql`i."userId" IN (${Prisma.join(prioritizedUserIds)})`);
orderBy = `(i."postId"::bigint * 100) + COALESCE(i."index", 0)`; // Order by oldest post first
}
}
if (userId && !!reactions?.length) {
isPersonalized = true; // per-user reaction filter
// Use IN subquery - planner can start from reactions (small set per user) and join to images
AND.push(Prisma.sql`i.id IN (
SELECT ir."imageId" FROM "ImageReaction" ir
WHERE ir."userId" = ${userId} AND ir.reaction IN (${Prisma.join(reactions)})
)`);
}
if (!!tools?.length) {
// Bring in images that contain the selected tools
AND.push(Prisma.sql`EXISTS (
SELECT 1
FROM "ImageTool" it
WHERE it."imageId" = i.id
GROUP BY it."imageId"
HAVING array_agg(it."toolId" ORDER BY it."toolId") @> ARRAY[${Prisma.join(tools)}]::integer[]
)`);
}
if (!!techniques?.length) {
AND.push(Prisma.sql`EXISTS (
SELECT 1
FROM "ImageTechnique" it
WHERE it."imageId" = i.id AND it."techniqueId" IN (${Prisma.join(techniques)})
)`);
}
if (baseModels?.length) {
AND.push(Prisma.sql`EXISTS (
SELECT 1 FROM "ModelVersion" mv
RIGHT JOIN "ImageResourceNew" ir ON ir."imageId" = i.id AND ir."modelVersionId" = mv.id
WHERE mv."baseModel" IN (${Prisma.join(baseModels)})
)`);
}
if (effectivePending && (isModerator || userId)) {
isPersonalized = true; // pending view is moderator/owner-scoped
if (isModerator) {
AND.push(Prisma.sql`((i."nsfwLevel" & ${browsingLevel}) != 0 OR i."nsfwLevel" = 0)`);
} else if (userId) {
AND.push(Prisma.sql`(i."needsReview" IS NULL OR i."userId" = ${userId})`);
AND.push(
Prisma.sql`((i."nsfwLevel" & ${browsingLevel}) != 0 OR (i."nsfwLevel" = 0 AND i."userId" = ${userId}) OR (p."collectionId" IS NOT NULL AND EXISTS (SELECT 1 FROM "CollectionContributor" cc WHERE cc."permissions" && ARRAY['MANAGE']::"CollectionContributorPermission"[] AND cc."collectionId" = p."collectionId" AND cc."userId" = ${userId})))`
);
}
} else {
AND.push(Prisma.sql`i."needsReview" IS NULL`);
// Acceptable in collections, need to check for contest collection only
if (!collectionId) AND.push(Prisma.sql`i."acceptableMinor" = FALSE`);
AND.push(
browsingLevel
? Prisma.sql`(i."nsfwLevel" & ${browsingLevel}) != 0 AND i."nsfwLevel" != 0`
: Prisma.sql`i.ingestion = ${ImageIngestionStatus.Scanned}::"ImageIngestionStatus"`
);
}
// TODO: Adjust ImageMetric
const queryFrom = Prisma.sql`
${Prisma.raw(from)}
${Prisma.raw(joins.join('\n'))}
JOIN "Post" p ON p.id = i."postId"
${Prisma.raw(WITH.length && collectionId ? `JOIN ct ON ct."imageId" = i.id` : '')}
WHERE ${Prisma.join(AND, ' AND ')}
-- Filter out images with NSFW level that are linked to license-restricted base models
-- Images with nsfwLevel >= 4 (R-XXX) cannot use base models with restricted licenses
AND (
(i."nsfwLevel" & ${nsfwBrowsingLevelsFlag}) = 0
OR NOT i."modelRestricted"
)
`;
const queryWith = WITH.length > 0 ? Prisma.sql`WITH ${Prisma.join(WITH, ', ')}` : Prisma.sql``;
const query = Prisma.sql`
${queryWith}
SELECT
i.id,
i.name,
i.url,
i."nsfwLevel",
i.width,
i.height,
i.hash,
-- i.meta,
i."hideMeta",
(
CASE
WHEN i.meta IS NULL OR jsonb_typeof(i.meta) = 'null' OR i."hideMeta" THEN FALSE
ELSE TRUE
END
) AS "hasMeta",
(
CASE
WHEN i.meta IS NOT NULL AND jsonb_typeof(i.meta) != 'null' AND NOT i."hideMeta"
AND i.meta->>'prompt' IS NOT NULL
THEN TRUE
ELSE FALSE
END
) AS "hasPositivePrompt",
${imageOnSiteSql()} as "onSite",
i."meta"->'extra'->'remixOfId' as "remixOfId",
i."createdAt",
GREATEST(p."publishedAt", i."scannedAt", i."createdAt") as "sortAt",
i."mimeType",
i.type,
i.metadata,
i.ingestion,
i."blockedFor",
i."scannedAt",
i."needsReview",
i."userId",
i."postId",
p."title" "postTitle",
i."index",
p."publishedAt",
p.metadata->>'unpublishedAt' "unpublishedAt",
p."modelVersionId",
p."model3dId",
p."availability",
i.minor,
i.poi,
i."acceptableMinor",
${Prisma.raw(cursorProp ? cursorProp : 'null')} "cursorId"
${Prisma.raw(
collectionId
? ', ct.note as "collectionItemNote", ct.status as "collectionItemStatus", ct."addedById" as "collectionItemAddedById"'
: ''
)}
${queryFrom}
ORDER BY ${Prisma.raw(orderBy)}
${Prisma.raw(skip ? `OFFSET ${skip}` : '')}
LIMIT ${limit + 1}
`;
// Final invariant: any personalized branch above forces no-cache, regardless of
// ordering relative to the modelId/modelVersionId enable site. This prevents a
// future personalization branch added above the enable from being silently
// re-cached.
if (isPersonalized) cacheTime = 0;
if (!env.IMAGE_QUERY_CACHING) cacheTime = 0;
// queryCacheRaw wraps imageDb.query so the dual-DB routing (write/datapacket/read)
// computed above is preserved while gaining Redis cache semantics.
// bustCacheTag('images-model:X' / 'images-modelVersion:X') is already wired in
// post.service.ts on image upload/update events.
const cacheable = queryCacheRaw(
async <Row>(q: Prisma.Sql) => {
// Per-call statement_timeout ceiling — see IMAGE_FEED_STATEMENT_TIMEOUT_MS
// for rationale. The timeout fires server-side (pg error code 57014);
// we catch it at the call site below and return an empty page.
const { rows } = await queryWithTimeout(imageDb, IMAGE_FEED_STATEMENT_TIMEOUT_MS, q);
return rows as Row[];
},
'getAllImages',
// Bumped v1 -> v2 when `p."model3dId"` was added to the SELECT. The query
// hash already changes with the new column (so old entries fall out of the
// keyspace on their own), but the explicit version bump abandons the stale
// keyspace immediately rather than leaving model3dId-less rows resident
// until their TTL — keeps the feed-modal chip from silently lacking the
// field on a warm cache after deploy.
'v2'
);
let rawImages: GetAllImagesRaw[];
try {
rawImages = await withSpan('image:getAllImages:rawQuery', () =>
cacheable<GetAllImagesRaw[]>(query, { ttl: cacheTime, tag: cacheTags })
);
} catch (e) {
const code = (e as { code?: string })?.code;
const message = (e as { message?: string })?.message ?? '';
// SQLSTATE 57014 (query_canceled) is shared by statement_timeout,
// pg_cancel_backend(), and client AbortSignal cancellation. Only the
// first should fall back to an empty page — the others must propagate
// so callers/observers see the cancellation. Postgres distinguishes
// them via the error message: "canceling statement due to statement timeout".
if (code === '57014' && message.includes('statement timeout')) {
// Query exceeded IMAGE_FEED_STATEMENT_TIMEOUT_MS on the replica.
// Return an empty page instead of surfacing a 500 — the feed is best-effort
// and the UI handles `items: []` gracefully. Log to axiom so we can track
// frequency and identify pathological filter combinations; also bump a
// counter so we can alert if the rate climbs.
imageFeedStatementTimeoutCounter.inc({ dbTarget });
logToAxiom({
name: 'getInfiniteImages:statement_timeout',
type: 'warning',
message: `image feed query exceeded ${IMAGE_FEED_STATEMENT_TIMEOUT_MS}ms ceiling`,
details: {
timeoutMs: IMAGE_FEED_STATEMENT_TIMEOUT_MS,
dbTarget,
userId,
limit,
cursor: cursor ? String(cursor) : undefined,
sort,
period,
modelId,
modelVersionId,
collectionId,
postId,
username,
tagCount: tags?.length,
baseModelCount: baseModels?.length,
},
}).catch(() => undefined);
noteEmptyIdsPage(input, 'statement-timeout', { dbTarget });
return { items: [], nextCursor: undefined };
}
throw e;
}
if (!rawImages.length) noteEmptyIdsPage(input, 'no-rows', { dbTarget });
// const rawImages = await dbRead.$queryRaw<GetAllImagesRaw[]>(query);
const imageIds = rawImages.map((i) => i.id);
const userIds = rawImages.map((i) => i.userId);
const videoIds = rawImages.filter((x) => x.type === MediaType.video).map((x) => x.id);
let nextCursor: string | undefined;
if (rawImages.length > limit) {
const nextItem = rawImages.pop();
nextCursor = nextItem?.cursorId;
}
// Fetch all cache data in parallel
const [
reactionsRaw,
tagIdsVar,
tagsVar,
userVotes,
userBasicData,
userCosmetics,
profilePictures,
cosmetics,
thumbnails,
imageMetrics,
imageMeta,
imageResources,
] = await withSpan('image:getAllImages:parallelFetch', () =>
Promise.all([
userId
? dbRead.imageReaction.findMany({
where: { imageId: { in: imageIds }, userId },
select: { imageId: true, reaction: true },
})
: undefined,
include?.includes('tagIds') ? tagIdsForImagesCache.fetch(imageIds) : undefined,
include?.includes('tags') ? getImageTagsForImages(imageIds) : undefined,
include?.includes('tags') && userId
? dbRead.tagsOnImageVote.findMany({
where: { imageId: { in: imageIds }, userId },
select: { imageId: true, tagId: true, vote: true },
})
: undefined,
getBasicDataForUsers(userIds),
includeCosmetics ? getCosmeticsForUsers(userIds) : undefined,
include?.includes('profilePictures') ? getProfilePicturesForUsers(userIds) : undefined,
includeCosmetics ? getCosmeticsForEntity({ ids: imageIds, entity: 'Image' }) : undefined,
getThumbnailsForImages(videoIds),
getImageMetricsObject(rawImages),
include?.includes('metaSelect') ? getMetaForImages(imageIds) : undefined,
includeBaseModel ? imageResourcesCache.fetch(imageIds) : undefined,
])
);
// Visibility-check the RAW `model3dId`s carried on the feed rows before they
// reach the client. `p."model3dId"` is unfiltered (it reflects the link, not
// whether the viewer may SEE the linked Model3D), so a hidden Draft / deleted
// model's id would otherwise leak as a clickable chip on the feed-modal path.
// Most feed images aren't linked (model3dId null) so the common page skips the
// query entirely; the linked few resolve in ONE batched query (no N+1),
// applying the SAME `canViewModel3d` predicate as the single-post lookup.
const rawModel3dIds = [
...new Set(rawImages.map((i) => i.model3dId).filter((id): id is number => id != null)),
];
const visibleModel3DIds = rawModel3dIds.length
? await getVisibleModel3DIds({ model3dIds: rawModel3dIds, userId, isModerator })
: undefined;
const images = withSpan('image:getAllImages:transform', () => {
// Process reactions into lookup
let userReactions: Record<number, ReviewReactions[]> | undefined;
if (reactionsRaw) {
userReactions = reactionsRaw.reduce((acc, { imageId, reaction }) => {
acc[imageId] ??= [] as ReviewReactions[];
acc[imageId].push(reaction);
return acc;
}, {} as Record<number, ReviewReactions[]>);
}
// Merge user votes into tags
if (tagsVar && userVotes) {
const voteMap = new Map(userVotes.map((v) => [`${v.imageId}:${v.tagId}`, v.vote]));
for (const tag of tagsVar) {
const vote = voteMap.get(`${tag.imageId}:${tag.id}`);
if (vote !== undefined) tag.vote = vote > 0 ? 1 : -1;
}
}
// Pre-index tags by imageId to avoid O(n*m) filter inside map
const tagsByImageId = tagsVar
? tagsVar.reduce((acc, tag) => {
const arr = acc.get(tag.imageId);
if (arr) arr.push(tag);
else acc.set(tag.imageId, [tag]);
return acc;
}, new Map<number, typeof tagsVar>())
: undefined;
const now = new Date();
const filtered = rawImages.filter((x) => {
if (isModerator) return true;
// if (x.needsReview && x.userId !== userId) return false;
if ((!x.publishedAt || x.publishedAt > now || !!x.unpublishedAt) && x.userId !== userId)
return false;
// if (x.ingestion !== 'Scanned' && x.userId !== userId) return false;
return true;
});
if (rawImages.length && !filtered.length)
noteEmptyIdsPage(input, 'filtered', { rows: rawImages.length });
const result: Array<
Omit<ImageV2Model, 'nsfwLevel' | 'metadata'> & {
// meta: ImageMetaProps | null; // TODO - don't fetch meta
meta?: ImageMetaProps | null; // deprecated. Only used in v1 api endpoint
hideMeta: boolean; // TODO - remove references to this. Instead, use `hasMeta`
hasMeta: boolean;
tags?: VotableTagModel[] | undefined;
tagIds?: number[];
publishedAt?: Date | null;
modelVersionId?: number | null;
baseModel?: string | null; // TODO - remove
availability?: Availability;
nsfwLevel: NsfwLevel;
cosmetic?: WithClaimKey<ContentDecorationCosmetic> | null;
metadata: ImageMetadata | VideoMetadata | null;
onSite: boolean;
modelVersionIds?: number[];
modelVersionIdsManual?: number[];
thumbnailUrl?: string;
remixOfId?: number | null;
hasPositivePrompt?: boolean;
poi?: boolean;
minor?: boolean;
judgeScore?: JudgeScore | Record<string, number> | null;
// Visibility-gated linked-Model3D id (or null) for the "Posted to 3D
// Model" chip on the feed-modal path. See the model3dId override below.
model3dId?: number | null;
collectionItemStatus?: CollectionItemStatus | null;
// Who put the item in the collection — the removal rule accepts them, so the card needs
// it to offer the action. Only present on a collection-filtered feed.
collectionItemAddedById?: number | null;
}
> = filtered.map(({ userId: creatorId, cursorId, unpublishedAt, collectionItemNote, ...i }) => {
const judgeScore = parseJudgeScore(collectionItemNote ?? null);
const match = imageMetrics[i.id];
const thumbnail = thumbnails[i.id];
const userData = userBasicData[creatorId];
return {
...i,
// Override the RAW `p."model3dId"` (spread in via `...i`) with the
// visibility-gated value: keep the id only when the viewer may see the
// linked Model3D, else null. Drives the feed-modal chip from a prop —
// no ambient `model3d.getByPostId`. (`null` here is the three-state
// "resolved-absent" the chip needs to NOT fall back.)
model3dId: i.model3dId != null && visibleModel3DIds?.has(i.model3dId) ? i.model3dId : null,
meta: imageMeta?.[i.id] ?? null,
nsfwLevel: Math.max(thumbnail?.nsfwLevel ?? 0, i.nsfwLevel),
// `modelVersionIds` is auto-detected only and `modelVersionIdsManual` is uploader-asserted,
// matching what the search-index path serves — consumers gate on the difference.
modelVersionIds:
imageResources?.[i.id]?.resources
?.filter((r) => r.detected)
.map((r) => r.modelVersionId) ?? [],
modelVersionIdsManual:
imageResources?.[i.id]?.resources
?.filter((r) => !r.detected)
.map((r) => r.modelVersionId) ?? [],
publishedAt: i.publishedAt ? i.sortAt : undefined,
baseModel: imageResources
? getBaseModelFromResources(imageResources[i.id]?.resources)
: undefined,
user: {
id: creatorId,
username: userData?.username ?? null,
image: userData?.image ?? null,
deletedAt: userData?.deletedAt ?? null,
cosmetics: userCosmetics?.[creatorId] ?? [],
profilePicture: profilePictures?.[creatorId] ?? null,
},
stats: {
likeCountAllTime: match?.reactionLike ?? 0,
laughCountAllTime: match?.reactionLaugh ?? 0,
heartCountAllTime: match?.reactionHeart ?? 0,
cryCountAllTime: match?.reactionCry ?? 0,
commentCountAllTime: match?.comment ?? 0,
collectedCountAllTime: match?.collection ?? 0,
tippedAmountCountAllTime: match?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
reactions:
userReactions?.[i.id]?.map((r) => ({ userId: userId as number, reaction: r })) ?? [],
tags: tagsByImageId?.get(i.id),
tagIds: tagIdsVar?.[i.id]?.tags,
cosmetic: cosmetics?.[i.id] ?? null,
thumbnailUrl: thumbnail?.url,
judgeScore,
};
});
// Put into cached order if prioritizing user (model version showcase)
if (prioritizeUser && useModelVersionCache) {
result.sort((a, b) => ids!.indexOf(a.id) - ids!.indexOf(b.id));
}
return result;
});
return {
nextCursor,
// Always-on wire trim: drop the grep-proven-unread fields no `image.getInfinite`
// consumer reads. Narrows `ImagesInfiniteModel` (defined from this return type) to
// `Omit<..., IMAGE_INFINITE_DROPPED_FIELDS>`, so tsc/`next build` flags any reader.
// See `~/server/utils/image-infinite-wire.ts` for the traced consumer graph.
items: images.map(stripImageForInfiniteWire),
};
};
export const getAllImages = async (input: Parameters<typeof getAllImagesUncaptured>[0]) => {
const started = Date.now();
try {
const result = await getAllImagesUncaptured(input);
void feedRequestCapture().record(input, {
source: 'getAllImages',
elapsedMs: Date.now() - started,
resultIds: result.items.map((i) => i.id),
nextCursor: result.nextCursor,
});
return result;
} catch (err) {
void feedRequestCapture().record(input, {
source: 'getAllImages',
error: true,
elapsedMs: Date.now() - started,
resultIds: [],
});
throw err;
}
};
// TODO split this into image-index.service because this file is a giant
const getMetaForImages = async (imageIds: number[]) => {
if (imageIds.length === 0) return {};
return imageMetaCache.fetch(imageIds);
};
const getMetadataForImages = async (imageIds: number[]) => {
if (imageIds.length === 0) return {};
return imageMetadataCache.fetch(imageIds);
};
const getThumbnailsForImages = async (imageIds: number[]) => {
if (imageIds.length === 0) return {};
return thumbnailCache.fetch(imageIds);
};
type GetAllImagesIndexResult = AsyncReturnType<typeof getAllImages>;
/**
* Only this path reports a `source`, so it is added here rather than to the shared
* alias — `getImagesFromFeedSearch` returns the same alias and never sets one, and
* widening that would let a caller test it for a value it can never hold. Optional
* because the blocked-browsing early return and a search reporting none both omit it.
*/
type GetAllImagesIndexSourcedResult = GetAllImagesIndexResult & {
source?: 'feed' | AsyncReturnType<typeof getImagesFromSearch>['source'];
};
async function getUserIdByUsername(username: string) {
const user =
(await dbRead.user.findUnique({ where: { username }, select: { id: true } })) ??
(await dbWrite.user.findUnique({ where: { username }, select: { id: true } }));
if (!user) throw throwNotFoundError('User not found');
return user.id;
}
export const getAllImagesIndex = async (
input: GetAllImagesInput
): Promise<GetAllImagesIndexSourcedResult> => {
// const {
// user,
// limit,
// cursor,
// postIds,
// modelVersionId,
// period,
// include,
// types,
// fromPlatform,
// baseModels,
// tools,
// techniques,
// tags,
// notPublished,
// scheduled,
// withMeta: hasMeta,
// excludedUserIds,
// hideAutoResources
// hideManualResources
// hidden,
// followed,
// //
// prioritizedUserIds, // TODO fix
// modelId, // TODO fix
// reviewId, // TODO - remove, not in use...true?
// // username, // TODO - query by `userId` instead
// userId,
// collectionId, // TODO - call this from separate method?
// ids,
// skip,
// postId,
// periodMode,
// generation,
// includeBaseModel,
// pending,
// collectionTagId,
// headers,
// excludedTagIds,
// withTags,
// imageId, // TODO - remove, not in use
// // reactions, // we don't have reaction data
// } = input;
// const { sort, browsingLevel } = input;
const { include, user } = input;
const blockedEnforcement = await enforceBlockedBrowsingTags(input, {
id: user?.id,
username: user?.username,
isModerator: user?.isModerator,
});
if (blockedEnforcement.emptyResult) return { nextCursor: undefined, items: [] };
applyHideChallengesExclusion(input);
// - cursor uses "offset|entryTimestamp" like "500|1724677401898"
const cursorParsed = input.cursor?.toString().split('|');
const offset = isNumber(cursorParsed?.[0]) ? Number(cursorParsed?.[0]) : 0;
const entry = isNumber(cursorParsed?.[1]) ? Number(cursorParsed?.[1]) : undefined;
const currentUserId = user?.id;
const userId =
input.userId ?? (input.username ? await getUserIdByUsername(input.username) : undefined);
const searchInput = {
...input,
userId,
currentUserId,
isModerator: user?.isModerator,
offset,
entry,
};
// The feed service picks and orders the page and Postgres supplies the rows; Meilisearch is
// not consulted. What the feed cannot serve (unmapped shape, timeout, error) goes to Meilisearch.
if (feedPrimaryAvailable()) {
const feedPrimary = await withSpan('image:flipt:feedPrimary', () =>
getFliptBoolean(
FLIPT_FEATURE_FLAGS.FEED_SERVICE_PRIMARY,
currentUserId?.toString() || 'anonymous',
feedFliptContext(searchInput)
)
);
if (feedPrimary) {
const started = Date.now();
const followedUserIds =
input.followed && currentUserId ? await getUserFollows(currentUserId) : undefined;
const served = await withSpan('image:feedPrimary', () =>
serveFromFeed(
{ ...searchInput, followedUserIds },
{
fetchFeed: fetchFeedPrimary,
hydrate: async (ids) =>
(
await getAllImagesUncaptured(feedHydrateQuery(input, ids))
).items,
}
)
);
if (served.ok) {
void feedRequestCapture().record(searchInput, {
source: 'getImagesFromSearch',
filterMode: 'feed',
elapsedMs: Date.now() - started,
resultIds: served.page.data.map((i) => i.id),
nextCursor: served.page.nextCursor,
});
return { items: served.page.data, nextCursor: served.page.nextCursor, source: 'feed' };
}
if (served.reason === DEEP_OFFSET)
throw throwBadRequestError('This feed cannot be paged this far; narrow the filters');
}
}
let searchResults: Awaited<ReturnType<typeof getImagesFromSearch>>['data'];
let searchNextCursor: Awaited<ReturnType<typeof getImagesFromSearch>>['nextCursor'];
let searchSource: Awaited<ReturnType<typeof getImagesFromSearch>>['source'];
try {
({
data: searchResults,
nextCursor: searchNextCursor,
source: searchSource,
} = await withSpan('image:getAllImagesIndex:search', () => getImagesFromSearch(searchInput)));
} catch (err) {
// Meilisearch saturation / timeout on the tRPC hot path (image.getInfinite).
// Surface as TRPCError SERVICE_UNAVAILABLE (HTTP 503) so the client gets a
// fast, retryable response instead of bleeding until Traefik's 30s router
// timeout — which is what backed up the event loop and tipped api-primary
// into kubelet SIGKILL on 2026-05-29. 503 is the correct code for a
// transient backend brownout (was TIMEOUT/408 as a tRPC-v10 stopgap; v11
// has SERVICE_UNAVAILABLE).
//
// `isTransientMeiliError` covers the civitai wrapper errors
// (MeiliCallTimeoutError = local timer / circuit-open; MeilisearchFetchError
// with a failfast status = raw-fetch 408/429/5xx) AND the meilisearch-js
// SDK's own error types (MeiliSearchCommunicationError /
// MeiliSearchApiError / MeiliSearchTimeOutError) thrown by the SDK calls
// inside the search index path. 4xx-other (malformed filter / auth) and
// any other Error are NOT transient and still bubble as-is.
if (isTransientMeiliError(err)) {
// Keep a transient Meili outage ATTRIBUTABLE: the reclassified 503 would
// otherwise vanish into the unlabeled 503 bucket. Mirror the post-filter
// loop's counter usage (route + reason) so an outage is queryable by the
// same label vocabulary; `iteration:'0'` (this is the single pre-filter
// search, not the post-filter iteration loop).
meiliFetchFailfastTotal.inc({
route: 'getAllImagesIndex',
iteration: '0',
reason: failfastReasonForTransientError(err),
});
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Image search is temporarily overloaded — please retry.',
cause: err as Error,
});
}
throw err;
}
if (!searchResults.length) {
return {
nextCursor: undefined,
items: [],
};
}
const imageIds = searchResults.map((sr) => sr.id);
const videoIds = searchResults.filter((sr) => sr.type === MediaType.video).map((sr) => sr.id);
const userIds = searchResults.map((sr) => sr.userId);
let userReactions: Record<number, ReviewReactions[]> | undefined;
if (currentUserId) {
const reactionsRaw = await dbRead.imageReaction.findMany({
where: { imageId: { in: imageIds }, userId: currentUserId },
select: { imageId: true, reaction: true },
});
userReactions = reactionsRaw.reduce((acc, { imageId, reaction }) => {
acc[imageId] ??= [] as ReviewReactions[];
acc[imageId].push(reaction);
return acc;
}, {} as Record<number, ReviewReactions[]>);
}
const [
userDatas,
profilePictures,
userCosmetics,
imageCosmetics,
imageMeta,
imageMetadata,
thumbnails,
imageMetrics,
tagIdsVar,
tagsVar,
] = await withSpan('image:getAllImagesIndex:parallelFetch', async () =>
Promise.all([
// These enrichment fetches are independent (each takes pre-computed
// userIds/imageIds/videoIds/searchResults) and are issued WITHOUT awaiting
// each element so Promise.all runs them concurrently — node-redis pipelines
// the cache GETs queued in the same tick, collapsing ~9 sequential Redis
// round-trips into ~1 round-trip-time. The prior version awaited each
// element, forcing sequential evaluation (the span name was aspirational).
// Mirrors the concurrent block in getAllImages.
getBasicDataForUsers(userIds),
include?.includes('profilePictures') ? getProfilePicturesForUsers(userIds) : undefined,
include?.includes('cosmetics') ? getCosmeticsForUsers(userIds) : undefined,
include?.includes('cosmetics')
? getCosmeticsForEntity({
ids: imageIds,
entity: 'Image',
})
: undefined,
include?.includes('metaSelect') ? getMetaForImages(imageIds) : undefined,
getMetadataForImages(videoIds), // Only need this for videos
getThumbnailsForImages(videoIds), // Only need this for videos
getImageMetricsObject(searchResults),
// Fetch tagIds from cache so client-side hidden-tag filtering works.
// Search results don't include tagIds (too expensive to store),
// and Meilisearch tagIds may be stale, so always fetch from the authoritative cache.
include?.includes('tagIds') ? tagIdsForImagesCache.fetch(imageIds) : undefined,
include?.includes('tags') ? getImageTagsForImages(imageIds) : undefined,
])
);
const tagsByImageId = tagsVar
? tagsVar.reduce((acc, tag) => {
const arr = acc.get(tag.imageId);
if (arr) arr.push(tag);
else acc.set(tag.imageId, [tag]);
return acc;
}, new Map<number, typeof tagsVar>())
: undefined;
// Visibility-check the RAW `model3dId` carried on the search docs (indexed
// from `Post.model3dId`) before it reaches the client — same no-leak bar as
// the raw-SQL feed path and `image.get`. Only the non-null few are resolved,
// in ONE batched query (no per-image N+1).
const rawIndexModel3dIds = [
...new Set(
searchResults
.map((sr) => (sr as { model3dId?: number }).model3dId)
.filter((id): id is number => typeof id === 'number')
),
];
const visibleIndexModel3DIds = rawIndexModel3dIds.length
? await getVisibleModel3DIds({
model3dIds: rawIndexModel3dIds,
userId: currentUserId,
isModerator: user?.isModerator,
})
: undefined;
const mergedData = withSpan('image:getAllImagesIndex:transform', () =>
searchResults.map(({ publishedAtUnix, ...sr }) => {
const thisUser = userDatas[sr.userId] ?? {};
const reactions =
userReactions?.[sr.id]?.map((r) => ({ userId: currentUserId as number, reaction: r })) ??
[];
const meta = imageMeta?.[sr.id]?.meta ?? null;
const metadata = imageMetadata[sr.id]?.metadata ?? null;
const thumbnail = thumbnails[sr.id] ?? null;
const nsfwLevel = Math.max(thumbnail?.nsfwLevel ?? 0, sr.nsfwLevel);
const metrics = imageMetrics[sr.id];
// Three-state chip signal on the feed-modal path:
// - linked model3d (any source) → gated number | null (no leak)
// - Meili doc, no link → null (resolved-absent → chip renders
// nothing AND does NOT fall back, the
// durable elimination of getByPostId)
// 🔴 That `null` is only correct while ONE backend serves this path. A
// second source that does not index `model3dId` needs its own arm
// returning `undefined`, or its chips silently stop appearing.
const rawModel3dId = (sr as { model3dId?: number }).model3dId;
const model3dId =
typeof rawModel3dId === 'number'
? visibleIndexModel3DIds?.has(rawModel3dId)
? rawModel3dId
: null
: null;
return {
...sr,
model3dId,
// Override tagIds from authoritative cache when available.
// This ensures client-side hidden-tag filtering works even when
// the search engine doesn't return tagIds.
tagIds: tagIdsVar?.[sr.id]?.tags ?? sr.tagIds,
modelVersionId: sr.postedToId,
type: sr.type as MediaType,
createdAt: sr.sortAt,
metadata: { ...metadata, width: sr.width ?? 0, height: sr.height ?? 0 },
publishedAt: publishedAtUnix ? sr.sortAt : undefined,
//
user: {
id: sr.userId,
username: thisUser.username,
image: thisUser.image,
deletedAt: thisUser.deletedAt,
cosmetics: userCosmetics?.[sr.userId] ?? [],
profilePicture: profilePictures?.[sr.userId] ?? null,
},
stats: {
likeCountAllTime: metrics?.reactionLike ?? 0,
laughCountAllTime: metrics?.reactionLaugh ?? 0,
heartCountAllTime: metrics?.reactionHeart ?? 0,
cryCountAllTime: metrics?.reactionCry ?? 0,
commentCountAllTime: metrics?.comment ?? 0,
collectedCountAllTime: metrics?.collection ?? 0,
tippedAmountCountAllTime: metrics?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
reactions,
cosmetic: imageCosmetics?.[sr.id] ?? null,
// TODO fix below
availability: Availability.Public,
tags: tagsByImageId?.get(sr.id) ?? [],
name: null, // leave
scannedAt: null, // remove
mimeType: null, // need?
ingestion:
nsfwLevel === NsfwLevel.Blocked
? ImageIngestionStatus.Blocked
: nsfwLevel === 0
? ImageIngestionStatus.NotFound
: ImageIngestionStatus.Scanned, // add? maybe remove
postTitle: null, // remove
meta,
nsfwLevel,
thumbnailUrl: thumbnail?.url,
};
})
);
// For single-post queries, re-sort by image index to preserve manual ordering.
// Search engines sort by sortAt/reactions, but posts need index-based ordering.
if (input.postId && !input.modelVersionId && mergedData.length > 1) {
const imageIds = mergedData.map((d) => d.id);
const indexData = await dbRead.$queryRaw<{ id: number; index: number }[]>`
SELECT id, COALESCE(index, 0) as index FROM "Image" WHERE id IN (${Prisma.join(imageIds)})
`;
const indexMap = new Map(indexData.map((d) => [d.id, d.index]));
mergedData.sort((a, b) => (indexMap.get(a.id) ?? 0) - (indexMap.get(b.id) ?? 0));
}
let nextCursor: string | undefined;
if (typeof searchNextCursor === 'string') nextCursor = searchNextCursor;
else if (searchNextCursor) nextCursor = `${offset + input.limit}|${searchNextCursor}`;
return {
nextCursor,
// Always-on wire trim on the DOMINANT tRPC Meili feed path: the item
// literal above emits `scannedAt`/`mimeType`/`postTitle` as explicit `null`
// props (plus any of IMAGE_INFINITE_DROPPED_FIELDS carried on `...sr`), which
// still SERIALIZE even though the return type is narrowed to `Omit<...>` (a
// `const` object literal → no excess-property check strips them). Map through
// `stripImageForInfiniteWire` so they are actually removed from the payload,
// matching the DB `getAllImages` path. See `~/server/utils/image-infinite-wire.ts`.
items: mergedData.map(stripImageForInfiniteWire),
...(searchSource && { source: searchSource }),
};
};
const METRICS_SEARCH_INDEX = `${METRICS_IMAGES_SEARCH_INDEX}`;
function strArray(arr: (string | number)[]) {
return arr.map((x) => `'${x}'`).join(',');
}
function snapToInterval(unixTimestamp: number, intervalMillisec = 60000): number {
return Math.floor(unixTimestamp / intervalMillisec) * intervalMillisec;
}
type MeiliImageFilter = `${MetricsImageFilterableAttribute} ${string}`;
export const makeMeiliImageSearchFilter = (
field: MetricsImageFilterableAttribute,
criteria: string
): MeiliImageFilter => {
return `${field} ${criteria}`;
};
/**
* Owner carve-out for a `scheduled` request: own content whose publish date is
* still in the future.
*
* The publish predicate is the whole point. A bare `userId = me` admits every
* unpublished row the caller owns — drafts, bounty entry uploads, orphans — so
* turning Scheduled on filled a creator's own profile with work they never
* posted (ClickUp 868kt9y1w). Shared rather than inlined because all three
* call sites had their own copy and only one of them would have been fixed.
*/
const makeOwnScheduledMeiliFilter = (currentUserId: number, snappedNow: number) =>
`(${makeMeiliImageSearchFilter('userId', `= ${currentUserId}`)} AND ${makeMeiliImageSearchFilter(
'publishedAtUnix',
`> ${snappedNow}`
)})`;
type MeiliImageSort = `${MetricsImageSortableAttribute}:${'asc' | 'desc'}`;
export const makeMeiliImageSearchSort = (
field: MetricsImageSortableAttribute,
criteria: 'asc' | 'desc'
): MeiliImageSort => {
return `${field}:${criteria}`;
};
type ImageSearchInput = GetInfiniteImagesOutput & {
useCombinedNsfwLevel?: boolean;
domain?: DomainColor;
currentUserId?: number;
isModerator?: boolean;
offset?: number;
entry?: number;
blockedFor?: string[];
signal?: AbortSignal;
actor?: string;
// Unhandled
//prioritizedUserIds?: number[];
//userIds?: number | number[];
//modelId?: number;
//reviewId?: number;
};
/**
* Strip the session user off a search input before it reaches a log sink.
*
* `getInfiniteImagesHandler` spreads the whole `ctx.user` into the search input
* for business logic, so logging the input verbatim shipped `email`,
* `emailVerified`, `username` and `createdAt` for every erroring search — 331k
* records/day in production, each naming a real account.
*
* Nothing diagnostic is lost: of the session user, `getAllImagesIndex` forwards
* exactly `currentUserId` (= `user?.id`) and `isModerator` into this function as
* separate top-level keys, and both survive redaction untouched. (It also reads
* `user?.username` for `enforceBlockedBrowsingTags`, but that is consumed in the
* caller and never reaches this input — so the redacted payload still carries
* every session-user field the search path actually had.)
*
* The user object is dropped WHOLE rather than having its known PII keys
* deleted. A denylist fails open — the next field added to the session user
* would silently start shipping to logs again, which is exactly how this
* regressed. Do not "improve" this by re-adding `user` minus some keys.
*
* `user.id` is deliberately NOT remapped onto a `userId` key: `userId` is
* already a *search filter* on this input (feed-by-creator), and overwriting it
* would corrupt the logged query.
*/
export function redactSearchInputForLog<T extends Record<string, unknown>>(input: T) {
const { user: _sessionUser, ...rest } = input as T & { user?: unknown };
return removeEmpty(rest);
}
/**
* Per-request tally, created by the caller and threaded through.
*
* NOT module-level. This process serves feed requests concurrently and every
* `await` in the page loop yields to another one, so a module-level counter
* would be reset and incremented by interleaving requests — request A logging
* request B's drops. A number whose value cannot be attributed to a request is
* not a measurement, and this counter exists precisely to attribute something.
*/
export async function getImagesFromSearch(input: ImageSearchInput) {
const started = Date.now();
try {
const result = await searchImages(input);
const outcome = {
source: 'getImagesFromSearch' as const,
filterMode: result.filterMode,
elapsedMs: Date.now() - started,
resultIds: result.data.map((i: { id: number }) => i.id),
nextCursor: result.nextCursor,
};
void feedRequestCapture().record(input, outcome);
void feedShadow().compare(input, outcome);
return result;
} catch (err) {
void feedRequestCapture().record(input, {
source: 'getImagesFromSearch',
error: true,
elapsedMs: Date.now() - started,
resultIds: [],
});
throw err;
}
}
async function searchImages(input: ImageSearchInput) {
if (!metricsSearchClient)
return {
data: [],
nextCursor: undefined,
source: 'meili' as const,
filterMode: 'none' as const,
};
let searchFn = getImagesFromSearchPreFilter;
let filterMode: 'pre' | 'post' = 'pre';
// Wrap Flipt feature-flag evaluation so the trace shows whether per-request
// flag fetch is contributing to the parent span's latency. Routes through
// getFliptBoolean instead of direct per-request wasm evaluateBoolean calls on
// this hot feed path — once the Flipt eval cache (PR #2394) lands these become
// memoized; today it's a behavior-preserving refactor. getFliptBoolean returns
// false on a missing/uninitialized client, matching the prior null-client
// fallthrough (flags default off → pre-filter).
input = await withSpan('image:flipt:eval', async () => {
const entityId = input.currentUserId?.toString() || 'anonymous';
const postFilter = await getFliptBoolean(FLIPT_FEATURE_FLAGS.FEED_POST_FILTER, entityId);
if (postFilter) {
searchFn = getImagesFromSearchPostFilter;
filterMode = 'post';
}
return input;
});
const result = await searchFn(input);
return { ...result, source: 'meili' as const, filterMode };
}
// No applyHideChallengesExclusion here: `hideChallenges` cannot reach this function. Its only
// upstreams are /api/v1/images and /api/v1/blocks/images, whose zod objects declare neither
// `hideChallenges` nor `excludedTagIds` and strip unknown keys. Adding either key to those
// schemas would hand the REST API an unfiltered feed — apply the exclusion here first. Note
// image-search.service.ts spreads the same `data` into both branches (getAllImages / here),
// so the result wouldn't be uniformly unfiltered: one branch would filter and this one
// wouldn't, which reads as a caching bug, not a gap.
export async function getImagesFromFeedSearch(
input: ImageSearchInput
): Promise<GetAllImagesIndexResult> {
// The fifth filter builder, and the one with no hub clause. Unreachable today
// only because the REST zod for /api/v1/images does not declare `hubId` and
// strips unknown keys — the same accident the hideChallenges comment above
// describes. Adding the key to that schema without a clause here would serve an
// unfiltered feed from one of three branches, so fail loudly instead.
if (input.hubId)
throw throwInternalServerError(
new Error('getImagesFromFeedSearch cannot serve a hub; hub queries must use the index path')
);
try {
const blockedEnforcement = await enforceBlockedBrowsingTags(input, {
id: input.currentUserId,
isModerator: input.isModerator,
});
if (blockedEnforcement.emptyResult) return { nextCursor: undefined, items: [] };
// Evaluate feature flags before creating feed. Routed through getFliptBoolean
// (memoized once PR #2394's eval cache lands) instead of a direct per-request
// wasm eval; it swallows errors and returns false on a missing/uninitialized
// client, preserving the prior fail-safe default (existence check off).
const enableExistenceCheck = await getFliptBoolean(
FLIPT_FEATURE_FLAGS.FEED_IMAGE_EXISTENCE,
input.currentUserId?.toString() || 'anonymous'
);
const feed = new ImagesFeed(
({ apiKey, host }: { apiKey: string; host: string }) => {
const client = new MeiliSearch({
host,
apiKey,
requestConfig: input.actor
? { headers: { [SEARCH_ACTOR_HEADER]: input.actor } }
: undefined,
});
// Wrap the returned IMeilisearch so that only the SDK calls inside
// event-engine-common's queryDocuments / populate go through
// withMeili('search'). Without this narrow scope, the previous wrap
// covered ALL of populatedQuery() — including Postgres / ClickHouse /
// Redis work in populateDocuments — which (a) falsely attributed
// slow DB queries as Meili timeouts and (b) held a Meili semaphore
// slot during non-Meili work, starving real Meili callers.
//
// NOTE: This is defense-in-depth. Verified hot path on 2026-05-29
// is getImagesFromSearch (above), not this feed path (REST-only,
// 0 errors/15min). Keeping this wrap so a future traffic-shift
// doesn't expose us again.
return wrapMeilisearchClientWithLimiter(client) as IMeilisearch;
},
clickhouse as IClickhouseClient,
pgDbWrite as IDbClient,
new MetricService(clickhouse as IClickhouseClient, redis as unknown as IRedisClient),
new CacheService(
redis as unknown as IRedisClient,
pgDbWrite as IDbClient,
clickhouse as IClickhouseClient,
undefined,
// Back the feed's image→tagIds lookup with civitai's own warm, actively-invalidated
// tagIdsForImagesCache (msgpack string) instead of the retired event-engine-common
// `image:tagIds` Redis hash. Same {imageId, tags[]} shape + same WD14/Rekognition +
// styleTags/subjectTags filter, so it's a drop-in. Relieves next-redis-cluster memory.
(ids: number[]) =>
tagIdsForImagesCache.fetch(ids) as Promise<
Record<number, { imageId: number; tags: number[] }>
>
)
);
// Convert cursor to string if it's not already, and add feature flag result
const feedInput = {
...input,
cursor: input.cursor ? String(input.cursor) : undefined,
enableExistenceCheck,
};
// No outer withMeili() here — the wrap now lives on the SDK calls inside
// the client wrapper above. populatedQuery() does DB+CH+Redis work that
// should NOT consume a Meili semaphore slot.
const feedResult = await feed.populatedQuery(feedInput as FeedQueryInput<ImageQueryInput>);
// Transform PopulatedImage to match getAllImagesIndex return type
// Remove extra fields that PopulatedImage has but getAllImagesIndex doesn't
const transformedItems: ImagesInfiniteModel[] = feedResult.items.map((img) => {
// Destructure to remove all extra fields from PopulatedImage/ImageDocument
// that aren't in ImagesInfiniteModel
const {
// Timestamp unix fields (not in ImagesInfiniteModel)
sortAtUnix,
publishedAtUnix,
existedAtUnix,
// Array fields handled differently
tagIds,
toolIds,
techniqueIds,
// Flags object (not in ImagesInfiniteModel)
flags,
// NSFW fields (different handling)
aiNsfwLevel,
combinedNsfwLevel,
// Metric counts (stats object has these instead)
reactionCount,
commentCount,
collectedCount,
// Other fields not in ImagesInfiniteModel
userId,
acceptableMinor,
// Fields that need type transformation
reactions,
tags,
...rest
} = img;
// Transform tags to match VotableTagModel (add missing fields with defaults)
// Note: tag.type and tag.nsfwLevel need casting because PopulatedImage uses
// its own type definitions from event-engine-common, while VotableTagModel
// uses types from ~/server/common/enums
const transformedTags: VotableTagModel[] = tags.map((tag) => ({
id: tag.id,
name: tag.name,
type: tag.type as unknown as TagType,
nsfwLevel: tag.nsfwLevel as unknown as NsfwLevel,
score: 0,
upVotes: 0,
downVotes: 0,
}));
// Transform reactions to use ReviewReactions enum
const transformedReactions = reactions.map((r) => ({
userId: r.userId,
reaction: r.reaction as ReviewReactions,
}));
// Return structure matching getAllImagesIndex
return {
...rest,
nsfwLevel: img.nsfwLevel as NsfwLevel,
type: img.type as MediaType,
availability: img.availability ?? Availability.Public,
reactions: transformedReactions,
tags: transformedTags,
};
});
return {
nextCursor: feedResult.nextCursor,
// Mirror the DB path's wire trim so both `image.getInfinite` backends ship the
// identical narrowed shape (drops any of IMAGE_INFINITE_DROPPED_FIELDS still
// carried on the ImageDocument rest).
items: transformedItems.map(stripImageForInfiniteWire),
};
} catch (err) {
console.error('Error in getImagesFromFeedSearch:', err);
// Any genuinely-transient upstream failure → fail fast as SERVICE_UNAVAILABLE
// (HTTP 503) so the caller gets a fast, retryable response instead of
// bleeding 30s while Traefik gives up. 503 is the correct transient-brownout
// code (was TIMEOUT/408 under tRPC v10, which lacked SERVICE_UNAVAILABLE).
//
// `isTransientMeiliError` covers BOTH civitai's own wrapper errors
// (MeiliCallTimeoutError / MeilisearchFetchError, from the raw-fetch path)
// AND the meilisearch-js SDK's own error types (MeiliSearchCommunicationError /
// MeiliSearchApiError / MeiliSearchTimeOutError) that the feed library's
// INNER SDK calls throw on a slow/shed backend — the latter were the
// dominant remaining HTTP-500 source on /api/v1/images (a 408/503 from the
// proxy surfaced as a bare {"error":"Request Timeout"} / {"error":"Service
// Unavailable"} 500 because they're not TRPCErrors and fell through the
// generic 500 mapping). 4xx-other (malformed filter / auth) and any other
// Error are NOT transient and still bubble as-is (→ their real status).
const transient = isTransientMeiliError(err);
if (transient) {
// Keep a transient Meili outage ATTRIBUTABLE (see getAllImagesIndex). The
// reclassified 503 would otherwise land in the unlabeled 503 bucket;
// mirror the post-filter loop's {route, reason} so a Meili brownout is
// queryable. `iteration:'0'` — this is the single feed-search call.
meiliFetchFailfastTotal.inc({
route: 'getImagesFromFeedSearch',
iteration: '0',
reason: failfastReasonForTransientError(err),
});
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Image search is temporarily overloaded — please retry.',
cause: err as Error,
});
}
// TRANSIENT ClickHouse transport blip in the metric-enrichment leg of
// populatedQuery (the event-engine-common MetricService read). The feed items
// come from Meili+Postgres; only the display-only engagement metrics are
// ClickHouse-backed, and they ALREADY fail-open to zero elsewhere
// (getImageMetricsObject → {}). But this feed path runs the metric read INSIDE
// populatedQuery, so a CH connection error (socket hang up / Code 279 / Code
// 210) thrown there isn't a Meili error → it would fall through to `throw err`
// → the handler's generic 500. Re-map it to the same retryable 503 as a Meili
// brownout: a CH transport flap is a transient upstream brownout, not a server
// bug, and 503+Retry-After lets the client/CF retry the (seconds-long) blip
// instead of surfacing a hard 500. A CH QUERY/SCHEMA error (UNKNOWN_TABLE etc.)
// is NOT matched by isClickHouseConnectionError and still throws → 500 → visible
// + alertable, exactly as the missing-table incident was. No money/entitlement
// is on this path — image metrics are display-only.
if (isClickHouseConnectionError(err)) {
clickhouseFailSoftCounter.inc({ path: 'image-feed' });
logToAxiom(
{
type: 'warning',
name: 'clickhouse-failsoft',
message: 'ClickHouse transport error in image feed metric enrichment — served 503',
path: 'image-feed',
error: err instanceof Error ? err.message : String(err),
},
'clickhouse'
).catch();
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Image feed is temporarily overloaded — please retry.',
cause: err as Error,
});
}
throw err;
}
}
import type { ResolvedHubSources } from '~/server/services/user-hub.service';
import { hubBrowsingLevel, resolveHubSources } from '~/server/services/user-hub.service';
import { HUB_COLLECTION_SOURCES_ENABLED } from '~/server/schema/user-hub.schema';
// The OR-group a hub's sources become. Mirrors the single-`modelVersionId`
// branch below, including its two gates: a hub must honour hideAutoResources /
// hideManualResources or it silently ignores two filters the user set.
//
// Returns null when the hub resolved to nothing. Callers must return an empty
// page for null — never fall through unfiltered, which would serve the global
// feed to someone who asked for their hub.
// The two filter builders are mutually exclusive per request and each calls this
// once, so nothing here needs memoizing. Kept as a helper so both spell the hubId
// short-circuit and the argument set identically.
async function resolvedHubSources(input: ImageSearchInput) {
if (!input.hubId) return null;
return resolveHubSources({
hubId: input.hubId,
userId: input.currentUserId,
isModerator: input.isModerator,
excludedSources: input.hubExcludedSources,
});
}
type HubFilterArm = { field: MetricsImageFilterableAttribute; ids: number[] };
// The single enumeration of the arms a hub ORs together. One builder consumes it
// today (`buildHubFilter`); the split survives so a second clause syntax cannot be
// given a different set of arms.
// Returns null for "no arm", which callers must treat as "serve nothing"; treating
// it as "no filter" hands the caller the global feed as their hub.
function hubFilterArms(
sources: ResolvedHubSources,
{
hideAutoResources,
hideManualResources,
}: Pick<ImageSearchInput, 'hideAutoResources' | 'hideManualResources'>
): HubFilterArm[] | null {
const arms: HubFilterArm[] = [];
if (sources.userIds.length) arms.push({ field: 'userId', ids: sources.userIds });
if (sources.modelVersionIds.length) {
arms.push({ field: 'postedToId', ids: sources.modelVersionIds });
if (!hideAutoResources) arms.push({ field: 'modelVersionIds', ids: sources.modelVersionIds });
if (!hideManualResources)
arms.push({ field: 'modelVersionIdsManual', ids: sources.modelVersionIds });
}
// No guard, unlike `collectionIds` below: `tagIds` has been a live filterable
// attribute on the metrics index since 2024, and the ids are denormalised onto the
// documents at index time. Verified against the prod index rather than assumed —
// a tag filter returns hits where `collectionIds IN [...]` is rejected outright.
if (sources.tagIds.length) arms.push({ field: 'tagIds', ids: sources.tagIds });
// Guarded, not merely unused: filtering on an attribute the index has not been
// rebuilt with makes Meilisearch reject the entire query, which surfaces as a 503.
if (HUB_COLLECTION_SOURCES_ENABLED && sources.collectionIds.length)
arms.push({ field: 'collectionIds', ids: sources.collectionIds });
return arms.length ? arms : null;
}
/**
* The hub's keep-out group: a creator, model or version whose content the owner
* said must not appear. ANDed as a `NOT` against everything else rather than ORed
* into the source group — an exclusion that joins the OR is not an exclusion, it is
* a fifth way to be included.
*
* Returns null for "this hub excludes nothing", which is the only safe reading of
* an empty set: unlike `hubFilterArms`, a null here must NOT empty the page.
*
* The three resource arms are emitted unconditionally, where the positive builder
* gates two of them on hideAutoResources / hideManualResources. Those gates say
* which attributions the viewer wants to be COLLECTED by; they do not say the
* viewer is willing to see a model the owner refused, arriving under a different
* attribution.
*/
function buildHubExclusionFilter(sources: ResolvedHubSources): string | null {
const { userIds, modelVersionIds, tagIds } = sources.excluded;
const arms: HubFilterArm[] = [];
if (userIds.length) arms.push({ field: 'userId', ids: userIds });
if (tagIds.length) arms.push({ field: 'tagIds', ids: tagIds });
if (modelVersionIds.length) {
arms.push({ field: 'postedToId', ids: modelVersionIds });
arms.push({ field: 'modelVersionIds', ids: modelVersionIds });
arms.push({ field: 'modelVersionIdsManual', ids: modelVersionIds });
}
if (!arms.length) return null;
// Verified against the prod metrics index rather than assumed: a document whose
// `tagIds` is empty survives `NOT tagIds IN [x]`, and `NOT field IN [unused-id]`
// returns the whole set. So a NOT arm removes matches only — it does not also
// drop documents that lack the field.
return `NOT (${arms
.map((arm) => makeMeiliImageSearchFilter(arm.field, `IN [${arm.ids.join(',')}]`))
.join(' OR ')})`;
}
function buildHubFilter(
sources: ResolvedHubSources,
input: Pick<ImageSearchInput, 'hideAutoResources' | 'hideManualResources'>
): string | null {
const arms = hubFilterArms(sources, input);
if (!arms) return null;
return `(${arms
.map((arm) => makeMeiliImageSearchFilter(arm.field, `IN [${arm.ids.join(',')}]`))
.join(' OR ')})`;
}
export async function getImagesFromSearchPreFilter(input: ImageSearchInput) {
if (!metricsSearchClient) return { data: [], nextCursor: undefined };
let { postIds = [] } = input;
const {
sort,
modelVersionId,
model3dId,
types,
withMeta,
fromPlatform,
notPublished,
scheduled,
publishedOnly,
username,
tags,
tools,
techniques,
baseModels,
period,
isModerator,
currentUserId,
excludedUserIds,
hideAutoResources,
hideManualResources,
hidden,
followed,
limit = 100,
offset,
entry,
postId,
reviewId,
modelId,
prioritizedUserIds,
useCombinedNsfwLevel,
remixOfId,
remixesOnly,
nonRemixesOnly,
excludedTagIds,
disablePoi,
disableMinor,
requiringMeta,
poiOnly,
minorOnly,
blockedFor,
newCreators,
hubId,
domain,
// TODO check the unused stuff in here
} = input;
let { browsingLevel, userId } = input;
const sorts: MeiliImageSort[] = [];
const filters: string[] = [];
// Only show images that belong to a post
filters.push(makeMeiliImageSearchFilter('postId', 'IS NOT NULL'));
const ownCarveOut = currentUserId ? ` OR "userId" = ${currentUserId}` : '';
if (!isModerator) {
filters.push(
// Avoids exposing private resources to the public
`((NOT availability = ${Availability.Private})${ownCarveOut})`
);
filters.push(
// Avoids blocked resources to the public
`(("blockedFor" IS NULL OR "blockedFor" NOT EXISTS)${ownCarveOut})`
);
}
if (postId) {
postIds = [...(postIds ?? []), postId];
}
if (disablePoi) {
filters.push(`(NOT poi = true${ownCarveOut})`);
}
if (disableMinor) {
filters.push(`(NOT minor = true)`);
}
if (isModerator) {
if (poiOnly) {
filters.push(`poi = true`);
}
if (minorOnly) {
filters.push(`minor = true`);
}
if (blockedFor?.length) {
filters.push(`blockedFor IN [${strArray(blockedFor)}]`);
}
}
// Filter
//------------------------
if (hidden) {
if (!currentUserId) throw throwAuthorizationError();
const hiddenImages = await dbRead.imageEngagement.findMany({
where: { userId: currentUserId, type: 'Hide' },
select: { imageId: true },
});
const imageIds = hiddenImages.map((x) => x.imageId);
if (imageIds.length) {
filters.push(makeMeiliImageSearchFilter('id', `IN [${imageIds.join(',')}]`));
} else {
return { data: [], nextCursor: undefined };
}
}
if (username && !userId) {
const targetUser =
(await dbRead.user.findUnique({ where: { username }, select: { id: true } })) ??
(await dbWrite.user.findUnique({ where: { username }, select: { id: true } }));
if (!targetUser) throw throwNotFoundError('User not found');
userId = targetUser.id;
logToAxiom(
{ type: 'info', message: 'Using username instead of userId' },
'temp-search'
).catch();
}
// could throw authorization error here
if (currentUserId && followed) {
const followedUsers = await dbRead.userEngagement.findMany({
where: { userId: currentUserId, type: 'Follow' },
select: { targetUserId: true },
});
const userIds = followedUsers.map((x) => x.targetUserId);
if (userIds.length) {
filters.push(makeMeiliImageSearchFilter('userId', `IN [${userIds.join(',')}]`));
} else {
return { data: [], nextCursor: undefined };
}
}
// Creators on the "new & upcoming" board. Same shape as `followed` above, but the
// set is global per domain rather than per viewer. An unpopulated board returns
// nothing rather than degrading to the unfiltered feed.
if (newCreators) {
const newCreatorIds = await getNewCreatorUserIds({ entity: 'images', domain });
if (!newCreatorIds.length) return { data: [], nextCursor: undefined };
filters.push(makeMeiliImageSearchFilter('userId', `IN [${newCreatorIds.join(',')}]`));
}
if (hubId) {
const sources = await resolvedHubSources(input);
const hubFilter = sources && buildHubFilter(sources, input);
if (!hubFilter) return { data: [], nextCursor: undefined };
filters.push(hubFilter);
// Pushed as its own AND term, and only when there is something to exclude: an
// empty keep-out set must leave the feed alone, not empty it.
const hubExclusionFilter = buildHubExclusionFilter(sources);
if (hubExclusionFilter) filters.push(hubExclusionFilter);
// The hub's own content cap, applied before the browsing-level block below
// reads `browsingLevel`. An empty intersection is served as an empty page, not
// as the uncapped feed the block's `if (!browsingLevel)` fallback would give it.
const capped = hubBrowsingLevel(browsingLevel, sources);
if (capped !== undefined) {
if (!capped) return { data: [], nextCursor: undefined };
browsingLevel = capped;
}
}
// nb: commenting this out while we try checking existence in the db
// const lastExistedAt = await redis.get(REDIS_KEYS.INDEX_UPDATES.IMAGE_METRIC);
// if (lastExistedAt) {
// filters.push(makeMeiliImageSearchFilter('existedAtUnix', `>= ${lastExistedAt}`));
// }
// NSFW Level
if (!browsingLevel) browsingLevel = NsfwLevel.PG;
else browsingLevel = onlySelectableLevels(browsingLevel);
const browsingLevels = Flags.instanceToArray(browsingLevel);
const includesNsfwContent = Flags.intersects(browsingLevel, nsfwBrowsingLevelsFlag);
if (isModerator && includesNsfwContent) browsingLevels.push(0);
const nsfwLevelField: MetricsImageFilterableAttribute = useCombinedNsfwLevel
? 'combinedNsfwLevel'
: 'nsfwLevel';
const nsfwFilters = [
makeMeiliImageSearchFilter(nsfwLevelField, `IN [${browsingLevels.join(',')}]`) as string,
];
// The caller's own unscanned uploads. Added only when there IS a caller: with no
// `userId` clause to pair it with, this arm is every unscanned image on the site,
// ORed past the browsing level, for anyone logged out. Signed-in behaviour is
// unchanged.
if (currentUserId) {
const nsfwUserFilters = [
makeMeiliImageSearchFilter(nsfwLevelField, `= 0`),
makeMeiliImageSearchFilter('userId', `= ${currentUserId}`),
];
nsfwFilters.push(`(${nsfwUserFilters.join(' AND ')})`);
}
filters.push(`(${nsfwFilters.join(' OR ')})`);
// NSFW License Restrictions Filter
// Filter out images with R/X/XXX NSFW levels that use restricted base models.
if (nsfwRestrictedBaseModels.length > 0) {
const restrictedBaseModelsQuoted = nsfwRestrictedBaseModels.map((bm) => `'${bm}'`);
// Exclude images that have BOTH restricted NSFW levels AND restricted base models
filters.push(
`NOT (${nsfwLevelField} IN [${nsfwBrowsingLevelsArray.join(
','
)}] AND baseModel IN [${restrictedBaseModelsQuoted.join(',')}])`
);
}
if (modelVersionId) {
const versionFilters = [makeMeiliImageSearchFilter('postedToId', `= ${modelVersionId}`)];
if (!hideAutoResources) {
versionFilters.push(makeMeiliImageSearchFilter('modelVersionIds', `IN [${modelVersionId}]`));
}
if (!hideManualResources) {
versionFilters.push(
makeMeiliImageSearchFilter('modelVersionIdsManual', `IN [${modelVersionId}]`)
);
}
filters.push(`(${versionFilters.join(' OR ')})`);
}
// Model3D gallery filter — `model3dId` is the index analog of `postedToId`
// (set at index time from `Post.model3dId`). Lets the 3D-model detail page
// serve its gallery from Meilisearch instead of the DB feed path, which
// was tripping the 20s ceiling on `getAllImages` for model3d queries.
if (model3dId) {
filters.push(makeMeiliImageSearchFilter('model3dId', `= ${model3dId}`));
}
if (remixOfId) {
filters.push(makeMeiliImageSearchFilter('remixOfId', `= ${remixOfId}`));
}
if (remixesOnly && !nonRemixesOnly) {
filters.push(makeMeiliImageSearchFilter('remixOfId', '>= 0'));
}
if (nonRemixesOnly) {
filters.push(makeMeiliImageSearchFilter('remixOfId', 'NOT EXISTS'));
}
if (excludedTagIds?.length) {
// Needed support for this in order to properly support multiple domains.
filters.push(makeMeiliImageSearchFilter('tagIds', `NOT IN [${excludedTagIds.join(',')}]`));
}
/*
// TODO this won't work, can't do custom sort
if (prioritizedUserIds?.length) {
// why do this?
// if (cursor) throw new Error('Cannot use cursor with prioritizedUserIds');
// If system user, show community images
if (prioritizedUserIds.length === 1 && prioritizedUserIds[0] === -1) {
sorts.push(makeMeiliImageSearchSort('index', 'asc'))
// orderBy = `IIF(i."userId" IN (${prioritizedUserIds.join(',')}), i.index, 1000), ${orderBy}`
} else {
// For everyone else, only show their images.
filters.push(makeMeiliImageSearchFilter('userId', `IN [${prioritizedUserIds.join(',')}]`));
sorts.push(makeMeiliImageSearchSort('postedToId', 'asc'));
sorts.push(makeMeiliImageSearchSort('index', 'asc'));
// orderBy = `(i."postId" * 100) + i."index"`; // Order by oldest post first
}
}
*/
if (withMeta) filters.push(makeMeiliImageSearchFilter('hasMeta', '= true'));
if (requiringMeta) {
filters.push(`("blockedFor" = ${BlockedReason.AiNotVerified})`);
}
if (fromPlatform) filters.push(makeMeiliImageSearchFilter('onSite', '= true'));
const snappedNow = snapToInterval(Math.round(Date.now()));
// Hoisted above the moderator branch so a creator browsing their OWN profile
// reaches it too. `canRequestUnpublished` is what keeps that safe: it refuses
// unless the request is already scoped to the caller, so a non-moderator
// cannot ask this question about the site at large. Moderator behaviour is
// unchanged — they still answer true for any creator.
if (notPublished && canRequestUnpublished({ isModerator, currentUserId, targetUserId: userId })) {
filters.push(makeMeiliImageSearchFilter('publishedAtUnix', 'NOT EXISTS'));
} else if (isModerator) {
if (scheduled) filters.push(makeMeiliImageSearchFilter('publishedAtUnix', `> ${snappedNow}`));
else {
const publishedFilters = [makeMeiliImageSearchFilter('publishedAtUnix', `<= ${snappedNow}`)];
// `publishedOnly` is the caller saying it cannot use an unpublished row at
// all, so the moderator's own-content carve-out is lifted too. Without
// this a moderator got their own drafts in a picker whose mutation
// refuses them, and no other caller could opt out of that.
if (currentUserId && !publishedOnly) {
publishedFilters.push(makeMeiliImageSearchFilter('userId', `= ${currentUserId}`));
}
filters.push(`(${publishedFilters.join(' OR ')})`);
}
} else {
// Non-mod path. By default, strict published-only — owners no longer see
// their own scheduled/unpublished content pinned to feeds. The `scheduled`
// flag is opt-in: when set, OR-in the owner carve-out so the user-own
// second pass surfaces own scheduled hits.
const publishedFilters: string[] = [
makeMeiliImageSearchFilter('publishedAtUnix', `<= ${snappedNow}`),
];
if (currentUserId && scheduled) {
publishedFilters.push(makeOwnScheduledMeiliFilter(currentUserId, snappedNow));
}
filters.push(`(${publishedFilters.join(' OR ')})`);
}
if (types?.length) filters.push(makeMeiliImageSearchFilter('type', `IN [${types.join(',')}]`));
if (tags?.length) filters.push(makeMeiliImageSearchFilter('tagIds', `IN [${tags.join(',')}]`));
if (tools?.length) filters.push(makeMeiliImageSearchFilter('toolIds', `IN [${tools.join(',')}]`));
if (techniques?.length)
filters.push(makeMeiliImageSearchFilter('techniqueIds', `IN [${techniques.join(',')}]`));
if (postIds?.length)
filters.push(makeMeiliImageSearchFilter('postId', `IN [${postIds.join(',')}]`));
if (baseModels?.length)
filters.push(makeMeiliImageSearchFilter('baseModel', `IN [${strArray(baseModels)}]`));
// TODO why were we doing this at all?
// if (userIds) {
// userIds = Array.isArray(userIds) ? userIds : [userIds];
// filters.push(makeMeiliImageSearchFilter('userId', `IN [${userIds.join(',')}]`));
// }
if (userId) filters.push(makeMeiliImageSearchFilter('userId', `= ${userId}`));
else if (excludedUserIds)
filters.push(makeMeiliImageSearchFilter('userId', `NOT IN [${excludedUserIds.join(',')}]`));
// TODO.metricSearch if reviewId, get corresponding userId instead and add to userIds before making this request
// how?
// if (reviewId) {}
// Handle period filter
let afterDate: Date | undefined;
if (period && period !== 'AllTime') {
const now = dayjs();
afterDate = now.subtract(1, period.toLowerCase() as ManipulateType).toDate();
}
if (afterDate) {
// convert to minutes for better caching
filters.push(
makeMeiliImageSearchFilter(
'sortAtUnix',
`> ${snapToInterval(Math.round(afterDate.getTime()))}`
)
);
}
// nb: this is for dev 08-19
// if (!isProd) {
// filters.push(makeMeiliImageSearchFilter('id', '<= 25147444'));
// }
// TODO log more of these
// Log properties we don't support yet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cantProcess: Record<string, any> = {
reviewId,
modelId,
prioritizedUserIds,
};
if (reviewId || modelId || prioritizedUserIds) {
const missingKeys = Object.keys(cantProcess).filter((key) => cantProcess[key] !== undefined);
logToAxiom({ type: 'info', input: JSON.stringify(missingKeys) }, 'temp-search').catch();
}
// Sort
//------------------------
let searchSort: MeiliImageSort;
if (sort === ImageSort.MostComments) {
searchSort = makeMeiliImageSearchSort('commentCount', 'desc');
} else if (sort === ImageSort.MostReactions) {
searchSort = makeMeiliImageSearchSort('reactionCount', 'desc');
} else if (sort === ImageSort.MostCollected) {
searchSort = makeMeiliImageSearchSort('collectedCount', 'desc');
} else if (sort === ImageSort.Oldest) {
searchSort = makeMeiliImageSearchSort('sortAt', 'asc');
} else {
searchSort = makeMeiliImageSearchSort('sortAt', 'desc');
// - to avoid dupes (for any ascending query), we need to filter on that attribute
if (entry) {
// Note: this could cause posts to be missed/included in multiple pages due to the minute rounding
filters.push(
makeMeiliImageSearchFilter('sortAtUnix', `<= ${snapToInterval(Math.round(entry))}`)
);
}
}
sorts.push(searchSort);
//sorts.push(makeMeiliImageSearchSort('id', 'desc')); // secondary sort for consistency
const request: SearchParams = {
filter: filters.join(' AND '),
sort: sorts,
limit: limit + 1,
offset,
};
const route = 'getImagesFromSearch';
const endTimer = requestDurationSeconds.startTimer({ route });
requestTotal.inc({ route }); // count every request up front
try {
const actor = input.actor;
// Always use the abortable raw fetch path. Two reasons:
// 1. Client disconnect: a caller-supplied `input.signal` still cancels
// the underlying request as before.
// 2. Hard local deadline: fetchDocumentsAbortable() now races against a
// 5s default timer (FETCH_DOCUMENTS_DEFAULT_TIMEOUT_MS). Before this,
// the non-signal branch fell through to the SDK getDocuments, which
// meilisearch-js 0.33/0.34 does NOT cancel — the only protection was
// withMeili()'s 2.5s wrapper timer, with the orphan SDK promise still
// running. Routing everything through fetchDocumentsAbortable gives
// us one code path with a real abort, regardless of whether the
// caller passed a signal.
//
// This is the hot tRPC path
// (image.getInfinite → getInfiniteImagesHandler → getAllImagesIndex →
// this prefilter). Verification on 2026-05-29 showed this is where the
// 374-error/15min Meili bleed lives, NOT in getImagesFromFeedSearch.
const mainResult = await withSpan('image:meili:getDocuments', () =>
fetchDocumentsAbortable<ImageMetricsSearchIndexRecord>(METRICS_SEARCH_INDEX, request, {
host: env.METRICS_SEARCH_HOST as string,
apiKey: env.METRICS_SEARCH_API_KEY,
signal: input.signal,
actor,
})
);
let results = mainResult.results;
let nextCursor: number | undefined;
if (results.length > limit) {
results.pop();
// - if we have no entrypoint, it's the first request, and set one for the future
// else keep it the same
nextCursor = !entry ? results[0]?.sortAtUnix : entry;
}
const filteredHits = results.filter((hit) => {
if (!hit.url)
// check for good data
return false;
// filter out items flagged with minor unless it's the owner or moderator
if (hit.acceptableMinor) return hit.userId === currentUserId || isModerator;
// filter out non-scanned unless it's the owner or moderator
if (![0, NsfwLevel.Blocked].includes(hit.nsfwLevel) && !hit.needsReview) return true;
return hit.userId === currentUserId || (isModerator && includesNsfwContent);
});
// Get all image IDs from search results
const searchImageIds = filteredHits.map((hit) => hit.id);
const filteredHitIds = [...new Set(searchImageIds)];
// Routed through getFliptBoolean (memoized once PR #2394's eval cache lands)
// instead of a direct per-request wasm eval; returns false on a missing/
// uninitialized client (existence check off), matching the prior default.
const cacheExistenceEnabled = await getFliptBoolean(
FLIPT_FEATURE_FLAGS.FEED_IMAGE_EXISTENCE,
currentUserId?.toString() || 'anonymous'
);
ffRequestsTotal.inc({ route, enabled: String(cacheExistenceEnabled) });
if (!cacheExistenceEnabled) {
cacheHitRequestsTotal.inc({ route, hit_type: 'miss' });
// BASIC DB CHECK (default)
const dbIdResp = await dbRead.image.findMany({
where: { id: { in: filteredHitIds } },
select: { id: true },
});
const idSet = new Set(dbIdResp.map((r) => r.id));
const filtered = results.filter((h) => idSet.has(h.id));
const droppedCount = results.length - filtered.length;
droppedIdsTotal.inc({ route, hit_type: 'miss' }, droppedCount);
const imageMetrics = await getImageMetricsObject(filtered);
const fullData = filtered.map((h) => {
const match = imageMetrics[h.id];
return {
...h,
stats: {
likeCountAllTime: match?.reactionLike ?? 0,
laughCountAllTime: match?.reactionLaugh ?? 0,
heartCountAllTime: match?.reactionHeart ?? 0,
cryCountAllTime: match?.reactionCry ?? 0,
commentCountAllTime: match?.comment ?? 0,
collectedCountAllTime: match?.collection ?? 0,
tippedAmountCountAllTime: match?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
};
});
endTimer();
return { data: fullData, nextCursor };
}
// ===== SMART CACHE EXISTENCE CHECK (feature-flagged) =====
const checkImageExistence = async (imageIds: number[]) => {
// Preserve original order and remove duplicates
const uniqueIds = [...new Set(imageIds)];
const cachePrefix = `${REDIS_SYS_KEYS.CACHES.IMAGE_EXISTS}:`;
const cacheKeys = uniqueIds.map((id) => `${cachePrefix}${id}` as RedisKeyTemplateSys);
// Check cached results first (10 minute TTL — see EX: 600 below). Fail open: image feed
// is the highest-traffic endpoint, a sysRedis outage shouldn't 500
// it. Treat a throw as full cache miss (everything falls through
// to DB — slower but correct).
let cachedResults: (string | null)[];
try {
// Wall-clock deadline so a silent sysRedis half-open can't park this
// highest-traffic read ~11min (a fast DOWN already rejects into catch).
cachedResults = await withSysReadDeadline(sysRedis.packed.mGet(cacheKeys));
} catch (err) {
logSysRedisFailOpen('read-degraded', 'checkImageExistence mGet', err);
cachedResults = new Array(uniqueIds.length).fill(null);
}
// Separate cached and uncached IDs
const uncachedIds: number[] = [];
const cachedMap = new Map<number, boolean>();
let cacheMiss = 0;
for (let i = 0; i < uniqueIds.length; i++) {
const id = uniqueIds[i];
const cachedResult = cachedResults[i];
if (cachedResult === 'true') {
cachedMap.set(id, true);
} else if (cachedResult === 'false') {
cachedMap.set(id, false);
} else {
uncachedIds.push(id);
cacheMiss++;
}
}
let hitType: 'full' | 'partial' | 'miss';
if (cacheMiss === 0) {
hitType = 'full';
} else if (cacheMiss === uniqueIds.length) {
hitType = 'miss';
} else {
hitType = 'partial';
}
cacheHitRequestsTotal.inc({ route, hit_type: hitType });
// Query DB for uncached IDs
if (uncachedIds.length > 0) {
const dbResults = await dbRead.image.findMany({
where: { id: { in: uncachedIds } },
select: { id: true },
});
const dbIdSet = new Set(dbResults.map((r) => r.id));
// Update cache with DB results (10-minute TTL, EX: 600)
const cacheUpdates: Record<string, string> = {};
for (const id of uncachedIds) {
const exists = dbIdSet.has(id);
cacheUpdates[`${cachePrefix}${id}`] = exists ? 'true' : 'false';
cachedMap.set(id, exists);
}
// Best-effort cache populate. Without this catch, the read-side
// fail-open above is defeated: every partial-cache-miss request
// during a sysRedis outage would still 500 here.
await Promise.all(
Object.entries(cacheUpdates).map(([key, value]) =>
sysRedis.packed.set(key as RedisKeyTemplateSys, value, { EX: 600 })
)
).catch((err) => {
logSysRedisFailOpen('write-degraded', 'checkImageExistence cache populate', err);
});
}
// Filter hits based on existence check while preserving order
let dropped = 0;
const filteredHits = results.filter((hit) => {
const exists = cachedMap.get(hit.id);
const keep = exists !== false; // treat undefined as exists=true
if (!keep) dropped++;
return keep;
});
droppedIdsTotal.inc({ route, hit_type: hitType }, dropped);
return filteredHits.filter((x) => imageIds.includes(x.id));
};
// Apply the (flagged) existence check
const filtered = await checkImageExistence(filteredHitIds);
const imageMetrics = await getImageMetricsObject(filtered);
const fullData = filtered.map((h) => {
const match = imageMetrics[h.id];
return {
...h,
stats: {
likeCountAllTime: match?.reactionLike ?? 0,
laughCountAllTime: match?.reactionLaugh ?? 0,
heartCountAllTime: match?.reactionHeart ?? 0,
cryCountAllTime: match?.reactionCry ?? 0,
commentCountAllTime: match?.comment ?? 0,
collectedCountAllTime: match?.collection ?? 0,
tippedAmountCountAllTime: match?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
};
});
endTimer();
return {
data: fullData,
nextCursor,
};
} catch (error) {
const err = error as Error;
logToAxiom(
{
type: 'search-error',
error: err.message,
cause: err.cause,
input: redactSearchInputForLog(input),
request,
},
'temp-search'
).catch();
endTimer();
// Let the error bubble up to tRPC. The client detects isError on the
// infinite query and renders the retry banner — same path that handles any
// other backend failure (API down, network blip, etc.).
throw err;
}
}
export async function getImagesFromSearchPostFilter(input: ImageSearchInput) {
if (!metricsSearchClient) return { data: [], nextCursor: undefined };
let { postIds = [] } = input;
const {
sort,
modelVersionId,
model3dId,
types,
withMeta,
fromPlatform,
notPublished,
scheduled,
publishedOnly,
username,
tags,
tools,
techniques,
baseModels,
period,
isModerator,
currentUserId,
excludedUserIds,
hideAutoResources,
hideManualResources,
hidden,
followed,
limit = 100,
offset,
entry,
postId,
reviewId,
modelId,
prioritizedUserIds,
useCombinedNsfwLevel,
remixOfId,
remixesOnly,
nonRemixesOnly,
excludedTagIds,
disablePoi,
disableMinor,
requiringMeta,
poiOnly,
minorOnly,
blockedFor,
// TODO check the unused stuff in here
newCreators,
hubId,
domain,
} = input;
let { browsingLevel, userId } = input;
const sorts: MeiliImageSort[] = [];
const filters: string[] = [];
// Only show images that belong to a post
filters.push(makeMeiliImageSearchFilter('postId', 'IS NOT NULL'));
if (postId) {
postIds = [...(postIds ?? []), postId];
}
// Past POI cut-off, don't even return for owners
if (disablePoi) {
filters.push(`(NOT poi = true)`);
}
if (disableMinor) {
filters.push(`(NOT minor = true)`);
}
if (isModerator) {
if (poiOnly) {
filters.push(`poi = true`);
}
if (minorOnly) {
filters.push(`minor = true`);
}
if (blockedFor?.length) {
filters.push(`blockedFor IN [${strArray(blockedFor)}]`);
}
}
// Filter
//------------------------
if (hidden) {
if (!currentUserId) throw throwAuthorizationError();
const hiddenImages = await dbRead.imageEngagement.findMany({
where: { userId: currentUserId, type: 'Hide' },
select: { imageId: true },
});
const imageIds = hiddenImages.map((x) => x.imageId);
if (imageIds.length) {
filters.push(makeMeiliImageSearchFilter('id', `IN [${imageIds.join(',')}]`));
} else {
return { data: [], nextCursor: undefined };
}
}
if (username && !userId) {
const targetUser =
(await dbRead.user.findUnique({ where: { username }, select: { id: true } })) ??
(await dbWrite.user.findUnique({ where: { username }, select: { id: true } }));
if (!targetUser) throw throwNotFoundError('User not found');
userId = targetUser.id;
logToAxiom(
{ type: 'info', message: 'Using username instead of userId' },
'temp-search'
).catch();
}
// could throw authorization error here
if (currentUserId && followed) {
const followedUsers = await dbRead.userEngagement.findMany({
where: { userId: currentUserId, type: 'Follow' },
select: { targetUserId: true },
});
const userIds = followedUsers.map((x) => x.targetUserId);
if (userIds.length) {
filters.push(makeMeiliImageSearchFilter('userId', `IN [${userIds.join(',')}]`));
} else {
return { data: [], nextCursor: undefined };
}
}
if (newCreators) {
const newCreatorIds = await getNewCreatorUserIds({ entity: 'images', domain });
if (!newCreatorIds.length) return { data: [], nextCursor: undefined };
filters.push(makeMeiliImageSearchFilter('userId', `IN [${newCreatorIds.join(',')}]`));
}
if (hubId) {
const sources = await resolvedHubSources(input);
const hubFilter = sources && buildHubFilter(sources, input);
if (!hubFilter) return { data: [], nextCursor: undefined };
filters.push(hubFilter);
// Pushed as its own AND term, and only when there is something to exclude: an
// empty keep-out set must leave the feed alone, not empty it.
const hubExclusionFilter = buildHubExclusionFilter(sources);
if (hubExclusionFilter) filters.push(hubExclusionFilter);
// The hub's own content cap, applied before the browsing-level block below
// reads `browsingLevel`. An empty intersection is served as an empty page, not
// as the uncapped feed the block's `if (!browsingLevel)` fallback would give it.
const capped = hubBrowsingLevel(browsingLevel, sources);
if (capped !== undefined) {
if (!capped) return { data: [], nextCursor: undefined };
browsingLevel = capped;
}
}
// nb: commenting this out while we try checking existence in the db
// const lastExistedAt = await redis.get(REDIS_KEYS.INDEX_UPDATES.IMAGE_METRIC);
// if (lastExistedAt) {
// filters.push(makeMeiliImageSearchFilter('existedAtUnix', `>= ${lastExistedAt}`));
// }
// NSFW Level
if (!browsingLevel) browsingLevel = NsfwLevel.PG;
else browsingLevel = onlySelectableLevels(browsingLevel);
const browsingLevels = Flags.instanceToArray(browsingLevel);
const includesNsfwContent = Flags.intersects(browsingLevel, nsfwBrowsingLevelsFlag);
if (isModerator && includesNsfwContent) browsingLevels.push(0);
const nsfwLevelField: MetricsImageFilterableAttribute = useCombinedNsfwLevel
? 'combinedNsfwLevel'
: 'nsfwLevel';
const nsfwFilters = [
makeMeiliImageSearchFilter(nsfwLevelField, `IN [${browsingLevels.join(',')}]`) as string,
];
// Allow users to see their own unscanned content on their user page.
if (currentUserId && userId === currentUserId)
nsfwFilters.push(makeMeiliImageSearchFilter(nsfwLevelField, `= 0`));
filters.push(`(${nsfwFilters.join(' OR ')})`);
// NSFW License Restrictions Filter
if (nsfwRestrictedBaseModels.length > 0) {
const restrictedBaseModelsQuoted = nsfwRestrictedBaseModels.map((bm) => `'${bm}'`);
// Exclude images that have BOTH restricted NSFW levels AND restricted base models
filters.push(
`NOT (${nsfwLevelField} IN [${nsfwBrowsingLevelsArray.join(
','
)}] AND baseModel IN [${restrictedBaseModelsQuoted.join(',')}])`
);
}
if (modelVersionId) {
const versionFilters = [makeMeiliImageSearchFilter('postedToId', `= ${modelVersionId}`)];
if (!hideAutoResources) {
versionFilters.push(makeMeiliImageSearchFilter('modelVersionIds', `IN [${modelVersionId}]`));
}
if (!hideManualResources) {
versionFilters.push(
makeMeiliImageSearchFilter('modelVersionIdsManual', `IN [${modelVersionId}]`)
);
}
filters.push(`(${versionFilters.join(' OR ')})`);
}
// Model3D gallery filter — `model3dId` is the index analog of `postedToId`
// (set at index time from `Post.model3dId`). Lets the 3D-model detail page
// serve its gallery from Meilisearch instead of the DB feed path, which
// was tripping the 20s ceiling on `getAllImages` for model3d queries.
if (model3dId) {
filters.push(makeMeiliImageSearchFilter('model3dId', `= ${model3dId}`));
}
if (remixOfId) {
filters.push(makeMeiliImageSearchFilter('remixOfId', `= ${remixOfId}`));
}
if (remixesOnly && !nonRemixesOnly) {
filters.push(makeMeiliImageSearchFilter('remixOfId', '>= 0'));
}
if (nonRemixesOnly) {
filters.push(makeMeiliImageSearchFilter('remixOfId', 'NOT EXISTS'));
}
if (excludedTagIds?.length) {
// Needed support for this in order to properly support multiple domains.
filters.push(makeMeiliImageSearchFilter('tagIds', `NOT IN [${excludedTagIds.join(',')}]`));
}
/*
// TODO this won't work, can't do custom sort
if (prioritizedUserIds?.length) {
// why do this?
// if (cursor) throw new Error('Cannot use cursor with prioritizedUserIds');
// If system user, show community images
if (prioritizedUserIds.length === 1 && prioritizedUserIds[0] === -1) {
sorts.push(makeMeiliImageSearchSort('index', 'asc'))
// orderBy = `IIF(i."userId" IN (${prioritizedUserIds.join(',')}), i.index, 1000), ${orderBy}`
} else {
// For everyone else, only show their images.
filters.push(makeMeiliImageSearchFilter('userId', `IN [${prioritizedUserIds.join(',')}]`));
sorts.push(makeMeiliImageSearchSort('postedToId', 'asc'));
sorts.push(makeMeiliImageSearchSort('index', 'asc'));
// orderBy = `(i."postId" * 100) + i."index"`; // Order by oldest post first
}
}
*/
if (withMeta) filters.push(makeMeiliImageSearchFilter('hasMeta', '= true'));
if (requiringMeta) {
filters.push(`("blockedFor" = ${BlockedReason.AiNotVerified})`);
}
if (fromPlatform) filters.push(makeMeiliImageSearchFilter('onSite', '= true'));
// Publish Date Filtering.
const snappedNow = snapToInterval(Date.now());
// Hoisted above the moderator branch so a creator browsing their OWN profile
// reaches it too — see the matching block in getImagesFromSearchPreFilter.
if (notPublished && canRequestUnpublished({ isModerator, currentUserId, targetUserId: userId })) {
filters.push(makeMeiliImageSearchFilter('publishedAtUnix', 'NOT EXISTS'));
} else if (isModerator) {
if (scheduled) filters.push(makeMeiliImageSearchFilter('publishedAtUnix', `> ${snappedNow}`));
else {
const publishedFilters = [makeMeiliImageSearchFilter('publishedAtUnix', `<= ${snappedNow}`)];
// `publishedOnly` is the caller saying it cannot use an unpublished row at
// all, so the moderator's own-content carve-out is lifted too. Without
// this a moderator got their own drafts in a picker whose mutation
// refuses them, and no other caller could opt out of that.
if (currentUserId && !publishedOnly) {
publishedFilters.push(makeMeiliImageSearchFilter('userId', `= ${currentUserId}`));
}
filters.push(`(${publishedFilters.join(' OR ')})`);
}
} else if (userId) {
const publishedFilters: string[] = [
makeMeiliImageSearchFilter('publishedAtUnix', `<= ${snappedNow}`),
];
// For own user's profile view, only surface own scheduled content when the
// caller explicitly opted in via the `scheduled` flag. Without opt-in, the
// strict published filter applies even to own profile.
if (currentUserId && userId === currentUserId && scheduled) {
publishedFilters.push(makeOwnScheduledMeiliFilter(currentUserId, snappedNow));
}
filters.push(`(${publishedFilters.join(' OR ')})`);
} else {
// General feed queries - strict published filter for caching by default.
// When `scheduled` is opt-in, OR-in the owner carve-out so own scheduled
// hits surface in the main feed.
const publishedFilters: string[] = [
makeMeiliImageSearchFilter('publishedAtUnix', `<= ${snappedNow}`),
];
if (currentUserId && scheduled) {
publishedFilters.push(makeOwnScheduledMeiliFilter(currentUserId, snappedNow));
}
filters.push(`(${publishedFilters.join(' OR ')})`);
}
if (types?.length) filters.push(makeMeiliImageSearchFilter('type', `IN [${types.join(',')}]`));
if (tags?.length) filters.push(makeMeiliImageSearchFilter('tagIds', `IN [${tags.join(',')}]`));
if (tools?.length) filters.push(makeMeiliImageSearchFilter('toolIds', `IN [${tools.join(',')}]`));
if (techniques?.length)
filters.push(makeMeiliImageSearchFilter('techniqueIds', `IN [${techniques.join(',')}]`));
if (postIds?.length)
filters.push(makeMeiliImageSearchFilter('postId', `IN [${postIds.join(',')}]`));
if (baseModels?.length)
filters.push(makeMeiliImageSearchFilter('baseModel', `IN [${strArray(baseModels)}]`));
// TODO why were we doing this at all?
// if (userIds) {
// userIds = Array.isArray(userIds) ? userIds : [userIds];
// filters.push(makeMeiliImageSearchFilter('userId', `IN [${userIds.join(',')}]`));
// }
if (userId) filters.push(makeMeiliImageSearchFilter('userId', `= ${userId}`));
else if (excludedUserIds)
filters.push(makeMeiliImageSearchFilter('userId', `NOT IN [${excludedUserIds.join(',')}]`));
// TODO.metricSearch if reviewId, get corresponding userId instead and add to userIds before making this request
// how?
// if (reviewId) {}
// Handle period filter
let afterDate: Date | undefined;
if (period && period !== 'AllTime') {
const now = dayjs();
afterDate = now.subtract(1, period.toLowerCase() as ManipulateType).toDate();
}
if (afterDate) {
// convert to minutes for better caching
filters.push(
makeMeiliImageSearchFilter('sortAtUnix', `> ${snapToInterval(afterDate.getTime())}`)
);
}
// nb: this is for dev 08-19
// if (!isProd) {
// filters.push(makeMeiliImageSearchFilter('id', '<= 25147444'));
// }
// TODO log more of these
// Log properties we don't support yet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cantProcess: Record<string, any> = {
reviewId,
modelId,
prioritizedUserIds,
};
if (reviewId || modelId || prioritizedUserIds) {
const missingKeys = Object.keys(cantProcess).filter((key) => cantProcess[key] !== undefined);
logToAxiom({ type: 'info', input: JSON.stringify(missingKeys) }, 'temp-search').catch();
}
// Sort
//------------------------
let searchSort: MeiliImageSort;
if (sort === ImageSort.MostComments) {
searchSort = makeMeiliImageSearchSort('commentCount', 'desc');
} else if (sort === ImageSort.MostReactions) {
searchSort = makeMeiliImageSearchSort('reactionCount', 'desc');
} else if (sort === ImageSort.MostCollected) {
searchSort = makeMeiliImageSearchSort('collectedCount', 'desc');
} else if (sort === ImageSort.Oldest) {
searchSort = makeMeiliImageSearchSort('sortAt', 'asc');
} else {
searchSort = makeMeiliImageSearchSort('sortAt', 'desc');
}
sorts.push(searchSort);
//sorts.push(makeMeiliImageSearchSort('id', 'desc')); // secondary sort for consistency
const route = 'getImagesFromSearch';
const endTimer = requestDurationSeconds.startTimer({ route });
requestTotal.inc({ route }); // count every request up front
// Iterative fetching with adaptive batch sizing to handle post-filtering
const MAX_ITERATIONS = 5;
const MAX_TOTAL_PROCESSED = limit * 20; // Safety limit to prevent excessive processing
const MIN_BATCH_SIZE = limit * 2;
const MAX_BATCH_SIZE = limit * 10;
const accumulatedHits: ImageMetricsSearchIndexRecord[] = [];
let currentOffset = offset || 0;
let batchSize = MIN_BATCH_SIZE;
let iteration = 0;
let totalProcessed = 0;
let consecutiveEmptyBatches = 0;
let nextCursor: number | undefined;
// Set true when the iteration loop breaks because the upstream signalled
// unavailability (local deadline, 408/5xx, or circuit-open). Used after the
// loop to distinguish "feed is genuinely empty" from "we returned nothing
// because Meili was unhealthy" — the latter must surface as a retryable
// TIMEOUT, not a silent empty 200 the client renders as end-of-feed.
let brokeOnUpstreamFailure = false;
const request: SearchParams = {
filter: filters.join(' AND '),
sort: sorts,
};
const actor = input.actor;
// (No actorClient pinning needed here: the iteration loop now runs through
// fetchDocumentsAbortable, which propagates `actor` via the X-Search-Actor
// header on every request directly.)
try {
while (accumulatedHits.length < limit + 1 && iteration < MAX_ITERATIONS) {
// Safety check for total processed results
if (totalProcessed >= MAX_TOTAL_PROCESSED) {
break;
}
const requestLimit = Math.min(batchSize, MAX_TOTAL_PROCESSED - totalProcessed);
request.limit = requestLimit;
request.offset = currentOffset;
// Always use the abortable raw fetch path so every iteration races
// against the same 5s hard deadline (FETCH_DOCUMENTS_DEFAULT_TIMEOUT_MS).
// See PreFilter path for the rationale — meilisearch-js 0.33/0.34
// getDocuments() can't be cancelled, so signal-less requests used to
// hang the event loop on a slow backend.
//
// Graceful break-on-fast-fail: if a single iteration hits the local
// deadline OR the upstream signals unavailability (408/5xx), return
// whatever's already accumulated rather than failing the whole request.
// iteration 1 → empty page; iteration ≥ 2 → degraded (partial) page
// with nextCursor cleared further below. Downstream handles either
// shape fine; the alternative (throwing) would hand the user a 5xx
// instead of a usable feed, and clients would retry → load amplifies
// → cascade sustains.
//
// Status-code rationale:
// - 408 (upstream-timeout) → Meilisearch backend page-cache thrash
// - 503 (upstream-overload) → the feeds proxy shed (MEILI_MAX_CONCURRENT)
// - other 5xx → upstream brownout / Traefik 504 / etc.
// - 4xx-other (400/401/403) → real client error, MUST bubble up
let results: ImageMetricsSearchIndexRecord[];
try {
const fetchResult = await fetchDocumentsAbortable<ImageMetricsSearchIndexRecord>(
METRICS_SEARCH_INDEX,
request,
{
host: env.METRICS_SEARCH_HOST as string,
apiKey: env.METRICS_SEARCH_API_KEY,
signal: input.signal,
actor,
}
);
results = fetchResult.results;
} catch (e) {
const err = e as Error & { name?: string; message?: string };
const isLocalTimeout =
err?.message === FETCH_DOCUMENTS_TIMEOUT_MESSAGE ||
(err?.name === 'AbortError' &&
(err as { cause?: { message?: string } })?.cause?.message ===
FETCH_DOCUMENTS_TIMEOUT_MESSAGE);
if (isLocalTimeout) {
meiliFetchFailfastTotal.inc({
route: 'getImagesFromSearchPostFilter',
iteration: String(iteration),
reason: 'local-timeout',
});
// Fall out of the iteration loop with whatever's already in
// accumulatedHits. The nextCursor / merge logic below handles the
// partial case; the empty case is converted to a retryable TIMEOUT
// after the loop (brokeOnUpstreamFailure) so the client shows its
// retry banner instead of a silent end-of-feed.
brokeOnUpstreamFailure = true;
break;
}
// Upstream-side fast-fail: 408 (proxy/backend timeout) or 5xx
// (overload / brownout / bad gateway). Same graceful-break shape
// as the local-timeout path; 4xx-other re-throws below.
if (e instanceof MeilisearchFetchError && isFailfastStatus(e.status)) {
meiliFetchFailfastTotal.inc({
route: 'getImagesFromSearchPostFilter',
iteration: String(iteration),
reason: failfastReasonForStatus(e.status),
});
brokeOnUpstreamFailure = true;
break;
}
// Wrapper-side fail-fast: runWithLimiter said no before the request
// touched the network — either the per-backend circuit breaker is
// OPEN / HALF_OPEN-busy or the wrapper's MEILI_CALL_TIMEOUT_MS timer
// fired on an SDK call. Both surface as MeiliCallTimeoutError and
// both indicate the same operational signal: "upstream is unhealthy,
// stop iterating". Without this branch the error re-throws and the
// user gets a 5xx → retries → the 900/day api-primary restart wave
// that remained after PR #2371 closed the HTTP-status paths.
//
// Branch order matters: this comes AFTER the local-timeout and
// MeilisearchFetchError checks (which are sibling failure modes
// surfaced through fetchDocumentsAbortable) and BEFORE the generic
// re-throw, so unrelated Errors still bubble up unchanged.
if (e instanceof MeiliCallTimeoutError) {
meiliFetchFailfastTotal.inc({
route: 'getImagesFromSearchPostFilter',
iteration: String(iteration),
reason: MEILI_FETCH_FAILFAST_REASON_CIRCUIT_OPEN,
});
brokeOnUpstreamFailure = true;
break;
}
throw e;
}
// If no more results, break the loop
if (results.length === 0) {
break;
}
// Apply post-query user-specific filtering
const batchFilteredHits = results.filter((hit) => {
if (!hit.url)
// check for good data
return false;
const isOwnContent = (currentUserId && hit.userId === currentUserId) || isModerator;
// User can see their own private content
if (hit.availability === Availability.Private && !isOwnContent) return false;
// User can see their own blocked content
if (hit.blockedFor && !isOwnContent) return false;
// Own scheduled/unpublished content only passes when the caller opted
// in via `scheduled` or `notPublished`. Without opt-in, even owners
// get the strict published filter applied.
if (
(!hit.publishedAtUnix || hit.publishedAtUnix > snappedNow) &&
!(isOwnContent && (input.scheduled || input.notPublished))
)
return false;
// User can see their own unscanned content
if (hit.nsfwLevel === 0 && !isOwnContent) return false;
// filter out items flagged with minor unless it's the owner or moderator
if (hit.acceptableMinor) return isOwnContent;
// filter out non-scanned unless it's the owner or moderator
if (![0, NsfwLevel.Blocked].includes(hit.nsfwLevel) && !hit.needsReview) return true;
return isOwnContent || (isModerator && includesNsfwContent);
});
// Add filtered results to accumulated results
accumulatedHits.push(...batchFilteredHits);
// Calculate filter ratio and adjust batch size for next iteration
const filterRatio = results.length > 0 ? 1 - batchFilteredHits.length / results.length : 0;
// Track consecutive batches where everything was filtered out.
// If this happens 3 times in a row, the filter likely has too few qualifying
// results — bail out to avoid hammering Meilisearch.
if (batchFilteredHits.length === 0) {
consecutiveEmptyBatches++;
if (consecutiveEmptyBatches >= 3) break;
} else {
consecutiveEmptyBatches = 0;
}
// If more than 80% of results are filtered out, increase batch size
if (filterRatio > 0.8 && batchSize < MAX_BATCH_SIZE) {
batchSize = Math.min(Math.ceil(batchSize * 1.5), MAX_BATCH_SIZE);
}
// Update tracking variables
currentOffset += results.length;
totalProcessed += results.length;
iteration++;
// If we got fewer results than what we actually requested, we've likely hit the end
if (results.length < requestLimit) {
break;
}
}
// If the loop bailed because Meili was unhealthy AND we accumulated
// nothing, we have no usable page to serve. Returning an empty 200 here
// makes the client treat it as end-of-feed (nextCursor undefined →
// hasNextPage false → no retry), and during a brownout every feed does
// the same, so switching feeds also shows "no results". Surface a
// retryable 503 instead so the existing SearchRetryBanner kicks in.
// (A non-empty partial page still serves normally — degraded, not broken.)
if (brokeOnUpstreamFailure && accumulatedHits.length === 0) {
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Image search is temporarily overloaded — please retry.',
});
}
// Record PostFilter metrics
const overallFilterRatio = totalProcessed > 0 ? 1 - accumulatedHits.length / totalProcessed : 0;
postFilterIterations.observe({ route }, iteration);
postFilterDocsProcessed.inc({ route }, totalProcessed);
postFilterFilterRatio.observe({ route }, overallFilterRatio);
const mergedHits = accumulatedHits;
// Update nextCursor based on whether we have more results than requested
if (mergedHits.length > limit) {
// We have more results, so there's a next page
const lastResult = mergedHits[limit];
nextCursor = lastResult?.sortAtUnix || nextCursor;
} else {
// We don't have more results than requested, so no next page
nextCursor = undefined;
}
// Trim results back to requested limit after filtering
const limitedHits = mergedHits.slice(0, limit + 1);
// Get all image IDs from limited results
const searchImageIds = limitedHits.map((hit) => hit.id);
const filteredHitIds = [...new Set(searchImageIds)];
// Routed through getFliptBoolean (memoized once PR #2394's eval cache lands)
// instead of a direct per-request wasm eval; returns false on a missing/
// uninitialized client (existence check off), matching the prior default.
const cacheExistenceEnabled = await getFliptBoolean(
FLIPT_FEATURE_FLAGS.FEED_IMAGE_EXISTENCE,
currentUserId?.toString() || 'anonymous'
);
ffRequestsTotal.inc({ route, enabled: String(cacheExistenceEnabled) });
if (!cacheExistenceEnabled) {
cacheHitRequestsTotal.inc({ route, hit_type: 'miss' });
// BASIC DB CHECK (default)
const dbIdResp = await dbRead.image.findMany({
where: { id: { in: filteredHitIds } },
select: { id: true },
});
const idSet = new Set(dbIdResp.map((r) => r.id));
const filtered = limitedHits.filter((h) => idSet.has(h.id));
if (limitedHits.length > limit) {
const lastItem = filtered.pop();
nextCursor = lastItem?.sortAtUnix;
} else {
nextCursor = undefined;
}
const droppedCount = limitedHits.length - filtered.length;
droppedIdsTotal.inc({ route, hit_type: 'miss' }, droppedCount);
const imageMetrics = await getImageMetricsObject(filtered);
const fullData = filtered.map((h) => {
const match = imageMetrics[h.id];
return {
...h,
stats: {
likeCountAllTime: match?.reactionLike ?? 0,
laughCountAllTime: match?.reactionLaugh ?? 0,
heartCountAllTime: match?.reactionHeart ?? 0,
cryCountAllTime: match?.reactionCry ?? 0,
commentCountAllTime: match?.comment ?? 0,
collectedCountAllTime: match?.collection ?? 0,
tippedAmountCountAllTime: match?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
};
});
endTimer();
return { data: fullData, nextCursor };
}
// ===== SMART CACHE EXISTENCE CHECK (feature-flagged) =====
const checkImageExistence = async (imageIds: number[]) => {
// Preserve original order and remove duplicates
const uniqueIds = [...new Set(imageIds)];
const cachePrefix = `${REDIS_SYS_KEYS.CACHES.IMAGE_EXISTS}:`;
const cacheKeys = uniqueIds.map((id) => `${cachePrefix}${id}` as RedisKeyTemplateSys);
// Check cached results first (10 minute TTL — see EX: 600 below). Fail open — see the
// sibling checkImageExistence call site above for the same pattern.
let cachedResults: (string | null)[];
try {
// Wall-clock deadline — see the sibling checkImageExistence call site
// above; bounds a silent sysRedis half-open on this hot read.
cachedResults =
cacheKeys.length > 0 ? await withSysReadDeadline(sysRedis.packed.mGet(cacheKeys)) : [];
} catch (err) {
logSysRedisFailOpen('read-degraded', 'checkImageExistence mGet', err);
cachedResults = new Array(uniqueIds.length).fill(null);
}
// Separate cached and uncached IDs
const uncachedIds: number[] = [];
const cachedMap = new Map<number, boolean>();
let cacheMiss = 0;
for (let i = 0; i < uniqueIds.length; i++) {
const id = uniqueIds[i];
const cachedResult = cachedResults[i];
if (cachedResult === 'true') {
cachedMap.set(id, true);
} else if (cachedResult === 'false') {
cachedMap.set(id, false);
} else {
uncachedIds.push(id);
cacheMiss++;
}
}
let hitType: 'full' | 'partial' | 'miss';
if (cacheMiss === 0) {
hitType = 'full';
} else if (cacheMiss === uniqueIds.length) {
hitType = 'miss';
} else {
hitType = 'partial';
}
cacheHitRequestsTotal.inc({ route, hit_type: hitType });
// Query DB for uncached IDs
if (uncachedIds.length > 0) {
const dbResults = await dbRead.image.findMany({
where: { id: { in: uncachedIds } },
select: { id: true },
});
const dbIdSet = new Set(dbResults.map((r) => r.id));
// Update cache with DB results (10-minute TTL, EX: 600)
const cacheUpdates: Record<string, string> = {};
for (const id of uncachedIds) {
const exists = dbIdSet.has(id);
cacheUpdates[`${cachePrefix}${id}`] = exists ? 'true' : 'false';
cachedMap.set(id, exists);
}
// Best-effort cache populate — same pattern as the sibling
// checkImageExistence above. Defeats the read-side fail-open if
// not wrapped.
await Promise.all(
Object.entries(cacheUpdates).map(([key, value]) =>
sysRedis.packed.set(key as RedisKeyTemplateSys, value, { EX: 600 })
)
).catch((err) => {
logSysRedisFailOpen('write-degraded', 'checkImageExistence cache populate', err);
});
}
// Filter hits based on existence check while preserving order
let dropped = 0;
const existenceFiltered = limitedHits.filter((hit) => {
const exists = cachedMap.get(hit.id);
const keep = exists !== false; // treat undefined as exists=true
if (!keep) dropped++;
return keep;
});
droppedIdsTotal.inc({ route, hit_type: hitType }, dropped);
return existenceFiltered.filter((x) => imageIds.includes(x.id));
};
// Apply the (flagged) existence check
const filtered = await checkImageExistence(filteredHitIds);
if (limitedHits.length > limit) {
const lastItem = filtered.pop();
nextCursor = lastItem?.sortAtUnix;
} else {
nextCursor = undefined;
}
const imageMetrics = await getImageMetricsObject(filtered);
const fullData = filtered.map((h) => {
const match = imageMetrics[h.id];
return {
...h,
stats: {
likeCountAllTime: match?.reactionLike ?? 0,
laughCountAllTime: match?.reactionLaugh ?? 0,
heartCountAllTime: match?.reactionHeart ?? 0,
cryCountAllTime: match?.reactionCry ?? 0,
commentCountAllTime: match?.comment ?? 0,
collectedCountAllTime: match?.collection ?? 0,
tippedAmountCountAllTime: match?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
};
});
endTimer();
return { data: fullData, nextCursor };
} catch (error) {
const err = error as Error;
logToAxiom(
{
type: 'search-error',
error: err.message,
cause: err.cause,
input: redactSearchInputForLog(input),
request,
},
'temp-search'
).catch();
endTimer();
throw err;
}
}
// Lazily-built MetricService over the watcher-fed `metrics:*` cache, reused
// across calls (this is the hot feed path) rather than reconstructed per call.
let _imageMetricService: MetricService | null = null;
const getImageMetricService = () =>
(_imageMetricService ??= new MetricService(
clickhouse as IClickhouseClient,
redis as unknown as IRedisClient
));
type ImageMetricsObject = Record<
number,
{
imageId: number;
reactionLike: number | null;
reactionHeart: number | null;
reactionLaugh: number | null;
reactionCry: number | null;
comment: number | null;
collection: number | null;
buzz: number | null;
}
>;
// Image metric counts are read from the watcher-fed `metrics:*` cache via
// MetricService (which now pulls from the FINAL `entityMetricDailyAgg_v2` view).
// The legacy in-app `entitymetric:*` read path (imageMetricsCache) was retired
// after the v2 + watcher cutover went 100% stable.
export const getImageMetricsObject = async (
data: { id: number }[]
): Promise<ImageMetricsObject> => {
try {
const ids = data.map((d) => d.id);
// The ClickHouse read has NO request-level timeout other than the
// @clickhouse/client 30s default, and a try/catch CANNOT catch a hang. Bound
// it here so a saturated/cold-miss metric read fails SOFT to empty metrics
// (callers treat missing ids as null) instead of parking ~30s and blowing the
// SSR deadline. Empty `{}` matches the existing catch fallback.
const timeoutMs = env.CLICKHOUSE_IMAGE_METRICS_TIMEOUT_MS;
// Narrow type flows from this call (`fetch('Image', …)` → Record<number,
// ImageMetrics>); withTimeoutFallback infers T from it so the empty fallback
// is typed identically (no widening to the full metric union).
const fetchPromise = getImageMetricService().fetch('Image', ids);
type ImageMetricMap = Awaited<typeof fetchPromise>;
const metrics = await withTimeoutFallback(fetchPromise, timeoutMs, {} as ImageMetricMap, () => {
imageMetricsClickhouseTimeoutCounter.inc();
logToAxiom(
{
type: 'warning',
name: 'getImageMetrics timeout',
message: `ClickHouse image metrics read exceeded ${timeoutMs}ms`,
idCount: ids.length,
timeoutMs,
},
'clickhouse'
).catch();
});
const result: ImageMetricsObject = {};
for (const id of ids) {
const m = metrics[id];
result[id] = {
imageId: id,
reactionLike: m?.Like || null,
reactionHeart: m?.Heart || null,
reactionLaugh: m?.Laugh || null,
reactionCry: m?.Cry || null,
comment: m?.commentCount || null,
collection: m?.Collection || null,
buzz: m?.tippedAmount || null,
};
}
return result;
} catch (e) {
const error = e as Error;
logToAxiom(
{
type: 'error',
name: 'Failed to getImageMetrics',
message: error.message,
stack: error.stack,
cause: error.cause,
},
'clickhouse'
).catch();
return {};
}
};
export async function getTagNamesForImages(imageIds: number[]) {
const tagIds = await tagIdsForImagesCache.fetch(imageIds);
const tags = await tagCache.fetch(Object.values(tagIds).flatMap((x) => x.tags));
const imageTags = Object.fromEntries(
Object.entries(tagIds).map(([k, v]) => [k, v.tags.map((t) => tags[t]?.name).filter(isDefined)])
) as Record<number, string[]>;
return imageTags;
}
export async function getResourceIdsForImages(imageIds: number[]) {
// Route to writer while DataPacket replica is missing ImageResourceNew backfill.
const useWrite = await isFlipt(FLIPT_FEATURE_FLAGS.IMAGE_RESOURCE_USE_WRITE);
const db = useWrite ? dbWrite : dbRead;
const imageResourcesArr = await db.$queryRaw<{ imageId: number; modelVersionId: number }[]>`
SELECT "imageId", "modelVersionId"
FROM "ImageResourceNew"
WHERE "imageId" IN (${Prisma.join(imageIds)});
`;
const imageResources = imageResourcesArr.reduce((acc, { imageId, modelVersionId }) => {
if (!acc[imageId]) acc[imageId] = [];
acc[imageId].push(modelVersionId);
return acc;
}, {} as Record<number, number[]>);
return imageResources;
}
/**
* Narrow a pinned post's media down to what the pinned model version made.
*
* Media with no resource rows at all is kept: it can't be attributed either way, and
* dropping it is what made pinned posts with videos vanish before 7518ca4f54.
*/
export async function filterPinnedImagesToVersion<T extends { id: number }>(
images: T[],
modelVersionId: number
) {
if (!images.length) return images;
const resources = await getResourceIdsForImages(images.map((x) => x.id));
return images.filter((image) => {
const imageResources = resources[image.id];
return !imageResources?.length || imageResources.includes(modelVersionId);
});
}
type GetImageRaw = GetAllImagesRaw & {
reactions?: ReviewReactions[];
postId?: number | null;
// User fields from JOIN (not in GetAllImagesRaw since main query uses cache)
username: string | null;
userImage: string | null;
deletedAt: Date | null;
};
export const getImage = async ({
id,
userId,
isModerator,
withoutPost,
}: GetImageInput & { userId?: number; isModerator?: boolean }) => {
const AND = [Prisma.sql`i.id = ${id}`];
if (!isModerator) {
AND.push(
Prisma.sql`(${Prisma.join(
[
Prisma.sql`i."needsReview" IS NULL AND ${imageReviewedSql()}`,
withoutPost
? null
: Prisma.sql`
p."collectionId" IS NOT NULL AND EXISTS (
SELECT 1 FROM "CollectionContributor" cc
WHERE cc."collectionId" = p."collectionId"
AND cc."userId" = ${userId}
AND cc."permissions" && ARRAY['MANAGE']::"CollectionContributorPermission"[]
)`,
Prisma.sql`i."userId" = ${userId}`,
].filter(isDefined),
' OR '
)})`
);
if (!withoutPost) {
// Post gates sit in the WHERE, not the JOIN: an image outlives a deleted post (`Image.postId`
// is ON DELETE SET NULL) and an inner join drops it before ownership is tested. Nothing on
// `Image` separates that from a never-posted upload, so only the owner may fetch a postless one.
AND.push(
Prisma.sql`(
p."publishedAt" < now()
OR p."userId" = ${userId}
OR (i."postId" IS NULL AND i."userId" = ${userId})
)`
);
AND.push(
Prisma.sql`(i."postId" IS NULL OR p."availability" != 'Private' OR p."userId" = ${userId})`
);
}
// A Blocked-level rating is a ToS removal (or a pending-Blocked verdict awaiting
// mod review) — never serve it by direct id to anyone but the owner. Feeds already
// drop it via the browsingLevel mask; single-image fetch had no equivalent gate.
AND.push(Prisma.sql`(i."nsfwLevel" != ${NsfwLevel.Blocked} OR i."userId" = ${userId})`);
}
const rawImages = await dbRead.$queryRaw<GetImageRaw[]>`
SELECT
i.id,
i.name,
i.url,
i.height,
i.width,
i.index,
i.hash,
-- i.meta,
i."hideMeta",
i."createdAt",
i."mimeType",
i."scannedAt",
i."needsReview",
i."postId",
i.ingestion,
i."blockedFor",
i.type,
i.metadata,
i."nsfwLevel",
i.minor,
i.poi,
i."acceptableMinor",
(
CASE
WHEN i.meta IS NULL OR jsonb_typeof(i.meta) = 'null' OR i."hideMeta" THEN FALSE
ELSE TRUE
END
) AS "hasMeta",
(
CASE
WHEN i.meta IS NOT NULL AND jsonb_typeof(i.meta) != 'null' AND NOT i."hideMeta"
AND i.meta->>'prompt' IS NOT NULL
THEN TRUE
ELSE FALSE
END
) AS "hasPositivePrompt",
${imageOnSiteSql()} as "onSite",
i."meta"->'extra'->'remixOfId' as "remixOfId",
u.id as "userId",
u.username,
u.image as "userImage",
u."deletedAt",
u."profilePictureId",
${
!withoutPost
? Prisma.sql`
COALESCE(p."availability", 'Public') "availability",
GREATEST(p."publishedAt", i."scannedAt", i."createdAt") "publishedAt",
`
: Prisma.sql`'Public' "availability",`
}
(
SELECT jsonb_agg(reaction)
FROM "ImageReaction"
WHERE "imageId" = i.id
AND "userId" = ${userId}
) reactions
FROM "Image" i
JOIN "User" u ON u.id = i."userId"
${Prisma.raw(withoutPost ? '' : `LEFT JOIN "Post" p ON p.id = i."postId"`)}
WHERE ${Prisma.join(AND, ' AND ')}
`;
if (!rawImages.length) throw throwNotFoundError(`No image with id ${id}`);
const [{ userId: creatorId, username, userImage, deletedAt, reactions, ...firstRawImage }] =
rawImages;
const userCosmetics = await getCosmeticsForUsers([creatorId]);
const profilePictures = await getProfilePicturesForUsers([creatorId]);
const imageMetrics = await getImageMetricsObject([firstRawImage]);
const match = imageMetrics[firstRawImage.id];
const imageCosmetics = await getCosmeticsForEntity({
ids: [firstRawImage.id],
entity: 'Image',
});
// Durable replacement for the ambient `model3d.getByPostId` chip call: carry
// the visibility-checked linked Model3D id on this payload (the image
// viewers already fetch it) so the "Posted to 3D Model" chip renders from a
// prop instead of firing a per-image tRPC query for every image (~36/s,
// mostly null). Resolves the SAME visibility predicate the chip lookup used,
// so a hidden draft/deleted Model3D yields null here too. Null when the post
// isn't linked, isn't visible, or there's no postId at all.
const model3dId = firstRawImage.postId
? await getVisibleModel3DIdForPost({ postId: firstRawImage.postId, userId, isModerator })
: null;
const image = {
...firstRawImage,
model3dId,
cosmetic: imageCosmetics?.[firstRawImage.id] ?? null,
user: {
id: creatorId,
username,
image: userImage,
deletedAt,
cosmetics: userCosmetics?.[creatorId] ?? [],
profilePicture: profilePictures?.[creatorId] ?? null,
},
stats: {
likeCountAllTime: match?.reactionLike ?? 0,
laughCountAllTime: match?.reactionLaugh ?? 0,
heartCountAllTime: match?.reactionHeart ?? 0,
cryCountAllTime: match?.reactionCry ?? 0,
commentCountAllTime: match?.comment ?? 0,
collectedCountAllTime: match?.collection ?? 0,
tippedAmountCountAllTime: match?.buzz ?? 0,
dislikeCountAllTime: 0,
viewCountAllTime: 0,
},
reactions: userId ? reactions?.map((r) => ({ userId, reaction: r })) ?? [] : [],
};
return image;
};
export const getImageResources = async ({ id }: GetByIdInput) => {
const resources = await dbRead.$queryRaw<ImageResourceHelperModel[]>`
SELECT
irh."imageId",
irh."reviewId",
irh."reviewRating",
irh."reviewDetails",
irh."reviewCreatedAt",
irh."name",
irh."modelVersionId",
irh."modelVersionName",
irh."modelVersionCreatedAt",
irh."modelId",
irh."modelName",
irh."modelThumbsUpCount",
irh."modelThumbsDownCount",
irh."modelDownloadCount",
irh."modelCommentCount",
irh."modelType",
irh."modelVersionBaseModel",
irh."detected"
FROM
"ImageResourceHelper" irh
JOIN "Model" m ON m.id = irh."modelId" AND m."status" = 'Published'
WHERE
irh."imageId" = ${Prisma.sql`${id}`}
AND irh."modelVersionId" IS NOT NULL
`;
return resources;
};
export type ImagesForModelVersions = {
id: number;
userId: number;
name: string;
url: string;
nsfwLevel: NsfwLevel;
width: number;
height: number;
hash: string;
modelVersionId: number;
meta?: ImageMetaProps | null;
type: MediaType;
metadata: ImageMetadata | VideoMetadata | null;
tags?: number[];
availability: Availability;
sizeKB?: number;
onSite: boolean;
hasMeta: boolean;
remixOfId?: number | null;
hasPositivePrompt?: boolean;
poi?: boolean;
minor?: boolean;
};
export const getImagesForModelVersion = async ({
modelVersionIds,
excludedTagIds,
excludedIds,
excludedUserIds,
imagesPerVersion = 1,
include = [],
user,
pending,
browsingLevel,
db,
}: {
modelVersionIds: number | number[];
excludedTagIds?: number[];
excludedIds?: number[];
excludedUserIds?: number[];
imagesPerVersion?: number;
include?: Array<'meta' | 'tags'>;
user?: SessionUser;
pending?: boolean;
browsingLevel?: number;
// Optional db override. Cache lookupFn passes dbWrite during refresh() so the
// cache always repopulates from primary; default reads still use the lagged
// replica.
db?: typeof dbRead;
}) => {
if (!Array.isArray(modelVersionIds)) modelVersionIds = [modelVersionIds];
if (!modelVersionIds.length) return [] as ImagesForModelVersions[];
const userId = user?.id;
const isModerator = user?.isModerator ?? false;
const imageWhere: Prisma.Sql[] = [Prisma.sql`p."publishedAt" IS NOT NULL`];
if (!!excludedTagIds?.length) {
const excludedTagsOr: Prisma.Sql[] = [
Prisma.join(
[
Prisma.sql`i."nsfwLevel" != 0`,
Prisma.sql`NOT EXISTS (SELECT 1 FROM "TagsOnImageDetails" toi WHERE toi."imageId" = i.id AND toi."disabled" = FALSE AND toi."tagId" IN (${Prisma.join(
excludedTagIds
)}) )`,
],
' AND '
),
];
if (userId) excludedTagsOr.push(Prisma.sql`i."userId" = ${userId}`);
imageWhere.push(Prisma.sql`(${Prisma.join(excludedTagsOr, ' OR ')})`);
}
if (!!excludedIds?.length) {
imageWhere.push(Prisma.sql`i.id NOT IN (${Prisma.join(excludedIds)})`);
}
if (!!excludedUserIds?.length) {
imageWhere.push(Prisma.sql`i."userId" != ALL(${excludedUserIds}::int[])`);
}
if (browsingLevel) browsingLevel = onlySelectableLevels(browsingLevel);
if (pending && (isModerator || userId) && browsingLevel) {
if (isModerator) {
imageWhere.push(Prisma.sql`((i."nsfwLevel" & ${browsingLevel}) != 0 OR i."nsfwLevel" = 0)`);
} else if (userId) {
imageWhere.push(Prisma.sql`(i."needsReview" IS NULL OR i."userId" = ${userId})`);
imageWhere.push(
Prisma.sql`((i."nsfwLevel" & ${browsingLevel}) != 0 OR (i."nsfwLevel" = 0 AND i."userId" = ${userId}))`
);
}
} else {
imageWhere.push(Prisma.sql`i."needsReview" IS NULL AND i."acceptableMinor" = FALSE`);
imageWhere.push(
browsingLevel
? Prisma.sql`(i."nsfwLevel" & ${browsingLevel}) != 0`
: Prisma.sql`i."nsfwLevel" != 0`
);
}
const query = Prisma.sql`
WITH targets AS (
SELECT
i.id,
full_mv.id::int AS "modelVersionId"
FROM unnest(ARRAY[${Prisma.join(modelVersionIds)}]) AS full_mv(id)
CROSS JOIN LATERAL
(
SELECT
i.id
FROM "Image" i
JOIN "Post" p ON p.id = i."postId"
JOIN "ModelVersion" mv ON mv.id = p."modelVersionId"
JOIN "Model" m ON m.id = mv."modelId"
WHERE (p."userId" = m."userId" OR m."userId" = -1)
AND p."modelVersionId" = full_mv.id
AND ${Prisma.join(imageWhere, ' AND ')}
AND (
(i."nsfwLevel" & ${nsfwBrowsingLevelsFlag}) = 0
OR NOT i."modelRestricted"
)
ORDER BY i."postId", i.index
LIMIT ${imagesPerVersion}
) i
)
SELECT
i.id,
i."userId",
i.name,
i.url,
i."nsfwLevel",
i.width,
i.height,
i.hash,
i.type,
i.metadata,
i.minor,
i.poi,
t."modelVersionId",
${Prisma.raw(
include.includes('meta') ? 'CASE WHEN i."hideMeta" THEN NULL ELSE i.meta END AS meta,' : ''
)}
p."availability",
(
CASE
WHEN i.meta IS NULL OR jsonb_typeof(i.meta) = 'null' OR i."hideMeta" THEN FALSE
ELSE TRUE
END
) AS "hasMeta",
(
CASE
WHEN i.meta IS NOT NULL AND jsonb_typeof(i.meta) != 'null' AND NOT i."hideMeta"
AND i.meta->>'prompt' IS NOT NULL
THEN TRUE
ELSE FALSE
END
) AS "hasPositivePrompt",
${imageOnSiteSql()} as "onSite",
i."meta"->'extra'->'remixOfId' as "remixOfId"
FROM targets t
JOIN "Image" i ON i.id = t.id
JOIN "Post" p ON p.id = i."postId"
ORDER BY i."postId", i."index"
`;
const images = await (db ?? dbRead).$queryRaw<ImagesForModelVersions[]>(query);
// const remainingModelVersionIds = modelVersionIds.filter(
// (x) => !images.some((i) => i.modelVersionId === x)
// );
// if (remainingModelVersionIds.length) {
// const communityImages = await dbRead.$queryRaw<ImagesForModelVersions[]>`
// -- Get Community posts tied to the specific modelVersion via the post.
// WITH targets AS (
// SELECT
// id,
// "modelVersionId",
// row_num
// FROM (
// SELECT
// i.id,
// p."modelVersionId",
// row_number() OVER (PARTITION BY p."modelVersionId" ORDER BY im."reactionCount" DESC) row_num
// FROM "Image" i
// JOIN "Post" p ON p.id = i."postId"
// JOIN "ImageMetric" im ON im."imageId" = i.id AND im.timeframe = 'AllTime'::"MetricTimeframe"
// WHERE p."modelVersionId" IN (${Prisma.join(remainingModelVersionIds)})
// AND ${Prisma.join(imageWhere, ' AND ')}
// ) ranked
// WHERE ranked.row_num <= 20
// )
// SELECT
// i.id,
// i."userId",
// i.name,
// i.url,
// i."nsfwLevel",
// i.width,
// i.height,
// i.hash,
// i.type,
// i.metadata,
// t."modelVersionId",
// p."availability"
// ${Prisma.raw(include.includes('meta') ? ', i.meta' : '')}
// FROM targets t
// JOIN "Image" i ON i.id = t.id
// JOIN "Post" p ON p.id = i."postId"
// ORDER BY t.row_num
// `;
// images = [...images, ...communityImages];
// }
if (include.includes('tags')) {
const imageIds = images.map((i) => i.id);
const tagIdsVar = await tagIdsForImagesCache.fetch(imageIds);
for (const image of images) {
image.tags = tagIdsVar?.[image.id]?.tags;
}
}
return images;
};
type CachedImagesForModelVersions = {
modelVersionId: number;
images: ImagesForModelVersions[];
};
export const imagesForModelVersionsCache = createCachedObject<CachedImagesForModelVersions>({
key: REDIS_KEYS.CACHES.IMAGES_FOR_MODEL_VERSION,
idKey: 'modelVersionId',
ttl: CacheTTL.day,
// The lookupFn filters on async-populated columns (i.nsfwLevel != 0,
// i.needsReview IS NULL). A read between publish and ingestion-complete
// returns zero rows. We still cache notFound to skip the requery cost on
// versions that are genuinely empty, but cap the lifetime so a transient
// empty doesn't pin the model out of feeds for the full 1-day TTL.
notFoundTtl: CacheTTL.xs,
// staleWhileRevalidate: false, // We might want to enable this later otherwise there will be a delay after a creator updates their showcase images...
lookupFn: async (ids, fromWrite) => {
// refresh() passes fromWrite=true. For plain fetch() misses, fall back to
// the lag-aware helper so a cache miss right after image upload doesn't
// poison the entry with `images: []` for a full TTL cycle.
const db = fromWrite ? dbWrite : await getDbWithoutLagBatch('modelVersion', ids);
// No `include: ['meta']`: these rows reach the Meilisearch model document and
// the model.getAll wire, and GETALL_DROPPED_IMAGE_FIELDS does not drop meta.
const images = await getImagesForModelVersion({
modelVersionIds: ids,
imagesPerVersion: 20,
db,
});
const records: Record<number, CachedImagesForModelVersions> = {};
for (const image of images) {
if (!records[image.modelVersionId])
records[image.modelVersionId] = { modelVersionId: image.modelVersionId, images: [] };
records[image.modelVersionId].images.push(image);
}
return records;
},
appendFn: async (records) => {
const imageIds = [...records].flatMap((x) => x.images.map((i) => i.id));
const tagIdsVar = await tagIdsForImagesCache.fetch(imageIds);
for (const entry of records) {
for (const image of entry.images) {
image.tags = tagIdsVar?.[image.id]?.tags ?? [];
}
}
},
});
export async function getImagesForModelVersionCache(modelVersionIds: number[]) {
const images = await imagesForModelVersionsCache.fetch(modelVersionIds);
const tagsForImages = await tagIdsForImagesCache.fetch(Object.keys(images).map(Number));
return Object.keys(images).reduce(
(acc, imageId) => ({
...acc,
[imageId]: {
...images[imageId],
tags: tagsForImages[imageId]?.tags,
},
}),
images
);
}
export async function deleteImagesForModelVersionCache(modelVersionId: number | number[]) {
await imagesForModelVersionsCache.bust(modelVersionId);
}
// TODO cover only is not handled, but is passed in
export const getImagesForPosts = async ({
postIds,
// excludedIds,
coverOnly = true,
browsingLevel,
user,
pending,
disablePoi,
disableMinor,
poiOnly,
minorOnly,
}: {
postIds: number | number[];
// excludedIds?: number[];
coverOnly?: boolean;
browsingLevel?: number;
user?: SessionUser;
pending?: boolean;
disablePoi?: boolean;
disableMinor?: boolean;
poiOnly?: boolean;
minorOnly?: boolean;
}) => {
const userId = user?.id;
const isModerator = user?.isModerator ?? false;
if (!Array.isArray(postIds)) postIds = [postIds];
const imageWhere: Prisma.Sql[] = [Prisma.sql`i."postId" IN (${Prisma.join(postIds)})`];
// if (!!excludedIds?.length)
// imageWhere.push(Prisma.sql`i."id" NOT IN (${Prisma.join(excludedIds)})`);
// }
if (!isModerator) {
imageWhere.push(
userId
? Prisma.sql`(i."ingestion" != 'Blocked' OR i."userId" = ${userId})`
: Prisma.sql`i."ingestion" != 'Blocked'`
);
}
if (browsingLevel) browsingLevel = onlySelectableLevels(browsingLevel);
if (pending && (isModerator || userId) && browsingLevel) {
if (isModerator) {
imageWhere.push(Prisma.sql`((i."nsfwLevel" & ${browsingLevel}) != 0 OR i."nsfwLevel" = 0)`);
} else if (userId) {
imageWhere.push(Prisma.sql`(i."needsReview" IS NULL OR i."userId" = ${userId})`);
imageWhere.push(
Prisma.sql`((i."nsfwLevel" & ${browsingLevel}) != 0 OR (i."nsfwLevel" = 0 AND i."userId" = ${userId}))`
);
} else {
imageWhere.push(Prisma.sql`i."needsReview" IS NULL`);
}
} else {
imageWhere.push(Prisma.sql`i."needsReview" IS NULL AND i."acceptableMinor" = FALSE`);
imageWhere.push(
browsingLevel
? Prisma.sql`(i."nsfwLevel" & ${browsingLevel}) != 0`
: Prisma.sql`i.ingestion = ${ImageIngestionStatus.Scanned}::"ImageIngestionStatus"`
);
}
if (disablePoi) {
imageWhere.push(Prisma.sql`(i."poi" = false OR i."poi" IS NULL OR i."userId" = ${userId})`);
}
if (disableMinor) {
imageWhere.push(Prisma.sql`(i."minor" = false OR i."minor" IS NULL)`);
}
if (isModerator) {
if (poiOnly) {
imageWhere.push(Prisma.sql`i."poi" = true`);
}
if (minorOnly) {
imageWhere.push(Prisma.sql`i."minor" = true`);
}
}
const images = await dbRead.$queryRaw<
{
id: number;
userId: number;
name: string;
url: string;
nsfwLevel: NsfwLevel;
width: number;
height: number;
hash: string;
// postId groups images under their post server-side (post.service groups
// on `x.postId === post.id`) AND is read on the response by
// ImageContextMenu → ImageMenuItems (collection/view/edit/searchable menu
// items on the browse post cards) — kept.
postId: number;
type: MediaType;
metadata: ImageMetadata | VideoMetadata | null;
onSite: boolean;
remixOfId?: number | null;
poi?: boolean;
minor?: boolean;
}[]
// NOTE: getImagesForPosts is used ONLY by getPostsInfinite. The browse
// cards render `images[0]` and the hidden-preferences filter reads
// {id,userId,nsfwLevel,tagIds,poi,minor}; NO consumer reads createdAt,
// hasMeta, or hasPositivePrompt — dropped here to cut per-image serialize
// weight (this endpoint's payload is ~85% images at ~7 images/post, so the
// per-image field COUNT — not the #3052 image CAP, which only trims the
// rare >8-image gallery tail — is what drives bytes + superjson serializeMs).
>`
SELECT
i.id,
i."userId",
i.name,
i.url,
i."nsfwLevel",
i.width,
i.height,
i.hash,
i.type,
i.metadata,
i."postId",
${imageOnSiteSql()} as "onSite",
i.metadata->>'remixOfId' as "remixOfId",
i.minor,
i.poi
FROM "Image" i
WHERE ${Prisma.join(imageWhere, ' AND ')}
AND (
(i."nsfwLevel" & ${nsfwBrowsingLevelsFlag}) = 0
OR NOT i."modelRestricted"
)
ORDER BY i.index ASC
`;
const imageIds = images.map((i) => i.id);
const tagIds = await tagIdsForImagesCache.fetch(imageIds);
return images.map((i) => {
return {
...i,
tagIds: tagIds[i.id]?.tags,
};
});
};
export const removeImageResource = async ({
imageId,
modelVersionId,
}: RemoveImageResourceSchema) => {
try {
const resource = await dbWrite.imageResourceNew.delete({
where: { imageId_modelVersionId: { imageId, modelVersionId } },
});
// if (!resource) throw throwNotFoundError(`No image resource with id ${id}`);
purgeImageGenerationDataCache(imageId);
await imageResourcesCache.refresh(imageId);
return resource;
} catch (error) {
if (error instanceof TRPCError) throw error;
throw throwDbError(error);
}
};
// export function applyModRulesSql(
// AND: Prisma.Sql[],
// { userId, publishedOnly = true }: { userId?: number; publishedOnly?: boolean }
// ) {
// // Hide images that need review
// const needsReviewOr = [Prisma.sql`i."needsReview" IS NULL`];
// // Hide images that aren't published
// const publishedOr = publishedOnly ? [Prisma.sql`p."publishedAt" < now()`] : [];
//
// if (userId) {
// const belongsToUser = Prisma.sql`i."userId" = ${userId}`;
// needsReviewOr.push(belongsToUser);
//
// if (publishedOnly) {
// publishedOr.push(belongsToUser);
// }
// }
//
// AND.push(Prisma.sql`(${Prisma.join(needsReviewOr, ' OR ')})`);
//
// if (publishedOr.length > 0) {
// AND.push(Prisma.sql`(${Prisma.join(publishedOr, ' OR ')})`);
// }
// }
// export type GetIngestionResultsProps = AsyncReturnType<typeof getIngestionResults>;
export const getIngestionResults = async ({ ids, userId }: { ids: number[]; userId?: number }) => {
const images = await dbRead.image.findMany({
where: { id: { in: ids } },
select: {
id: true,
ingestion: true,
blockedFor: true,
tagComposites: {
where: { OR: [{ score: { gt: 0 } }, { tagType: 'Moderation' }] },
select: imageTagCompositeSelect,
orderBy: { score: 'desc' },
},
},
});
const dictionary = images.reduce<
Record<
number,
{ ingestion: ImageIngestionStatus; blockedFor?: string; tags?: VotableTagModel[] }
>
>((acc, value) => {
const { id, ingestion, blockedFor, tagComposites } = value;
const tags: VotableTagModel[] = tagComposites.map(
({ tagId, tagName, tagType, tagNsfwLevel, ...tag }) => ({
...tag,
id: tagId,
type: tagType,
nsfwLevel: tagNsfwLevel as NsfwLevel,
name: tagName,
})
);
return {
...acc,
[id]: {
ingestion,
blockedFor: blockedFor ?? undefined,
tags: !!blockedFor ? undefined : tags,
},
};
}, {});
if (userId) {
const userVotes = await dbRead.tagsOnImageVote.findMany({
where: { imageId: { in: ids }, userId },
select: { tagId: true, vote: true },
});
const voteByTagId = new Map(userVotes.map((v) => [v.tagId, v.vote]));
for (const key in dictionary) {
if (dictionary.hasOwnProperty(key)) {
for (const tag of dictionary[key].tags ?? []) {
const vote = voteByTagId.get(tag.id);
if (vote !== undefined) tag.vote = vote > 0 ? 1 : -1;
}
}
}
}
return dictionary;
};
type GetImageConnectionRaw = {
id: number;
name: string;
url: string;
nsfwLevel: NsfwLevel;
width: number;
height: number;
hash: string;
hideMeta: boolean;
createdAt: Date;
mimeType: string;
scannedAt: Date;
ingestion: ImageIngestionStatus;
needsReview: string | null;
userId: number;
index: number;
type: MediaType;
metadata: ImageMetadata | VideoMetadata;
entityId: number;
hasMeta: boolean;
hasPositivePrompt: boolean;
poi?: boolean;
minor?: boolean;
};
export const getImagesByEntity = async ({
id,
ids,
type,
imagesPerId = 4,
include,
userId,
isModerator,
}: {
id?: number;
ids?: number[];
type: ImageEntityType;
imagesPerId?: number;
include?: ['tags'];
userId?: number;
isModerator?: boolean;
}) => {
if (!id && (!ids || ids.length === 0)) {
return [];
}
const AND: Prisma.Sql[] = !isModerator
? [
Prisma.sql`(i."ingestion" = ${ImageIngestionStatus.Scanned}::"ImageIngestionStatus"${
userId ? Prisma.sql` OR i."userId" = ${userId}` : Prisma.sql``
})`,
]
: [];
if (!isModerator) {
const needsReviewOr = [
Prisma.sql`i."needsReview" IS NULL`,
userId ? Prisma.sql`i."userId" = ${userId}` : null,
].filter(isDefined);
if (needsReviewOr.length > 0) {
AND.push(Prisma.sql`(${Prisma.join(needsReviewOr, ' OR ')})`);
}
}
const images = await dbRead.$queryRaw<GetImageConnectionRaw[]>`
WITH targets AS (
SELECT
id,
"entityId"
FROM (
SELECT
i.id,
ic."entityId",
row_number() OVER (PARTITION BY ic."entityId" ORDER BY i.index) row_num
FROM "Image" i
JOIN "ImageConnection" ic ON ic."imageId" = i.id
AND ic."entityType" = ${type}
AND ic."entityId" IN (${Prisma.join(ids ? ids : [id])})
${AND.length ? Prisma.sql`WHERE ${Prisma.join(AND, ' AND ')}` : Prisma.empty}
) ranked
WHERE ranked.row_num <= ${imagesPerId}
)
SELECT
i.id,
i.name,
i.url,
i."nsfwLevel",
i.width,
i.height,
i.hash,
i."hideMeta",
i."createdAt",
i."mimeType",
i.type,
i.metadata,
i.ingestion,
i."scannedAt",
i."needsReview",
i."userId",
i."index",
i.poi,
i.minor,
(
CASE
WHEN i.meta IS NULL OR jsonb_typeof(i.meta) = 'null' OR i."hideMeta" THEN FALSE
ELSE TRUE
END
) AS "hasMeta",
(
CASE
WHEN i.meta IS NOT NULL AND jsonb_typeof(i.meta) != 'null' AND NOT i."hideMeta"
AND i.meta->>'prompt' IS NOT NULL
THEN TRUE
ELSE FALSE
END
) AS "hasPositivePrompt",
t."entityId"
FROM targets t
JOIN "Image" i ON i.id = t.id`;
let tagsVar: (VotableTagModel & { imageId: number })[] | undefined = [];
if (include && include.includes('tags')) {
const imageIds = images.map((i) => i.id);
tagsVar = await getImageTagsForImages(imageIds);
}
return attachTagsToImages(images, tagsVar);
};
export async function createImage({
toolIds,
techniqueIds,
skipIngestion,
verifiedSourceImageIds,
...image
}: ImageSchema & {
userId: number;
skipIngestion?: boolean;
/**
* Derivation the caller proved (see remix-provenance.ts). Nothing else can put
* `meta.extra.sourceImageIds` on a row — every other caller's claim is stripped
* here, so a new image path can't grant itself provenance by accident.
*/
verifiedSourceImageIds?: number[] | null;
}) {
/**
* 🔴 THE ROW MUST NOT OUTLIVE ITS MEDIA — so ask the store before writing it.
*
* `createImage` is the widest single funnel for `Image` rows — `addPostImage`, the
* post-with-images handler, model-version and collection paths, comics, cover images,
* thumbnails all arrive here — and the check belongs at that one funnel rather than at
* each caller, because a predicate open-coded at N sites is wrong at N-1 of them. It
* cannot live in the zod schema, because it needs IO.
*
* 🔴 IT IS NOT EVERY `Image` ROW, AND THIS COMMENT USED TO CLAIM IT WAS. Six write
* paths reach the `Image` table without passing through here, so their rows appear in
* neither the numerator nor the denominator of anything this emits:
* 1. `article.service.ts` `linkArticleContentImages` (`tx.image.createManyAndReturn`)
* 2. `createEntityImages`, below in this file (`dbClient.image.createMany`)
* 3. `updateEntityImages`, below in this file (`dbClient.image.createMany`)
* 4. `blocks/app-listing-assets.service.ts` (`dbWrite.image.create`)
* 5. `pages/api/admin/temp/migrate-article-images.ts` (a one-off admin backfill)
* 6. `jobs/daily-challenge-processing.ts` `duplicateImage` — a raw
* `INSERT INTO "Image" (…) SELECT … FROM "Image" i WHERE i.id = …` that re-owns an
* existing row's columns (`url` first among them) to another user, for the
* challenge cover.
*
* 🔴 THIS LIST SAID FIVE UNTIL A ROUND-2 AUDIT FOUND (6), so treat it as a claim that
* has already been wrong once. The population it asserts over is: every
* `.image.(create|createMany|createManyAndReturn|upsert)` call and every raw
* `INSERT INTO "Image"` under `src/`, excluding test files. Re-derive it, do not trust
* it — and note that (6) is the LOWER-risk shape of the two kinds here: its `url` is
* copied from a row that already exists rather than freshly minted from a client
* upload, so it can propagate an existing defective key but cannot create a new one.
* (1)(5) all take their `url` from their INPUT rather than from an existing `Image`
* row, so those are where a brand-new orphan key can enter. That is a claim about the
* SHAPE of each write, not a ranking — nothing here has measured a per-path rate.
*
* 🔴 The article path is the highest-risk of those, because it is fed by the exact hook
* defect this change describes: the TipTap editor nodes (`TipTap/EdgeMediaNode.tsx`,
* `libs/tiptap/extensions/CustomImage.tsx`) write the upload's key into article content
* on a promise that RESOLVES even when the PUT was refused, and that content is then
* materialised into rows by `linkArticleContentImages` — unprobed.
*
* 🔴 EXTENDING THE PROBE TO IT IS A SEPARATE CHANGE, and the reason is structural, not
* scheduling: `linkArticleContentImages` does its `createManyAndReturn` INSIDE a
* `dbWrite.$transaction` callback, and this repo has a lint rule
* (`local-rules/no-io-in-transaction`) whose whole purpose is to keep network IO out of
* that callback, because it burns the transaction's timeout budget. Adding a HEAD there
* means restructuring the transaction first.
*
* So the honest scope of what follows is: every `Image` row written through
* `createImage`. Both halves of this change are partial — the client half only reaches
* sessions that have loaded the new bundle, this half only reaches this funnel — and
* nothing here establishes which of the two leaves more rows uncovered.
*
* 🔴 OBSERVE-ONLY, BY CONSTRUCTION AND NOT BY A FLAG. The verdict is logged and then
* dropped; nothing below branches on it. Rejecting here would be a new failure mode on
* a working user-facing path, and the only honest way to size that is to let the
* `absent` rate accumulate against a real denominator first. Enforcement is a separate
* change gated on the measurement this emits — which is also why there is no env var to
* flip: an unused enforcement branch is an untested one.
*
* 🔴 COST, AND THE LOOPS THAT MULTIPLY IT. One HEAD per call. Most callers are
* single-image, but TWO `comics.router.ts` procedures call this once per input item:
* `bulkCreatePanels` (`panels` capped at 20 by its zod schema) and `addReferenceImages`
* (`images` capped at 10). Both loops are sequential, so they add up to 20 and up to 10
* sequential probes respectively.
*
* 🔴 Size the DEGRADED case, not the healthy one — and it is now an ACTUAL NUMBER. An
* earlier version of this comment ended "the absolute number is unbounded until
* `getB2ImageS3Client` sets a `maxAttempts`", which was true and is the defect this
* paragraph used to describe: an abort signal bounds each network ATTEMPT, and the SDK's
* between-attempt sleep is not abort-aware, so a degraded store could add tens of seconds
* to a 20-item import and blow the request budget — telemetry becoming a new failure mode
* on a working path.
*
* That is closed, and NOT by changing `getB2ImageS3Client`: that factory is shared with
* the live upload-completion endpoints, which want their retries. The probe was given its
* own `maxAttempts: 1` client (`getImageUploadProbeBackend`), and
* `probeCreatedImageMedia` additionally races itself against
* `CREATED_IMAGE_MEDIA_PROBE_DEADLINE_MS` so the bound holds without trusting the SDK.
* Worst case is therefore 3s per call: 60s added to a 20-item `bulkCreatePanels` and 30s
* to a 10-item `addReferenceImages` — bounded, and only while the store is degraded.
*
* Both loops are already N sequential IO round-trips per item (a DB write plus an
* ingestion call), so this remains a proportional addition to an existing sequential-IO
* path. If those worst cases are judged too large, the lever is the deadline constant or
* making these two routers probe concurrently — not removing the bound.
*/
const mediaVerdict = await probeCreatedImageMedia(image.url);
/**
* Logged on EVERY call, whatever the verdict, so the `absent` rate has a denominator:
* one line per `createImage` call means the denominator is "image creations", which is
* the population the historical 10-in-a-sample figure was measured against. A log
* emitted only on the bad verdict counts a numerator against nothing.
*
* 🔴 THE POSITIVE CONTROL IS `present`, AND IT IS WHY THIS IS NOT A ONE-FIELD LOG. A
* probe that can never answer emits zero `absent` verdicts, and "zero absent" is exactly
* what a clean result looks like. `getImageUploadBackend()` throws without credentials
* and a rotated key answers 403 — both land on `unknown`, both look clean. Do not read
* the `absent` count until a NON-ZERO `present` count proves the probe reaches the store
* at all, and `unknown` is a small share of `present + absent + unknown`.
*
* 🔴 BUT A NON-ZERO `present` IS NOT SUFFICIENT ON ITS OWN, AND AN EARLIER VERSION OF
* THIS COMMENT SAID IT WAS. `present` proves the credential can read an object that
* EXISTS; it does not prove that a key which does NOT exist comes back as a 404 rather
* than a 403. `isNotFoundError` in `~/utils/s3-utils` accepts only `NotFound`,
* `NoSuchKey` and a 404 status, so under a 403-on-missing credential every real defect
* would land on `unknown` and `absent` would sit at zero — while the `present` control
* above reads perfectly healthy.
*
* That specific hazard was MEASURED rather than reasoned about, on 2026-09-08, by
* issuing HeadObject against the production image bucket with the production upload
* credential: a key that does not exist answered `name=NotFound`,
* `$metadata.httpStatusCode=404` (and a key that does exist returned its
* `ContentLength`, as the positive control). So `absent` IS reachable and the counter is
* not structurally pinned at zero.
*
* 🔴 That measurement is contingent on the credential it was taken with. A key rotation,
* a permissions change, or a move to a different bucket can reintroduce the hazard
* silently — the symptom is `absent` going to zero while `present` stays healthy. If you
* see that shape, re-take the measurement before concluding the defect is gone.
*
* `url` is logged only for a PROBED verdict, where `isProbeableMediaKey` has already
* accepted the value and it is therefore a 36-character uuid — not a filename and not
* PII. It is the field that makes an `absent` verdict actionable: settling "real defect
* or false verdict" means taking a key and HEADing the store by hand, and there is
* nothing else to look up (`postId` is null on most non-post paths, and `userId` alone
* selects thousands of rows). `not-applicable` is by definition the arbitrary
* caller-supplied strings that FAILED that predicate, so those are omitted rather than
* shipped to a log sink.
*
* There is deliberately no image id — the row does not exist yet, which is the whole
* reason the check sits here.
*
* 🔴 NOT AWAITED, AND CONTAINED. Telemetry must never be able to change this call's
* outcome, nor sit on a user-facing mutation's latency budget.
*
* 🔴 THIS LINE IS TEMPORARY, AND HERE IS WHAT ENDS IT. It is unsampled — one line per
* `createImage` call — which is the only shape that gives the `absent` count a
* denominator, and also the reason it must not live here indefinitely.
*
* CLOSING CONDITION: a merged PR that reads the `present` / `absent` / `unknown` counts
* for `create-image-media-verify`, quotes those three numbers in its body, and then
* either (a) adds the enforcement branch this measurement exists to size, or (b) deletes
* this call outright because the rate does not justify one. Either way that PR removes
* or samples down this `logToAxiom`.
*
* MECHANICAL CHECK THAT IT IS CLOSED: the string `create-image-media-verify` no longer
* appears in this file. Until then, an unread counter is the failure mode — if you are
* reading this and no such PR exists, the counts are already in the log; read them.
*/
void logToAxiom({
type: 'info',
name: 'create-image-media-verify',
message: 'createImage media existence probe',
verdict: mediaVerdict,
url: mediaVerdict === 'not-applicable' ? null : image.url,
userId: image.userId,
postId: image.postId ?? null,
}).catch(() => {
// swallow — best-effort logging must never break the creation it is observing
});
const meta = sanitizeProvenance(
image.meta as Record<string, unknown> | null | undefined,
verifiedSourceImageIds
);
const result = await dbWrite.image.create({
data: {
...image,
meta: (meta as Prisma.JsonObject) ?? Prisma.JsonNull,
generationProcess: meta ? getImageGenerationProcess(meta as ImageMetaProps) : null,
tools: !!toolIds?.length
? { createMany: { data: toolIds.map((toolId) => ({ toolId })) } }
: undefined,
techniques: !!techniqueIds?.length
? { createMany: { data: techniqueIds.map((techniqueId) => ({ techniqueId })) } }
: undefined,
ingestion: skipIngestion ? ImageIngestionStatus.PendingManualAssignment : undefined,
},
select: { id: true },
});
if (!skipIngestion) {
await upsertImageFlag({ imageId: result.id, prompt: image.meta?.prompt });
await ingestImage({
image: {
id: result.id,
url: image.url,
type: image.type,
height: image.height,
width: image.width,
prompt: image?.meta?.prompt,
},
userId: image.userId,
});
}
// No count refresh here: a new image is Pending and unpublished, so it cannot
// satisfy the count predicate yet, and caching that zero pins it for the TTL.
// The count is updated on the transitions that make an image countable —
// publish and scan completion.
return result;
}
export const createEntityImages = async ({
tx,
entityId,
entityType,
images,
userId,
}: {
tx?: Prisma.TransactionClient;
entityId?: number;
entityType?: string;
images: ImageUploadProps[];
userId: number;
}) => {
const dbClient = tx ?? dbWrite;
if (images.length === 0) {
return [];
}
await dbClient.image.createMany({
data: images.map((image) => ({
...image,
// Same strip as `createImage`: nothing that reaches an Image row keeps a
// provenance claim it didn't prove. These rows have no post, so they can't
// reach a remix gallery today — but the invariant is "no unproven claim on
// any row", not "on the rows that currently matter".
meta:
(sanitizeProvenance(image?.meta as Record<string, unknown> | null | undefined) as
| Prisma.JsonObject
| undefined) ?? Prisma.JsonNull,
userId,
resources: undefined,
})),
});
const imageRecords = await dbClient.image.findMany({
select: { id: true, url: true, type: true, width: true, height: true },
where: {
url: { in: images.map((i) => i.url) },
ingestion: ImageIngestionStatus.Pending,
userId,
},
});
const shouldAddImageResources = !!entityType && ['Bounty', 'BountyEntry'].includes(entityType);
const batches = chunk(imageRecords, 50);
for (const batch of batches) {
if (shouldAddImageResources) {
const tasks = batch.map((image) => () => createImageResources({ imageId: image.id, tx }));
await limitConcurrency(tasks, 10);
}
}
if (entityType && entityId) {
await dbClient.imageConnection.createMany({
data: imageRecords.map((image) => ({
imageId: image.id,
entityId,
entityType,
})),
});
}
return imageRecords;
};
type GetEntityImageRaw = {
id: number;
name: string;
url: string;
nsfwLevel: NsfwLevel;
width: number;
height: number;
hash: string;
hideMeta: boolean;
hasMeta: boolean;
hasPositivePrompt: boolean;
createdAt: Date;
mimeType: string;
scannedAt: Date;
needsReview: string | null;
userId: number;
index: number;
postId: number | null;
type: MediaType;
metadata: MixedObject | null;
entityId: number;
entityType: string;
poi?: boolean;
minor?: boolean;
};
export const getEntityCoverImage = async ({
entities,
include,
}: GetEntitiesCoverImage & {
include?: ['tags'];
}) => {
if (entities.length === 0) {
return [];
}
// Returns 1 cover image for:
// Models, Images, Bounties, BountyEntries, Article and Post.
const imagesRaw = await dbRead.$queryRaw<GetEntityImageRaw[]>`
WITH entities AS (
SELECT * FROM jsonb_to_recordset(${JSON.stringify(entities)}::jsonb) AS v(
"entityId" INTEGER,
"entityType" VARCHAR
)
)
SELECT
i.id,
i.name,
i.url,
i."nsfwLevel",
i.width,
i.height,
i.hash,
i."hideMeta",
(
CASE
WHEN i.meta IS NULL OR jsonb_typeof(i.meta) = 'null' OR i."hideMeta" THEN FALSE
ELSE TRUE
END
) AS "hasMeta",
(
CASE
WHEN i.meta IS NOT NULL AND jsonb_typeof(i.meta) != 'null' AND NOT i."hideMeta"
AND i.meta->>'prompt' IS NOT NULL
THEN TRUE
ELSE FALSE
END
) AS "hasPositivePrompt",
i."createdAt",
i."mimeType",
i.type,
i.metadata,
i."scannedAt",
i."needsReview",
i."userId",
i."index",
i."postId",
t."entityId",
t."entityType",
i."poi",
i."minor"
FROM (
-- NOTE: Adding "order1/2/3" looks a bit hacky, but it avoids using partitions and makes it far more performant.
-- It might may look weird, but it has 0 practical effect other than better performance.
SELECT
*
FROM
(
-- MODEL
SELECT DISTINCT ON (e."entityId")
e."entityId",
e."entityType",
i.id as "imageId",
mv.index "order1",
p.id "order2",
i.index "order3"
FROM entities e
JOIN "Model" m ON e."entityId" = m.id
JOIN "ModelVersion" mv ON m.id = mv."modelId"
JOIN "Post" p ON mv.id = p."modelVersionId" AND p."userId" = m."userId"
JOIN "Image" i ON p.id = i."postId"
WHERE e."entityType" = 'Model'
AND m.status = 'Published'
AND i."ingestion" = 'Scanned'
AND i."needsReview" IS NULL
AND (
(i."nsfwLevel" & ${nsfwBrowsingLevelsFlag}) = 0
OR NOT i."modelRestricted"
)
ORDER BY e."entityId", mv.index, p.id, i.index
) t
UNION
-- MODEL VERSION
SELECT * FROM (
SELECT DISTINCT ON (e."entityId")
e."entityId",
e."entityType",
i.id as "imageId",
mv.index "order1",
p.id "order2",
i.index "order3"
FROM entities e
JOIN "ModelVersion" mv ON e."entityId" = mv."id"
JOIN "Post" p ON mv.id = p."modelVersionId"
JOIN "Image" i ON p.id = i."postId"
WHERE e."entityType" = 'ModelVersion'
AND mv.status = 'Published'
AND i."ingestion" = 'Scanned'
AND i."needsReview" IS NULL
AND (
(i."nsfwLevel" & ${nsfwBrowsingLevelsFlag}) = 0
OR NOT i."modelRestricted"
)
ORDER BY e."entityId", mv.index, p.id, i.index
) t
UNION
-- IMAGES
SELECT
e."entityId",
e."entityType",
e."entityId" AS "imageId",
0 "order1",
0 "order2",
0 "order3"
FROM entities e
WHERE e."entityType" = 'Image'
UNION
-- ARTICLES
SELECT * FROM (
SELECT DISTINCT ON (e."entityId")
e."entityId",
e."entityType",
i.id AS "imageId",
0 "order1",
0 "order2",
0 "order3"
FROM entities e
JOIN "Article" a ON a.id = e."entityId"
JOIN "Image" i ON a."coverId" = i.id
WHERE e."entityType" = 'Article'
AND a."publishedAt" IS NOT NULL
AND i."ingestion" = 'Scanned'
AND i."needsReview" IS NULL
) t
UNION
-- POSTS
SELECT * FROM (
SELECT DISTINCT ON(e."entityId")
e."entityId",
e."entityType",
i.id AS "imageId",
i."postId" "order1",
i.index "order2",
0 "order3"
FROM entities e
JOIN "Post" p ON p.id = e."entityId"
LEFT JOIN "ModelVersion" mv ON p."modelVersionId" = mv.id
JOIN "Image" i ON i."postId" = p.id
WHERE e."entityType" = 'Post'
AND p."publishedAt" IS NOT NULL
AND i."ingestion" = 'Scanned'
AND i."needsReview" IS NULL
AND (
(i."nsfwLevel" & ${nsfwBrowsingLevelsFlag}) = 0
OR NOT i."modelRestricted"
)
ORDER BY e."entityId", i."postId", i.index
) t
UNION
-- CONNECTIONS
SELECT * FROM (
-- There is one "ImageConnection" row per linked image, so this branch --
-- alone among the six -- can emit many rows per entity (fan-out p50 1,
-- p99 11, max 525). DISTINCT ON collapses it to the single row the JS
-- join below would have consumed anyway, which is what keeps the size of
-- this result set proportional to the number of entities requested.
--
-- The eligibility predicate belongs HERE rather than in the outer WHERE.
-- DISTINCT ON picks its row before any later filter runs, so collapsing
-- first and filtering afterwards could settle on an unscanned image and
-- leave the entity with no cover at all, even though a sibling connection
-- was eligible the whole time.
--
-- Both key columns are required. Every other branch pins a single
-- "entityType", so "entityId" alone identifies a row there; this branch
-- joins on the pair, so one id can legitimately recur across types.
--
-- "ImageConnection" carries no ordering column of its own -- no index, no
-- timestamp -- so nothing on the link records which image the author meant
-- to come first. The tiebreak is the image id, chosen for the properties
-- that can actually be guaranteed: it is a primary key, so the order is
-- total, never null, and leaves no residual tie for the planner to settle
-- arbitrarily. It is deliberately NOT claimed to be a first-attached rule.
-- "updateEntityImages" links already-existing images -- with arbitrary older
-- ids -- ahead of the ones it creates in the same call, so attaching an older
-- image on a later edit lowers the minimum and promotes that image to cover.
-- What the tiebreak buys is a stable, deterministic choice, not a
-- semantically-first one.
SELECT DISTINCT ON (e."entityId", e."entityType")
e."entityId",
e."entityType",
i.id AS "imageId",
0 "order1",
0 "order2",
0 "order3"
FROM entities e
JOIN "ImageConnection" ic ON ic."entityId" = e."entityId" AND ic."entityType" = e."entityType"
JOIN "Image" i ON i.id = ic."imageId"
WHERE i."ingestion" = 'Scanned'
AND i."needsReview" IS NULL
ORDER BY e."entityId", e."entityType", i.id
) t
) t
JOIN "Image" i ON i.id = t."imageId"
WHERE i."ingestion" = 'Scanned' AND i."needsReview" IS NULL`;
// Index once instead of scanning `imagesRaw` per entity. `set` is guarded so the
// first row for a key wins, matching what `.find()` returned: an entity can still
// draw rows from two branches at once (an Article has both a cover image and
// content-image connections), and this must not silently switch which one is kept.
const imagesByEntity = new Map<string, GetEntityImageRaw>();
for (const image of imagesRaw) {
const key = `${image.entityId}:${image.entityType}`;
if (!imagesByEntity.has(key)) imagesByEntity.set(key, image);
}
const images = entities
.map((e) => imagesByEntity.get(`${e.entityId}:${e.entityType}`) ?? null)
.filter(isDefined);
let tagsVar: (VotableTagModel & { imageId: number })[] | undefined = [];
if (include && include.includes('tags')) {
const imageIds = images.map((i) => i.id);
tagsVar = await getImageTagsForImages(imageIds);
}
const cosmetics = await getCosmeticsForEntity({ ids: images.map((i) => i.id), entity: 'Image' });
return attachTagsToImages(images, tagsVar).map((i) => ({
...i,
cosmetic: cosmetics[i.id],
}));
};
export const updateEntityImages = async ({
tx,
entityId,
entityType,
images,
userId,
}: {
tx?: Prisma.TransactionClient;
entityId: number;
entityType: string;
images: ImageUploadProps[];
userId: number;
}) => {
const dbClient = tx ?? dbWrite;
const connections = await dbClient.imageConnection.findMany({
select: { imageId: true },
where: {
entityId,
entityType,
},
});
// Delete any images that are no longer in the list.
await dbClient.imageConnection.deleteMany({
where: {
entityId,
entityType,
imageId: { notIn: images.map((i) => i.id).filter(isDefined) },
},
});
const newImages = images.filter((x) => !x.id);
const newLinkedImages = images.filter(
(x) => !!x.id && !connections.find((c) => c.imageId === x.id)
);
const links = [...newLinkedImages.map((i) => i.id)];
let imageRecords: {
id: number;
url: string;
type: MediaType;
width: number | null;
height: number | null;
}[] = [];
if (newImages.length > 0) {
await dbClient.image.createMany({
data: newImages.map((image) => ({
...image,
meta:
(sanitizeProvenance(image?.meta as Record<string, unknown> | null | undefined) as
| Prisma.JsonObject
| undefined) ?? Prisma.JsonNull,
userId,
resources: undefined,
})),
});
imageRecords = await dbClient.image.findMany({
select: { id: true, url: true, type: true, width: true, height: true },
where: {
url: { in: newImages.map((i) => i.url) },
ingestion: ImageIngestionStatus.Pending,
userId,
},
});
links.push(...imageRecords.map((i) => i.id));
// Process the new images just in case:
const shouldAddImageResources = !!entityType && ['Bounty', 'BountyEntry'].includes(entityType);
const batches = chunk(imageRecords, 50);
for (const batch of batches) {
if (shouldAddImageResources) {
await Promise.all(batch.map((image) => createImageResources({ imageId: image.id, tx })));
}
}
}
if (links.length > 0) {
// Create any new files.
await dbClient.imageConnection.createMany({
data: links.filter(isDefined).map((id) => ({
imageId: id,
entityId,
entityType,
})),
});
}
return imageRecords;
};
export async function get404Images() {
const imagesRaw = await dbRead.$queryRaw<
{ url: string; username: string; meta: ImageMetaProps | null }[]
>`
SELECT
u.username,
i.url,
i.meta
FROM "CollectionItem" ci
JOIN "Image" i ON i.id = ci."imageId"
JOIN "User" u ON u.id = i."userId" AND username IS NOT NULL
JOIN "Collection" c ON c.id = ci."collectionId"
WHERE c."userId" = -1
AND c.name = '404 Contest'
AND i."ingestion" = 'Scanned'
AND i."needsReview" IS NULL
AND (i."nsfwLevel" & ${sfwBrowsingLevelsFlag}) != 0
AND ci.status = 'ACCEPTED';
`;
const images = Object.values(imagesRaw).map(({ meta, username, url }) => {
const alt = truncate(meta?.prompt, { length: constants.altTruncateLength });
return [username, url, alt];
});
return images;
}
type NameReference = {
imageId: number;
tagId: number;
name: string;
};
// async function removeNameReference(imageIds: number[]) {
// const tasks = chunk(imageIds, 500).map((imageIds) => async () => {
// // Get images to de-reference
// const [targets, prompts] = await Promise.all([
// dbRead.$queryRaw<NameReference[]>`
// SELECT
// toi."imageId",
// t.id as "tagId",
// t.name
// FROM "TagsOnImageNew" toi
// JOIN "TagsOnTags" tot ON tot."toTagId" = toi."tagId"
// JOIN "Tag" t ON t.id = tot."toTagId"
// JOIN "Tag" f ON f.id = tot."fromTagId" AND f.name = 'real person'
// WHERE toi."imageId" IN (${Prisma.join(imageIds)});
// `,
// // Update prompts
// dbRead.$queryRaw<{ imageId: number; prompt: string }[]>`
// SELECT
// i.id as "imageId",
// meta->>'prompt' as prompt
// FROM "Image" i
// WHERE id IN (${Prisma.join(imageIds)});
// `,
// ]);
// // Update prompts
// for (const x of prompts) {
// const { name } = targets.find((target) => target.imageId === x.imageId) ?? {};
// if (!name) continue;
// x.prompt = promptWordReplace(x.prompt, name, 'person');
// }
// const promptsJson = JSON.stringify(prompts);
// await dbWrite.$executeRaw`
// WITH updates AS (
// SELECT
// CAST(t->>'imageId' as int) as id,
// t->>'prompt' as prompt
// FROM json_array_elements(${promptsJson}::json) t
// )
// UPDATE "Image" i
// SET meta = jsonb_set(meta, '{prompt}', to_jsonb(t.prompt)),
// "needsReview" = null,
// poi = false,
// ingestion = 'Scanned'::"ImageIngestionStatus",
// "blockedFor" = null
// FROM updates t
// WHERE t.id = i.id;
// `;
// await upsertTagsOnImageNew(
// targets.map(({ tagId, imageId }) => ({ tagId, imageId, disabled: true, needsReview: false }))
// );
// });
// await limitConcurrency(tasks, 3);
// }
export async function reportCsamImages({
imageIds,
user,
ip,
}: ReportCsamImagesInput & {
user: SessionUser;
ip?: string;
}) {
if (!user.isModerator) throw throwAuthorizationError();
await dbWrite.image.updateMany({
where: { id: { in: imageIds } },
data: { needsReview: 'csam' },
});
const images = await dbRead.image.findMany({
where: { id: { in: imageIds } },
select: { reports: { select: { reportId: true } } },
});
const reportIds = images.flatMap((x) => x.reports.map((x) => x.reportId));
await bulkSetReportStatus({ ids: reportIds, status: ReportStatus.Actioned, userId: user.id, ip });
}
export async function updateImageNsfwLevel({
id,
nsfwLevel,
userId,
status,
isModerator,
activity,
reason,
}: UpdateImageNsfwLevelOutput & {
userId: number;
isModerator?: boolean;
activity?: ImageModActivity['activity'];
}) {
if (!nsfwLevel) throw throwBadRequestError();
if (isModerator) {
const image = await dbRead.image.findUnique({
where: { id },
select: { metadata: true, postId: true },
});
if (!image) throw throwNotFoundError('Image not found');
const metadata = (image.metadata as ImageMetadata) ?? undefined;
if (activity === 'setNsfwLevelKono' && !reason) reason = 'Knights Vote';
const updatedMetadata = { ...metadata, nsfwLevelReason: reason ?? null };
await dbWrite.image.update({
where: { id },
data: { nsfwLevel, nsfwLevelLocked: true, metadata: updatedMetadata },
});
await imageMetadataCache.refresh(id);
// Current meilisearch image index gets locked specially when doing a single image update due to the cheer size of this index.
// Commenting this out should solve the problem.
// await imagesSearchIndex.updateSync([{ id, action: SearchIndexUpdateQueueAction.Update }]);
if (status) {
await dbWrite.imageRatingRequest.updateMany({
where: { imageId: id, status: 'Pending' },
data: { status },
});
}
await updateModel3DNsfwLevelForThumbnailImage({ imageId: id, postId: image.postId });
await trackModActivity(userId, {
entityType: 'image',
entityId: id,
activity: activity ?? 'setNsfwLevel',
});
} else {
// Track potential content leaking
// If the image is currently PG and the new level is R or higher, and the image isn't from the original user, increment the counter
const current = await dbWrite.image.findFirst({
where: { id },
select: { nsfwLevel: true, userId: true, nsfwLevelLocked: true },
});
if (!current) return;
if (
current?.nsfwLevel === NsfwLevel.PG &&
nsfwLevel >= NsfwLevel.R &&
current?.userId !== userId
) {
leakingContentCounter.inc();
}
if (!current.nsfwLevelLocked) {
await dbWrite.imageRatingRequest.upsert({
where: { imageId_userId: { imageId: id, userId: userId } },
create: {
nsfwLevel,
imageId: id,
userId: userId,
weight: current.userId === userId ? 3 : 1,
},
update: { nsfwLevel },
});
if (current.userId === userId) {
await addImageToQueue({
imageIds: id,
rankType: NewOrderRankType.Knight,
priority: 1,
});
}
}
}
return nsfwLevel;
}
// NOTE(moderator-migration): getImageRatingRequests + getDownleveledImages (the image-rating-review and
// downleveled-review queues) now live in the spoke app (apps/moderator). updateImageNsfwLevel STAYS — it
// backs user rating votes + the mod APIs (set-image-nsfw-level, retool) + new-order.
/**
* Timeout budget for the media-existence probe below. A moderator is waiting on this click, and an
* UNBOUNDED probe against a degraded store would turn a review action into a hung request — strictly
* worse than the bug it guards. Per `headObject`'s contract this bounds each network ATTEMPT, not
* wall clock: the SDK's retry sleep is not abort-aware, so worst case is this budget plus one
* backoff. An abort is not a not-found shape, so it lands on `unknown` and the publish proceeds.
*/
const RESOLVE_INGESTION_MEDIA_PROBE_TIMEOUT_MS = 5_000;
/**
* Ask the uploads bucket whether an image's object is actually there, as a three-valued answer.
*
* 🔴 THE BACKEND COMES FROM `getImageUploadBackend()`, NOT AN INLINE CLIENT + BUCKET LITERAL.
* `Image.url` is the key the UPLOAD path minted, so the only store that can answer about it is the
* one that path writes to. An open-coded `getB2ImageS3Client()` + `env.S3_IMAGE_B2_BUCKET ??
* 'civitai-media-uploads'` pair agrees with that resolver only by coincidence today: change where
* uploads land and the probe keeps asking the old bucket, which answers 404 for every key — and on
* this path an `absent` is a PERMANENT publish refusal, so the whole queue would fail closed while
* looking exactly like a real run of misses. Resolving through the same function the uploader uses
* removes that class WITHIN THIS PROCESS. (The sibling probe at
* `src/server/utils/stored-image-probe.ts` already resolves this way.)
*
* 🔴 SCOPE OF THAT CLAIM — IT IS ABOUT THIS RUNTIME ONLY, AND THE SPOKE IS NOT COVERED. Today this
* is a pure no-op: `getImageUploadBackend()` (`src/utils/s3-utils.ts`) returns exactly the
* `getB2ImageS3Client()` + `env.S3_IMAGE_B2_BUCKET ?? 'civitai-media-uploads'` pair it replaces.
* Its whole value is that a future move of the upload store moves the probe with it, here.
*
* The moderator spoke's copy of this probe (`apps/moderator/src/lib/server/ingestion.service.ts`)
* names a fixed backend — `getMediaProbeStorage().headObject({ backend: 'b2Image', key })` — and
* that is NOT the same asymmetry it looks like, so it is written out rather than filed as a TODO.
* `'b2Image'` is not a bucket literal; it is an alias in `@civitai/storage`'s wire enum
* (`packages/civitai-storage/src/schema.ts`) that the `apps/storage` service resolves in ITS OWN
* process (`apps/storage/src/lib/server/backends.ts`) from ITS OWN `S3_IMAGE_B2_*` env — a
* different deployment and a different Secret from this app's. So the spoke already resolves
* through an indirection; it just lands on a SECOND source of truth rather than this one. There is
* no `getImageUploadBackend()` equivalent to route it through, and adding one is not a rename.
*
* The consequence, stated so nobody re-derives it as symmetry: moving the upload store needs THREE
* coordinated changes, not one — this app's `S3_IMAGE_B2_*`, the storage service's `S3_IMAGE_B2_*`
* (or a new alias in that enum plus every `backend: 'b2Image'` call site), and the Go
* storage-resolver's `B2_MEDIA_BUCKET_NAME`, whose `media_locations` rows are already stamped
* `backend='backblaze'`. This change removes one of the three from the list of things that can be
* forgotten silently. It does not remove the other two.
*
* Calling it is deliberately INSIDE the caller's try (see `assertMediaPresentForPublish`), because
* it builds the S3 client, which throws when credentials are absent — that must land on `unknown`,
* not on a failed publish.
*/
async function probeImageMediaPresence(key: string): Promise<MediaProbeAnswer> {
// 🔴 No key check here, deliberately. `assertMediaPresentForPublish` classifies the url and only
// calls this with a value that already passed the SHARED `isProbeableMediaKey` — a copy of that
// test in each probe is exactly how two runtimes come to disagree about the same row.
const { s3, bucket } = await getImageUploadBackend();
const head = await headObject(bucket, key, s3, {
abortSignal: AbortSignal.timeout(RESOLVE_INGESTION_MEDIA_PROBE_TIMEOUT_MS),
});
if (head.status === 'present') return MediaPresence.Present;
if (head.status === 'absent') return MediaPresence.Absent;
return MediaPresence.Unknown;
}
// NOTE(moderator-migration): getIngestionErrorImages (the ingestion-error-review queue) now lives in
// the spoke app (apps/moderator, Kysely). resolveIngestionError STAYS — main's article-image-scan
// (resolveArticleImageScan) reuses it to pin an article image's nsfwLevel.
export async function resolveIngestionError({
id,
nsfwLevel,
userId,
}: {
id: number;
nsfwLevel: NsfwLevel;
userId: number;
}) {
const image = await dbRead.image.findUnique({
where: { id },
select: {
ingestion: true,
postId: true,
userId: true,
metadata: true,
url: true,
},
});
if (!image) throw new Error('Image not found');
/**
* 🔴 REFUSE TO PUBLISH AN IMAGE WHOSE MEDIA IS GONE.
*
* This function's whole effect is to make an image visible: `ingestion = 'Scanned'` plus a locked
* nsfwLevel. It did that unconditionally, and the queue that feeds it selects only on
* `ingestion = 'Error' AND nsfwLevel = 0` — so an image whose file can never be fetched was
* presented to a moderator for a rating exactly like a scan that merely timed out, and publishing
* it put a permanent 404 on the site. Measured over ~92,000 image creations in 24h: 10 such
* images, all 10 confirmed absent from the store, 8 of them published by the queue that day.
*
* Only `absent` refuses; see `@civitai/shared/missing-media` for why an unconsultable store must
* fail OPEN, and `@civitai/shared/media-key` for which urls are even askable. The same rule runs
* in the moderator spoke, from the same module, so the two cannot drift.
*/
await assertMediaPresentForPublish({
url: image.url,
probe: (key) => probeImageMediaPresence(key),
raise: (message) => throwBadRequestError(message),
// The mirror of onUnknown: a wrong bucket name 404s for EVERY key, so a fail-CLOSED
// misconfiguration would refuse every publish and look exactly like a real run of misses.
onRefused: (presence) =>
logToAxiom({
type: 'warning',
name: 'resolveIngestionError:media-absent-refused',
message: 'Refused to publish an image whose media cannot be served',
imageId: id,
presence,
}).catch(() => undefined),
// Silent fail-open is how a guard lies for months: with credentials rotated, every publish
// would take the `unknown` branch and this would be indistinguishable from a clean run.
// 🔴 `reason` is what makes the count readable: `headObject` RESOLVES `{ status: 'unknown' }`
// rather than throwing, so without it a store that answers 403 for every key and a client that
// cannot be built at all emit the same line with an empty `error`.
onUnknown: ({ reason, error }) =>
logToAxiom({
type: 'warning',
name: 'resolveIngestionError:media-probe-unknown',
message: 'Could not consult the uploads bucket; allowing the publish',
imageId: id,
reason,
error: summarizeProbeError(error),
}).catch(() => undefined),
/**
* 🔴 The short-circuit needs a counter too, and for a sharper reason than the other two.
* `isProbeableMediaKey` is a deliberate UNDER-approximation — it matches only the bare-uuid
* shape our upload endpoints mint, and several write paths accept a caller-supplied string —
* so it is KNOWN to decline real keys. Without this line that under-approximation is
* unmeasurable: a run in which the predicate declines every row emits nothing at all and is
* byte-identical to a run where the guard did its job.
*/
onSkipped: () =>
logToAxiom({
type: 'warning',
name: 'resolveIngestionError:media-probe-skipped',
message: 'Image.url is not a probeable media key; no existence check ran',
imageId: id,
}).catch(() => undefined),
});
const metadata = (image.metadata as ImageMetadata) ?? {};
await dbWrite.image.update({
where: { id },
data: {
nsfwLevel,
nsfwLevelLocked: true,
ingestion: ImageIngestionStatus.Scanned,
scannedAt: new Date(),
metadata: { ...metadata, nsfwLevelReason: 'Moderator ingestion error review' },
},
});
await imageMetadataCache.refresh(id);
await tagIdsForImagesCache.refresh(id);
if (image.postId) await updatePostNsfwLevel(image.postId);
await queueImageSearchIndexUpdate({
ids: [id],
action: SearchIndexUpdateQueueAction.Update,
});
await trackModActivity(userId, {
entityType: 'image',
entityId: id,
activity: 'setNsfwLevel',
});
}
// #region [image tools]
async function authorizeImagesAction({
imageIds,
user,
}: {
imageIds: number[];
user: SessionUser;
}) {
if (!user.isModerator) {
const images = await dbRead.image.findMany({
where: { id: { in: imageIds }, userId: user.id },
select: { id: true },
});
const validatedIds = images.map((x) => x.id);
if (!imageIds.every((id) => validatedIds.includes(id))) throw throwAuthorizationError();
}
}
export async function addImageTools({
data,
user,
}: {
data: AddOrRemoveImageToolsOutput['data'];
user: SessionUser;
}) {
await authorizeImagesAction({ imageIds: data.map((x) => x.imageId), user });
await dbWrite.imageTool.createMany({ data, skipDuplicates: true });
// Update these images if blocked:
const updated = await dbWrite.image.updateManyAndReturn({
where: { id: { in: data.map((x) => x.imageId) }, blockedFor: BlockedReason.AiNotVerified },
data: {
blockedFor: null,
// Ensures we do another run:
ingestion: 'Pending',
},
select: {
id: true,
url: true,
type: true,
},
});
enqueueImageIngestion({
images: updated,
name: 'add-image-tools',
userId: user.id,
lowPriority: true,
});
for (const { imageId } of data) {
purgeImageGenerationDataCache(imageId);
}
await queueImageSearchIndexUpdate({
ids: data.map((x) => x.imageId),
action: SearchIndexUpdateQueueAction.Update,
});
}
export async function removeImageTools({
data,
user,
}: {
data: AddOrRemoveImageToolsOutput['data'];
user: SessionUser;
}) {
await authorizeImagesAction({ imageIds: data.map((x) => x.imageId), user });
const toolsByImage = data.reduce<Record<number, number[]>>((acc, { imageId, toolId }) => {
if (!acc[imageId]) acc[imageId] = [];
acc[imageId].push(toolId);
return acc;
}, {});
await dbWrite.$transaction(
Object.entries(toolsByImage).map(([imageId, toolIds]) =>
dbWrite.imageTool.deleteMany({ where: { imageId: Number(imageId), toolId: { in: toolIds } } })
)
);
for (const { imageId } of data) {
purgeImageGenerationDataCache(imageId);
}
await queueImageSearchIndexUpdate({
ids: data.map((x) => x.imageId),
action: SearchIndexUpdateQueueAction.Update,
});
}
export async function updateImageTools({
data,
user,
}: {
data: UpdateImageToolsOutput['data'];
user: SessionUser;
}) {
await authorizeImagesAction({ imageIds: data.map((x) => x.imageId), user });
await dbWrite.$transaction(
data.map(({ imageId, toolId, notes }) =>
dbWrite.imageTool.update({
where: { imageId_toolId: { imageId, toolId } },
data: { notes },
select: { imageId: true },
})
)
);
for (const { imageId } of data) {
purgeImageGenerationDataCache(imageId);
}
}
// #endregion
// #region [image techniques]
export async function addImageTechniques({
data,
user,
}: {
data: AddOrRemoveImageTechniquesOutput['data'];
user: SessionUser;
}) {
await authorizeImagesAction({ imageIds: data.map((x) => x.imageId), user });
await dbWrite.imageTechnique.createMany({ data, skipDuplicates: true });
for (const { imageId } of data) {
purgeImageGenerationDataCache(imageId);
}
await queueImageSearchIndexUpdate({
ids: data.map((x) => x.imageId),
action: SearchIndexUpdateQueueAction.Update,
});
}
export async function removeImageTechniques({
data,
user,
}: {
data: AddOrRemoveImageTechniquesOutput['data'];
user: SessionUser;
}) {
await authorizeImagesAction({ imageIds: data.map((x) => x.imageId), user });
const techniquesByImage = data.reduce<Record<number, number[]>>(
(acc, { imageId, techniqueId }) => {
if (!acc[imageId]) acc[imageId] = [];
acc[imageId].push(techniqueId);
return acc;
},
{}
);
await dbWrite.$transaction(
Object.entries(techniquesByImage).map(([imageId, techniqueIds]) =>
dbWrite.imageTechnique.deleteMany({
where: { imageId: Number(imageId), techniqueId: { in: techniqueIds } },
})
)
);
for (const { imageId } of data) {
purgeImageGenerationDataCache(imageId);
}
await queueImageSearchIndexUpdate({
ids: data.map((x) => x.imageId),
action: SearchIndexUpdateQueueAction.Update,
});
}
export async function updateImageTechniques({
data,
user,
}: {
data: UpdateImageTechniqueOutput['data'];
user: SessionUser;
}) {
await authorizeImagesAction({ imageIds: data.map((x) => x.imageId), user });
await dbWrite.$transaction(
data.map(({ imageId, techniqueId, notes }) =>
dbWrite.imageTechnique.update({
where: { imageId_techniqueId: { imageId, techniqueId } },
data: { notes },
select: { imageId: true },
})
)
);
for (const { imageId } of data) {
purgeImageGenerationDataCache(imageId);
}
}
// #endregion
export function purgeImageGenerationDataCache(id: number) {
purgeCache({ tags: [`image-generation-data-${id}`] }).catch((error) =>
logToAxiom({
type: 'error',
name: 'purgeImageGenerationDataCache',
message: error.message,
error,
})
);
}
const strengthTypes: ModelType[] = ['TextualInversion', 'LORA', 'DoRA', 'LoCon'];
export async function getImageGenerationData({ id }: { id: number }) {
const image = await dbRead.image.findUnique({
where: { id },
select: {
hideMeta: true,
generationProcess: true,
meta: true,
type: true,
tools: {
orderBy: { tool: { priority: 'asc' } },
select: {
notes: true,
tool: {
select: {
id: true,
name: true,
icon: true,
domain: true,
priority: true,
},
},
},
},
techniques: {
select: {
notes: true,
technique: {
select: {
id: true,
name: true,
},
},
},
},
},
});
if (!image) throw throwNotFoundError();
const tools = image.tools.map(({ notes, tool }) => ({ ...tool, notes }));
const techniques = image.techniques.map(({ notes, technique }) => ({ ...technique, notes }));
const cachedResources = await imageResourcesCache.fetch([id]);
const resources = (cachedResources[id]?.resources ?? []).map((r) => ({
imageId: r.imageId,
modelVersionId: r.modelVersionId,
strength: r.strength,
modelId: r.modelId,
modelName: r.modelName,
modelType: r.modelType as ModelType,
versionId: r.modelVersionId, // versionId is the same as modelVersionId
versionName: r.versionName,
baseModel: r.baseModel,
}));
const parsedMeta = imageMetaOutput.safeParse(image.meta);
const data = parsedMeta.success ? parsedMeta.data : {};
const { 'Clip skip': legacyClipSkip, clipSkip = legacyClipSkip, external, ...rest } = data;
const meta =
parsedMeta.success && !image.hideMeta ? removeEmpty({ ...rest, clipSkip }) : undefined;
let onSite = false;
let process: string | undefined | null = undefined;
let hasControlNet = false;
if (meta) {
onSite = isImageMetaOnSite(meta);
if ('engine' in meta) {
process = meta.process ?? meta.type;
}
if (meta.comfy) {
hasControlNet = !!meta.controlNets?.length;
} else {
hasControlNet = Object.keys(meta).some((x) => x.toLowerCase().startsWith('controlnet'));
}
if (!process) {
if (meta.comfy) process = 'comfy';
else if (image.generationProcess === 'txt2imgHiRes') process = 'txt2img + Hi-Res';
else process = image.generationProcess;
if (process && hasControlNet) process += ' + ControlNet';
}
}
// On-site generations: let the generation graph decide which meta keys are
// real generator inputs (drops computed/derived nodes + unrelated legacy
// junk). Off-site/foreign metadata has no graph mapping, so leave undefined
// and the client shows all keys.
let displayKeys: string[] | undefined;
if (onSite && meta) {
const graphResources = resources.map((r) => ({
id: r.modelVersionId,
baseModel: r.baseModel,
model: { type: r.modelType },
strength: r.strength,
}));
displayKeys =
getGenerationDisplayKeys(meta as Record<string, unknown>, graphResources) ?? undefined;
}
return {
type: image.type,
onSite,
process,
meta,
displayKeys,
resources: resources.map((resource) => ({
...resource,
strength:
strengthTypes.includes(resource.modelType) && resource.strength
? resource.strength / 100
: undefined,
})),
tools,
techniques,
external,
canRemix: !image.hideMeta && !!meta?.prompt,
remixOfId: meta?.extra?.remixOfId,
remixOfIds: getRemixSourceIds(id, meta),
};
}
/**
* Every image this one was VERIFIED to have been derived from.
*
* `meta.extra.sourceImageIds` only. It is server-written by `sanitizeProvenance`
* after the orchestrator workflow was checked, and nothing else can put it on a
* row — a client-supplied value is stripped on the way in (see
* remix-provenance.ts, and `remix-provenance.test.ts:224`, which demonstrates in
* one assertion that an unverified `sourceImageIds` is stripped while
* `remixOfId` survives untouched).
*
* ⚠️ The older `meta.extra.remixOfId` is deliberately NOT read here, and adding
* it back is a product decision, not a bug fix. It is a client-declared claim
* with no verification behind it, and Justin ruled on 2026-08-27 that public
* attribution must not rest on it. This costs real coverage rather than only
* legacy rows: measured on prod that day, 28 images carried the old field
* against 39 with the new one over 8 hours, interleaved hour by hour with no
* downward trend, and zero images carried both. So roughly half of all remixes
* intentionally show no card. That is the accepted trade, not a gap to close.
*
* Validation goes through `storedSourceImageIds` rather than reading the field
* directly. That is load-bearing: `sanitizeProvenance` writes `verified`
* VERBATIM — the MAX_SOURCE_IMAGES cap lives in the three resolvers that produce
* it, not in the sink — so nothing about a stored row bounds this list. An
* earlier version of this comment claimed the writer capped it; it does not, and
* the read path is where every other reader in this feature re-applies both the
* cap and element validation.
*
* Exported for `__tests__/remix-of-provenance.test.ts`, which pins the exclusion
* above by name so it cannot be quietly unioned back.
*/
export function getRemixSourceIds(
imageId: number,
meta: { extra?: { sourceImageIds?: number[] } } | null | undefined
) {
// Self-reference is not a derivation, and it would render the image as its own
// source. Dedupe as well, so a repeated id shows once.
return [...new Set(storedSourceImageIds(meta) ?? [])].filter((sourceId) => sourceId !== imageId);
}
// LRU cache for contest collection items lookup - caches by imageId
// This avoids repeated database queries for the same image's contest participation
type ContestCollectionItem = {
id: number;
imageId: number;
addedById: number | null;
status: string;
tag: { id: number; name: string } | null;
collection: { id: number; name: string; metadata: Prisma.JsonValue; mode: 'Contest' };
scores: { userId: number; score: number }[];
rejectionReason: CollectionItemRejectionReason | null;
rejectionDetail: string | null;
};
const contestCollectionItemsCache = createLruCache({
name: 'contest-collection-items',
max: 100_000,
ttl: 30 * 60 * 1000, // 30 minutes
keyFn: (imageId: number) => `image:${imageId}`,
fetchFn: async (imageId: number) => {
return dbRead.$queryRaw<ContestCollectionItem[]>`
SELECT
ci.id,
ci."imageId",
ci."addedById",
ci.status,
ci."rejectionReason"::text as "rejectionReason",
ci."rejectionDetail",
CASE WHEN t.id IS NOT NULL
THEN jsonb_build_object('id', t.id, 'name', t.name)
ELSE NULL
END as tag,
jsonb_build_object('id', c.id, 'name', c.name, 'metadata', c.metadata, 'mode', c.mode) as collection,
COALESCE(
(SELECT jsonb_agg(jsonb_build_object('userId', cis."userId", 'score', cis.score))
FROM "CollectionItemScore" cis
WHERE cis."collectionItemId" = ci.id),
'[]'::jsonb
) as scores
FROM "CollectionItem" ci
JOIN "Collection" c ON c.id = ci."collectionId"
LEFT JOIN "Tag" t ON t.id = ci."tagId"
WHERE ci."imageId" = ${imageId}
AND c.mode = 'Contest'
`;
},
});
export const getImageContestCollectionDetails = async ({
id,
userId,
isModerator,
}: { userId?: number; isModerator?: boolean } & GetByIdInput) => {
const items = await contestCollectionItemsCache.fetch(id);
// Fetch all permissions in one query instead of N queries
const collectionIds = items.map((i) => i.collection.id);
const allPermissions = await getUserCollectionPermissionsByIds({
ids: collectionIds,
userId,
});
// `addedById` is destructured off rather than spread: it is only here to resolve the gate below,
// and it names who submitted an entry, which this public endpoint has never returned.
return items.map(({ addedById, ...i }) => {
const permissions = allPermissions.find((p) => p.collectionId === i.collection.id);
// This endpoint is public. The reason — and above all the reviewer's free text about
// someone else's entry — is only for the submitter, whoever manages the collection,
// and site moderators investigating reports about reviewer behaviour.
const canReadRejection =
(!!userId && userId === addedById) || !!permissions?.manage || !!isModerator;
return {
...i,
rejectionReason: canReadRejection ? i.rejectionReason : null,
rejectionDetail: canReadRejection ? i.rejectionDetail : null,
permissions,
collection: {
...i.collection,
metadata: (i.collection.metadata ?? {}) as CollectionMetadataSchema,
},
};
});
};
// this method should hopefully not be a lasting addition
export type ModerationImageModel = AsyncReturnType<typeof getImagesByUserIdForModeration>[number];
export async function getImagesByUserIdForModeration(userId: number) {
const { tags, meta, ...select } = imageSelect;
return await dbRead.image.findMany({
where: { userId },
select,
orderBy: { id: 'desc' },
});
}
export function addBlockedImage({ hash, reason }: { hash: bigint; reason: BlockImageReason }) {
return clickhouse?.insert({
table: 'blocked_images',
values: [{ hash: toClickhouseInt64(hash), reason }],
format: 'JSONEachRow',
});
}
export function bulkAddBlockedImages({
data,
}: {
data: { hash: bigint; reason: BlockImageReason }[];
}) {
if (data.length === 0) return;
const values = data.map(({ hash, reason }) => ({
hash: toClickhouseInt64(hash),
reason: reason.toString(),
}));
return clickhouse?.insert({
table: 'blocked_images',
values,
format: 'JSONEachRow',
});
}
export async function bulkRemoveBlockedImages(hashes: bigint[]) {
if (hashes.length === 0 || !clickhouse) return;
const blocked = await clickhouse.$query<{ hash: string; reason: string }>`
SELECT toString(hash) AS hash, reason
FROM "blocked_images"
WHERE hash IN (${hashes.map(toClickhouseInt64).join(',')}) AND disabled = false
`;
const values = blocked.map(({ hash, reason }) => ({
hash: toClickhouseInt64(hash),
reason,
disabled: true,
}));
return await clickhouse.insert({
table: 'blocked_images',
values,
format: 'JSONEachRow',
});
}
// export async function bulkRemoveBlockedImages({
// ids,
// hashes,
// }: {
// hashes?: bigint[] | number[];
// ids?: number[];
// }) {
// if (ids) {
// const images = await dbWrite.image.findMany({
// where: { id: { in: ids } },
// select: { pHash: true },
// });
// hashes = images.map((i) => i.pHash as bigint).filter(isDefined);
// }
// if (!hashes?.length) return;
// return dbWrite.blockedImage.deleteMany({ where: { hash: { in: hashes } } });
// }
// NOTE(moderator-migration): getImagesPendingIngestion (the images/to-ingest queue) now lives in the
// spoke app (apps/moderator, Kysely).
export async function queueImageSearchIndexUpdate({
ids,
action,
}: {
ids: number[];
action: SearchIndexUpdateQueueAction;
}) {
await imagesSearchIndex.queueUpdate(ids.map((id) => ({ id, action })));
await imagesMetricsSearchIndex.queueUpdate(ids.map((id) => ({ id, action })));
if (action === SearchIndexUpdateQueueAction.Delete) {
// Bust the thumbnail cache for deleted images
await thumbnailCache.refresh(ids);
// Remove the image from the knights of new order pool counters
await Promise.all([
...poolCounters.Knight.a.map((queue) => queue.reset({ id: ids })),
...poolCounters.Knight.b.map((queue) => queue.reset({ id: ids })),
...poolCounters.Templar.a.map((queue) => queue.reset({ id: ids })),
...poolCounters.Templar.b.map((queue) => queue.reset({ id: ids })),
]);
}
}
export async function getPostDetailByImageId({ imageId }: { imageId: number }) {
const image = await dbRead.image.findUnique({
where: { id: imageId },
select: { postId: true },
});
if (!image || !image.postId) return null;
const post = await dbRead.post.findUnique({
where: { id: image.postId },
select: { title: true, detail: true },
});
if (!post) return null;
return post;
}
export async function setVideoThumbnail({
imageId,
frame,
customThumbnail,
userId,
isModerator,
postId,
}: SetVideoThumbnailInput & { userId: number; isModerator?: boolean }) {
const db = await getDbWithoutLag('postImages', postId);
const image = await db.image.findUnique({
where: { id: imageId, userId: !isModerator ? userId : undefined },
select: { id: true, type: true, metadata: true, userId: true },
});
if (!image)
throw throwAuthorizationError("You don't have permission to set the thumbnail for this video.");
if (image.type !== MediaType.video) throw throwBadRequestError('This is not a video.');
let thumbnailId = customThumbnail?.id;
if (customThumbnail) {
const thumbnail = await createImage({
...customThumbnail,
userId: image.userId,
metadata: { parentId: image.id },
});
thumbnailId = thumbnail.id;
}
const videoMetadata = image.metadata as VideoMetadata;
const updated = await dbWrite.image.update({
where: { id: imageId },
data: { metadata: { ...videoMetadata, thumbnailFrame: frame, thumbnailId } },
});
// Clear up the thumbnail cache
await Promise.all([
preventReplicationLag('postImages', postId),
thumbnailCache.refresh(imageId),
imageMetadataCache.refresh(imageId),
queueImageSearchIndexUpdate({
ids: [imageId],
action: SearchIndexUpdateQueueAction.Update,
}),
]);
return updated;
}
export async function updateImageAcceptableMinor({
id,
acceptableMinor,
}: UpdateImageAcceptableMinorInput) {
const image = await dbWrite.image.update({
where: { id },
data: { acceptableMinor },
});
// Remove it from search index if minor is true
await queueImageSearchIndexUpdate({
ids: [id],
action: acceptableMinor
? SearchIndexUpdateQueueAction.Delete
: SearchIndexUpdateQueueAction.Update,
});
if (acceptableMinor) {
await invalidateManyImageExistence([id]);
}
return image;
}
export async function getImageResourcesFromImageId({
imageId,
tx,
}: {
imageId: number;
tx?: Prisma.TransactionClient;
}) {
const dbClient = tx ?? dbWrite;
const computed = await dbClient.$queryRaw<
{
id: number;
modelversionid: number | null;
name: string | null;
hash: string | null;
strength: number | null;
detected: boolean;
}[]
>`SELECT * FROM get_image_resources(${imageId}::int)`;
return computed;
}
export async function createImageResources({
imageId,
tx,
}: {
imageId: number;
tx?: Prisma.TransactionClient;
}) {
const dbClient = tx ?? dbWrite;
// Read the resources based on complex metadata and hash matches
const resources = await getImageResourcesFromImageId({ imageId, tx });
if (!resources.length) return null;
const withModelVersionId = resources
.map((x) => {
if (!x.modelversionid) return null;
return x;
})
.filter(isDefined);
const resourcesWithModelVersions = uniqBy(withModelVersionId, 'modelversionid');
if (resourcesWithModelVersions.length) {
const values = Prisma.join(
resourcesWithModelVersions.map(
(r) => Prisma.sql`(${r.id}, ${r.modelversionid}, ${r.strength}, ${r.detected})`
)
);
await dbClient.$queryRaw`
INSERT INTO "ImageResourceNew" ("imageId", "modelVersionId", strength, detected)
VALUES ${values}
ON CONFLICT ("imageId", "modelVersionId") DO UPDATE
SET
detected = excluded.detected,
strength = excluded.strength;
`;
}
const unmatchedHashes = new Set(
resources
.filter((r) => r.detected && !r.modelversionid && r.hash)
.map((r) => r.hash!.toLowerCase())
);
// Unguarded on purpose: an `unmatchedHashes.size > 0` guard skips the whole block for an image
// that just became fully matched, so the warning never clears after the user uploads the model.
const image = await dbClient.image.findUnique({
where: { id: imageId },
select: { meta: true },
});
const meta = (image?.meta ?? {}) as Record<string, any>;
const metaResources = (meta.resources ?? []) as {
type?: string;
name?: string;
hash?: string;
unmatched?: boolean;
}[];
let updated = false;
for (const resource of metaResources) {
const unmatched = !!resource.hash && unmatchedHashes.has(resource.hash.toLowerCase());
if (unmatched === !!resource.unmatched) continue;
// Set false, not deleted: bundles deployed before meta.unmatchedResources read an absent key as
// "legacy" and fall through to a count heuristic that over-reports. Removable once those are gone.
resource.unmatched = unmatched;
updated = true;
}
const unmatchedResources = deriveUnmatchedResources(resources, metaResources);
if (!isEqual(unmatchedResources, meta.unmatchedResources ?? [])) updated = true;
if (updated) {
await dbClient.image.update({
where: { id: imageId },
data: { meta: { ...meta, resources: metaResources, unmatchedResources } },
});
}
await imageResourcesCache.refresh(imageId);
return resources;
}
export const getMyImages = async ({
mediaTypes,
userId,
limit,
cursor = 0,
}: GetMyImagesInput & { userId: number }) => {
const allowedMediaTypes = mediaTypes.filter((x) => x !== MediaType.audio);
try {
const media = await dbRead.image.findMany({
select: { id: true, url: true, meta: true, createdAt: true, type: true },
where: {
userId,
type: {
in: allowedMediaTypes.length ? allowedMediaTypes : [MediaType.image, MediaType.video],
},
postId: { not: null },
ingestion: ImageIngestionStatus.Scanned,
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'desc' },
});
let nextCursor: number | undefined;
if (media.length > limit) {
const nextItem = media.pop();
nextCursor = nextItem?.id;
}
return {
items: media,
nextCursor,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
else throw throwDbError(error);
}
};
export const uploadImageFromUrl = async ({ imageUrl }: { imageUrl: string }) => {
const blob = await fetchBlob(imageUrl);
if (!blob) {
throw new Error('Failed to fetch image');
}
const imageKey = randomUUID();
const upload = await serverUploadImage({
file: blob,
key: imageKey,
bucket: env.S3_IMAGE_B2_BUCKET ?? 'civitai-media-uploads',
});
const data = await upload.done();
const meta = await getMetadata(imageUrl);
// Attempt to guess if this is a video or image based off of the sample image url.
// This is no accurate science for all scenarios, but should give out a decent result at least.
const isVideo = imageUrl.includes('.mp4') || imageUrl.includes('.mov');
const response = {
type: (isVideo ? 'video' : 'image') as MediaType,
meta: meta,
metadata: {
size: blob.size,
// We need a better way to determine the size of the content here. However, due to the fact that we can't
// present these images in the server size, we have no exact measurements. We can only assume the size.
// The front-end has an easier time determining the size of the content because they can render it.
...(isVideo
? {
width: 640,
height: 480,
}
: { width: 512, height: 512 }),
},
url: data.Key,
};
return response;
};
export async function getImagesModRules() {
const modRules = await fetchThroughCache(
REDIS_KEYS.CACHES.MOD_RULES.IMAGES,
async () => {
const rules = await dbRead.moderationRule.findMany({
where: { entityType: EntityType.Image, enabled: true },
select: { id: true, definition: true, action: true, reason: true },
orderBy: [{ order: 'asc' }],
});
return rules.map(({ definition, ...rule }) => ({
...rule,
definition: definition as RuleDefinition,
}));
},
{ ttl: CacheTTL.day }
);
return modRules;
}
export async function bustImageModRulesCache() {
await bustFetchThroughCache(REDIS_KEYS.CACHES.MOD_RULES.IMAGES);
}
export const toggleImageFlag = async ({ id, flag }: ToggleImageFlagInput) => {
const image = await dbRead.image.findUnique({
where: { id },
select: { [flag]: true },
});
if (!image) throw throwNotFoundError();
await dbWrite.image.update({
where: { id },
data: { [flag]: !image[flag] },
});
await imageMetadataCache.refresh(id);
// Ensure we update the search index:
await imagesMetricsSearchIndex.queueUpdate([{ id, action: SearchIndexUpdateQueueAction.Update }]);
return true;
};
export const updateImagesFlag = async ({
ids,
flag,
value,
}: Pick<ToggleImageFlagInput, 'flag'> & { ids: number[]; value: boolean }) => {
if (ids.length === 0) return false;
await dbWrite.image.updateMany({
where: { id: { in: ids } },
data: { [flag]: value },
});
await imageMetadataCache.refresh(ids);
// Ensure we update the search index:
await imagesMetricsSearchIndex.queueUpdate(
ids.map((id) => ({ id, action: SearchIndexUpdateQueueAction.Update }))
);
return true;
};
export async function refreshImageResources(imageId: number) {
await dbWrite.$queryRaw`
DELETE FROM "ImageResourceNew" WHERE "imageId" = ${imageId} AND detected
`;
await createImageResources({ imageId });
await imageResourcesCache.refresh(imageId);
return await dbWrite.imageResourceHelper.findMany({ where: { imageId } });
}
export async function addSeenImageIds(imageIds: number[], maxSize = 10000) {
if (imageIds.length === 0) return;
const key = REDIS_SYS_KEYS.QUEUES.SEEN_IMAGES;
const score = Date.now();
await sysRedis
.multi()
.zAdd(
key,
imageIds.map((id) => ({ score, value: id.toString() }))
)
.zRemRangeByRank(key, 0, -(maxSize + 1))
.exec()
.catch((e) => {
const err = e as Error;
logToAxiom(
{
type: 'search-redis-error',
error: err.message,
cause: err.cause,
stack: err.stack,
},
'temp-search'
).catch();
});
}
export async function getSeenImageIds(): Promise<number[]> {
const key = REDIS_SYS_KEYS.QUEUES.SEEN_IMAGES;
const ids = await sysRedis.zRange(key, 0, -1, { REV: true });
return ids.map((id) => parseInt(id, 10));
}
export async function getReportViolationDetailsForImages(
imageIds: number[]
): Promise<Record<number, { violation?: string; comment?: string; reason?: string }>> {
if (!imageIds.length) return {};
const reports = await dbRead.$queryRaw<
{ imageId: number; reason: string; details: Prisma.JsonValue }[]
>`
SELECT DISTINCT ON (ir."imageId") ir."imageId", r.reason, r.details
FROM "Report" r
JOIN "ImageReport" ir ON ir."reportId" = r.id
WHERE ir."imageId" IN (${Prisma.join(imageIds)})
AND r.reason = 'TOSViolation'
ORDER BY ir."imageId", r."createdAt" DESC
`;
const result: Record<number, { violation?: string; comment?: string; reason?: string }> = {};
for (const report of reports) {
const details = report.details as Record<string, string> | null;
result[report.imageId] = {
violation: details?.violation,
comment: details?.comment,
reason: report.reason,
};
}
return result;
}