diff --git a/apps/server/package.json b/apps/server/package.json index 805d2188..2c507220 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,7 @@ "date-fns": "^4.4.0", "effect": "4.0.0-rc.111", "grammy": "catalog:", + "jszip": "^3.10.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "sharp": "^0.35.3", diff --git a/apps/server/src/crypto/cookie-encryption.test.ts b/apps/server/src/crypto/cookie-encryption.test.ts index b7561cdd..bb56c209 100644 --- a/apps/server/src/crypto/cookie-encryption.test.ts +++ b/apps/server/src/crypto/cookie-encryption.test.ts @@ -197,6 +197,35 @@ describe("CookieEncryption", () => { }); describe("key derivation", () => { + test("decrypts scoped ciphertext without marking it as legacy", () => { + const encrypted = encryption.encryptScoped( + testCookieData, + testUserId, + "provider:twitter:cookies:v1", + ); + const decrypted = encryption.decryptScopedOrLegacy( + encrypted, + testUserId, + "provider:twitter:cookies:v1", + "legacy-telegram-id", + ); + + expect(decrypted).toEqual({ data: testCookieData, usedLegacyEncryption: false }); + }); + + test("decrypts legacy user-scoped ciphertext for migration", () => { + const legacyTelegramId = "123456"; + const encrypted = encryption.encrypt(testCookieData, legacyTelegramId); + const decrypted = encryption.decryptScopedOrLegacy( + encrypted, + testUserId, + "provider:twitter:cookies:v1", + legacyTelegramId, + ); + + expect(decrypted).toEqual({ data: testCookieData, usedLegacyEncryption: true }); + }); + test("should produce consistent results for same inputs", () => { const encrypted1 = encryption.encrypt(testCookieData, testUserId); const decrypted1 = encryption.decrypt(encrypted1, testUserId); @@ -245,41 +274,4 @@ describe("CookieEncryption", () => { expect(() => encryption2.decrypt(encrypted1, testUserId)).toThrow(); }); }); - - describe("performance characteristics", () => { - test("should encrypt and decrypt efficiently", () => { - const start = performance.now(); - - // Perform multiple operations - for (let i = 0; i < 100; i++) { - const encrypted = encryption.encrypt(testCookieData, testUserId); - const decrypted = encryption.decrypt(encrypted, testUserId); - expect(decrypted).toBe(testCookieData); - } - - const end = performance.now(); - const duration = end - start; - - // Should complete 100 encrypt/decrypt cycles in under 1 second - expect(duration).toBeLessThan(1000); - }); - - test("should handle concurrent operations", async () => { - const operations = Array.from({ length: 50 }, (_, i) => - Promise.resolve().then(() => { - const userId = `user_${i}`; - const data = `${testCookieData}_${i}`; - const encrypted = encryption.encrypt(data, userId); - const decrypted = encryption.decrypt(encrypted, userId); - return { original: data, decrypted, userId }; - }), - ); - - const results = await Promise.all(operations); - - for (const result of results) { - expect(result.decrypted).toBe(result.original); - } - }); - }); }); diff --git a/apps/server/src/handlers/image.ts b/apps/server/src/handlers/image.ts index 47eb65c3..cda67e4f 100644 --- a/apps/server/src/handlers/image.ts +++ b/apps/server/src/handlers/image.ts @@ -1,44 +1,53 @@ import { FormattedString } from "@grammyjs/parse-mode"; -import { CookieEncryption } from "@starlight/crypto"; import { resolveQueryEmbedding } from "@starlight/api/services/embedding-cache"; import * as EmbeddingsService from "@starlight/api/services/embeddings"; +import { hasTwitterCookies } from "@starlight/api/services/twitter-credential"; import { env, isTwitterUrl, Prisma, prisma } from "@starlight/utils"; import { Composer, InlineKeyboard, InlineQueryResultBuilder } from "grammy"; -import { webAppKeyboard } from "@/bot"; import type { Logger } from "@/logger"; -import { scrapperQueue } from "@/queue/scrapper"; import { runtime } from "@/services/runtime"; -import { Cookies } from "@/storage"; import type { Context } from "@/types"; const INLINE_QUERY_PAGE_SIZE = 50; const INLINE_QUERY_CANDIDATE_MULTIPLIER = 8; -const INLINE_QUERY_AUTHOR_REGEX = /(?:^|\s)@(?[A-Za-z0-9_]+)/gu; -const SET_COOKIES_LABEL = "Set cookies"; +const INLINE_QUERY_AUTHOR_REGEX = /(^|\s)@([A-Za-z0-9_]+)/g; -interface InlineImageSearchResult { +const createInlineImageResultId = ( + provider: string, + externalMediaId: string, + userId: string, +): string => { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(JSON.stringify(["v1", provider, externalMediaId, userId])); + return `m_${hasher.digest("base64url")}`; +}; + +const createInlineImageDedupeKey = ( + provider: string, + externalMediaId: string, + userId: string, + perceptualHash: string | null, +): string => + perceptualHash?.trim() + ? JSON.stringify(["hash", provider, perceptualHash.trim(), userId]) + : JSON.stringify(["identity", provider, externalMediaId, userId]); + +type InlineImageSearchResult = { photo_id: string; + photo_provider: string; + photo_user_id: string; s3_path: string; tweet_id: string; + source_url: string; username: string | null; height: number | null; width: number | null; final_score: number; -} - -interface InlineQueryLogFields { - candidateLimit?: number; - pageQueryLimit?: number; - pageSize?: number; - photoOffset?: number; - searchMode: string; - tweetSkip?: number; - userId: string; -} +}; async function runInlineImageQuery( logger: Logger, - fields: InlineQueryLogFields, + fields: Record, query: () => Promise, ): Promise { const startedAt = performance.now(); @@ -70,47 +79,6 @@ async function runInlineImageQuery( } } -async function fetchLegacyTweetsPage( - logger: Logger, - userId: string, - tweetSkip: number, - whereClause: Prisma.TweetWhereInput, -) { - return await runInlineImageQuery( - logger, - { searchMode: "legacy", userId, tweetSkip, pageSize: INLINE_QUERY_PAGE_SIZE }, - () => - prisma.tweet.findMany({ - where: { - userId, - photos: { - some: { - deletedAt: null, - s3Path: { not: null }, - }, - }, - ...whereClause, - }, - include: { - photos: { - where: { - deletedAt: null, - s3Path: { not: null }, - }, - orderBy: { - createdAt: "desc", - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - take: INLINE_QUERY_PAGE_SIZE, - skip: tweetSkip, - }), - ); -} - async function searchInlineImagesWithLegacyQuery( logger: Logger, userId: string, @@ -124,34 +92,79 @@ async function searchInlineImagesWithLegacyQuery( while (allPhotos.length < photoOffset + pageQueryLimit) { const { authors, textQuery } = parseInlineImageQuery(query); - const whereClause: Prisma.TweetWhereInput = {}; + const whereClause: Prisma.PostWhereInput = {}; if (authors.length > 0 && textQuery) { whereClause.AND = [ { OR: authors.map((author) => ({ - username: { contains: author, mode: "insensitive" }, + authorUsername: { contains: author, mode: "insensitive" }, })), }, - { tweetText: { contains: textQuery, mode: "insensitive" } }, + { + OR: [ + { text: { contains: textQuery, mode: "insensitive" } }, + { title: { contains: textQuery, mode: "insensitive" } }, + { tags: { has: textQuery } }, + ], + }, ]; } else if (authors.length > 0) { whereClause.OR = authors.map((author) => ({ - username: { contains: author, mode: "insensitive" }, + authorUsername: { contains: author, mode: "insensitive" }, })); } else if (textQuery) { - whereClause.tweetText = { contains: textQuery, mode: "insensitive" }; + whereClause.OR = [ + { text: { contains: textQuery, mode: "insensitive" } }, + { title: { contains: textQuery, mode: "insensitive" } }, + { tags: { has: textQuery } }, + ]; } - const tweets = await fetchLegacyTweetsPage(logger, userId, tweetSkip, whereClause); + const tweets = await runInlineImageQuery( + logger, + { searchMode: "legacy", userId, tweetSkip, pageSize: INLINE_QUERY_PAGE_SIZE }, + () => + prisma.post.findMany({ + where: { + userId, + media: { + some: { + deletedAt: null, + kind: "image", + s3Path: { not: null }, + }, + }, + ...whereClause, + }, + include: { + media: { + where: { + deletedAt: null, + kind: "image", + s3Path: { not: null }, + }, + orderBy: [{ createdAt: "desc" }, { provider: "desc" }, { id: "desc" }], + }, + }, + orderBy: [{ createdAt: "desc" }, { provider: "desc" }, { id: "desc" }], + take: INLINE_QUERY_PAGE_SIZE, + skip: tweetSkip, + }), + ); if (tweets.length === 0) { break; } for (const tweet of tweets) { - for (const photo of tweet.photos) { - const dedupeKey = photo.perceptualHash?.trim() || photo.id; + for (const photo of tweet.media) { + const dedupeKey = createInlineImageDedupeKey( + photo.provider, + photo.id, + photo.userId, + photo.perceptualHash, + ); if (seenPhotoKeys.has(dedupeKey)) { continue; @@ -160,9 +173,12 @@ async function searchInlineImagesWithLegacyQuery( seenPhotoKeys.add(dedupeKey); allPhotos.push({ photo_id: photo.id, + photo_provider: photo.provider, + photo_user_id: photo.userId, s3_path: photo.s3Path as string, tweet_id: tweet.id, - username: tweet.username, + source_url: tweet.sourceUrl, + username: tweet.authorUsername ?? tweet.username, height: photo.height, width: photo.width, final_score: 0, @@ -177,13 +193,13 @@ async function searchInlineImagesWithLegacyQuery( } function parseInlineImageQuery(query: string) { - const authors = [...query.matchAll(INLINE_QUERY_AUTHOR_REGEX)].map((match) => - match.groups!.author!.toLowerCase(), + const authors = [...query.matchAll(INLINE_QUERY_AUTHOR_REGEX)].map(([, , author]) => + author!.toLowerCase(), ); return { authors: [...new Set(authors)], - textQuery: query.replace(INLINE_QUERY_AUTHOR_REGEX, " ").replaceAll(/\s+/gu, " ").trim(), + textQuery: query.replace(INLINE_QUERY_AUTHOR_REGEX, " ").replace(/\s+/g, " ").trim(), }; } @@ -198,43 +214,64 @@ function getInlineQueryEmbedding(query: string) { ); } -interface InlineLexicalFragmentInputs { - queryContains: string; - queryLower: string; - queryStartsWith: string; - queryStartsWithSeries: string; -} +const composer = new Composer(); -function buildAuthorFilters(authors: string[]): { filter: Prisma.Sql; score: Prisma.Sql } { - if (authors.length === 0) { - return { filter: Prisma.empty, score: Prisma.sql`0.0` }; - } +const privateChat = composer.chatType("private"); - const filter = Prisma.sql`AND (${Prisma.join( - authors.map((author) => Prisma.sql`strpos(lower(COALESCE(t.username, '')), ${author}) > 0`), - " OR ", - )})`; - - const score = Prisma.sql`GREATEST(${Prisma.join( - authors.map( - (author) => - Prisma.sql`CASE - WHEN lower(COALESCE(t.username, '')) = ${author} THEN 1.0 - WHEN strpos(lower(COALESCE(t.username, '')), ${author}) = 1 THEN 0.88 - WHEN strpos(lower(COALESCE(t.username, '')), ${author}) > 0 THEN 0.76 +composer.on("inline_query").filter( + (ctx) => !isTwitterUrl(ctx.inlineQuery.query.trim()), + async (ctx) => { + const photoOffset = Number(ctx.inlineQuery.offset || "0") || 0; + const query = ctx.inlineQuery.query.trim(); + const userId = ctx.user?.id; + const { authors, textQuery } = parseInlineImageQuery(query); + const queryLower = textQuery.toLowerCase(); + const hasTextQuery = queryLower.length > 0; + const queryContains = `%${queryLower}%`; + const queryStartsWith = `${queryLower}%`; + const queryStartsWithSeries = `${queryLower} (%`; + const pageQueryLimit = INLINE_QUERY_PAGE_SIZE + 1; + const candidateLimit = Math.max( + (photoOffset + pageQueryLimit) * INLINE_QUERY_CANDIDATE_MULTIPLIER, + 200, + ); + const queryTime = new Date().toISOString(); + const photoDedupeKey = Prisma.sql`jsonb_build_array( + CASE WHEN NULLIF(p.perceptual_hash, '') IS NULL THEN 'identity' ELSE 'hash' END, + p.provider, + COALESCE(NULLIF(p.perceptual_hash, ''), p.external_id), + p.user_id + )::text`; + + const authorFilter = + authors.length > 0 + ? Prisma.sql`AND (${Prisma.join( + authors.map( + (author) => + Prisma.sql`strpos(lower(COALESCE(t.author_username, t.username, '')), ${author}) > 0`, + ), + " OR ", + )})` + : Prisma.empty; + + const authorScore = + authors.length > 0 + ? Prisma.sql`GREATEST(${Prisma.join( + authors.map( + (author) => + Prisma.sql`CASE + WHEN lower(COALESCE(t.author_username, t.username, '')) = ${author} THEN 1.0 + WHEN strpos(lower(COALESCE(t.author_username, t.username, '')), ${author}) = 1 THEN 0.88 + WHEN strpos(lower(COALESCE(t.author_username, t.username, '')), ${author}) > 0 THEN 0.76 ELSE 0.0 END`, - ), - ", ", - )})`; - - return { filter, score }; -} - -function buildLexicalMatch(inputs: InlineLexicalFragmentInputs): Prisma.Sql { - const { queryContains, queryLower, queryStartsWith, queryStartsWithSeries } = inputs; + ), + ", ", + )})` + : Prisma.sql`0.0`; - return Prisma.sql` + const lexicalMatch = hasTextQuery + ? Prisma.sql` ( EXISTS ( SELECT 1 @@ -251,22 +288,23 @@ function buildLexicalMatch(inputs: InlineLexicalFragmentInputs): Prisma.Sql { OR lower(general_tag.value) LIKE ${queryStartsWith} OR lower(general_tag.value) LIKE ${queryContains} ) - OR lower(COALESCE(t.tweet_text, '')) LIKE ${queryContains} + OR lower(COALESCE(t.text, '')) LIKE ${queryContains} + OR lower(COALESCE(t.title, '')) LIKE ${queryContains} + OR lower(COALESCE(t.author_name, '')) LIKE ${queryContains} + OR lower(COALESCE(t.author_username, '')) LIKE ${queryContains} OR EXISTS ( SELECT 1 - FROM jsonb_array_elements_text(COALESCE(t.tweet_data->'hashtags', '[]'::jsonb)) AS hashtag(value) - WHERE lower(hashtag.value) = ${queryLower} - OR lower(hashtag.value) LIKE ${queryStartsWith} - OR lower(hashtag.value) LIKE ${queryContains} + FROM unnest(t.tags) AS post_tag(value) + WHERE lower(post_tag.value) = ${queryLower} + OR lower(post_tag.value) LIKE ${queryStartsWith} + OR lower(post_tag.value) LIKE ${queryContains} ) ) - `; -} + ` + : Prisma.sql`FALSE`; -function buildCharacterScore(inputs: InlineLexicalFragmentInputs): Prisma.Sql { - const { queryContains, queryLower, queryStartsWith, queryStartsWithSeries } = inputs; - - return Prisma.sql` + const characterScore = hasTextQuery + ? Prisma.sql` COALESCE( ( SELECT MAX( @@ -282,13 +320,11 @@ function buildCharacterScore(inputs: InlineLexicalFragmentInputs): Prisma.Sql { ), 0.0 ) - `; -} + ` + : Prisma.sql`0.0`; -function buildTagLexicalScore(inputs: InlineLexicalFragmentInputs): Prisma.Sql { - const { queryContains, queryLower, queryStartsWith } = inputs; - - return Prisma.sql` + const tagLexicalScore = hasTextQuery + ? Prisma.sql` COALESCE( ( SELECT MAX( @@ -303,150 +339,239 @@ function buildTagLexicalScore(inputs: InlineLexicalFragmentInputs): Prisma.Sql { ), 0.0 ) - `; -} - -function buildHashtagScore(inputs: InlineLexicalFragmentInputs): Prisma.Sql { - const { queryContains, queryLower, queryStartsWith } = inputs; + ` + : Prisma.sql`0.0`; - return Prisma.sql` + const postTagScore = hasTextQuery + ? Prisma.sql` COALESCE( ( SELECT MAX( CASE - WHEN lower(hashtag.value) = ${queryLower} THEN 0.76 - WHEN lower(hashtag.value) LIKE ${queryStartsWith} THEN 0.62 - WHEN lower(hashtag.value) LIKE ${queryContains} THEN 0.5 + WHEN lower(post_tag.value) = ${queryLower} THEN 0.76 + WHEN lower(post_tag.value) LIKE ${queryStartsWith} THEN 0.62 + WHEN lower(post_tag.value) LIKE ${queryContains} THEN 0.5 ELSE 0.0 END ) - FROM jsonb_array_elements_text(COALESCE(t.tweet_data->'hashtags', '[]'::jsonb)) AS hashtag(value) + FROM unnest(t.tags) AS post_tag(value) ), 0.0 ) - `; -} - -interface SemanticInlineSearch { - authorFilter: Prisma.Sql; - authorScore: Prisma.Sql; - candidateLimit: number; - characterScore: Prisma.Sql; - hashtagScore: Prisma.Sql; - lexicalMatch: Prisma.Sql; - logger: Logger; - pageQueryLimit: number; - photoDedupeKey: Prisma.Sql; - photoOffset: number; - queryTime: string; - tagLexicalScore: Prisma.Sql; - textVector: string; - tweetTextScore: Prisma.Sql; - userId: string; -} + ` + : Prisma.sql`0.0`; + + const postTextScore = hasTextQuery + ? Prisma.sql`GREATEST( + CASE WHEN lower(COALESCE(t.text, '')) LIKE ${queryContains} THEN 0.34 ELSE 0.0 END, + CASE WHEN lower(COALESCE(t.title, '')) LIKE ${queryContains} THEN 0.34 ELSE 0.0 END, + CASE WHEN lower(COALESCE(t.author_name, '')) LIKE ${queryContains} THEN 0.3 ELSE 0.0 END, + CASE WHEN lower(COALESCE(t.author_username, '')) LIKE ${queryContains} THEN 0.3 ELSE 0.0 END + )` + : Prisma.sql`0.0`; + + let rankedPhotos: InlineImageSearchResult[] = []; + + if (userId) { + if (!hasTextQuery) { + const recencyAuthorFilter = + authors.length > 0 + ? Prisma.sql`AND (${Prisma.join( + authors.map( + (author) => + Prisma.sql`strpos(lower(COALESCE(t.author_username, t.username, '')), ${author}) > 0`, + ), + " OR ", + )})` + : Prisma.empty; + + rankedPhotos = await runInlineImageQuery( + ctx.logger, + { searchMode: "recency", userId, photoOffset, pageQueryLimit }, + () => + prisma.$queryRaw(Prisma.sql` + WITH ranked AS ( + SELECT + p.external_id AS photo_id, + p.provider AS photo_provider, + p.user_id AS photo_user_id, + p.s3_path, + t.external_id AS tweet_id, + t.source_url, + COALESCE(t.author_username, t.username) AS username, + p.height, + p.width, + p.created_at AS photo_created_at, + ROW_NUMBER() OVER ( + PARTITION BY ${photoDedupeKey} + ORDER BY p.created_at DESC, p.provider DESC, p.external_id DESC, p.user_id DESC + ) AS duplicate_rank + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider + WHERE p.user_id = ${userId} + AND p.deleted_at IS NULL + AND p.kind = 'image' + AND p.s3_path IS NOT NULL + ${recencyAuthorFilter} + ) + SELECT + photo_id, + photo_provider, + photo_user_id, + s3_path, + tweet_id, + source_url, + username, + height, + width, + 0.0 AS final_score + FROM ranked + WHERE duplicate_rank = 1 + ORDER BY photo_created_at DESC, photo_provider DESC, photo_id DESC, photo_user_id DESC + OFFSET ${photoOffset} + LIMIT ${pageQueryLimit} + `), + ); + } else { + let textEmbedding: number[] | null = null; + let shouldUseLegacyQuery = false; + + if (hasTextQuery) { + try { + textEmbedding = await getInlineQueryEmbedding(textQuery); + } catch (error) { + ctx.logger.warn( + { error, query: textQuery }, + "Inline image semantic search unavailable", + ); + } + + if (!textEmbedding) { + shouldUseLegacyQuery = true; + } + } -async function runSemanticInlineSearch( - search: SemanticInlineSearch, -): Promise { - const { - authorFilter, - authorScore, - candidateLimit, - characterScore, - hashtagScore, - lexicalMatch, - logger, - pageQueryLimit, - photoDedupeKey, - photoOffset, - queryTime, - tagLexicalScore, - textVector, - tweetTextScore, - userId, - } = search; - - return await runInlineImageQuery( - logger, - { searchMode: "semantic", userId, photoOffset, pageQueryLimit, candidateLimit }, - () => - prisma.$queryRaw(Prisma.sql` + if (shouldUseLegacyQuery) { + rankedPhotos = await searchInlineImagesWithLegacyQuery( + ctx.logger, + userId, + query, + photoOffset, + pageQueryLimit, + ); + } else if (textEmbedding) { + const textVector = `[${textEmbedding.join(",")}]`; + + rankedPhotos = await runInlineImageQuery( + ctx.logger, + { searchMode: "semantic", userId, photoOffset, pageQueryLimit, candidateLimit }, + () => + prisma.$queryRaw(Prisma.sql` WITH image_candidates AS ( - SELECT p.id, p.user_id - FROM photos p - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + SELECT p.external_id AS id, p.user_id, p.provider + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider WHERE p.user_id = ${userId} AND p.deleted_at IS NULL + AND p.kind = 'image' AND p.s3_path IS NOT NULL AND p.classification IS NOT NULL AND p.image_vec IS NOT NULL AND p.tag_vec IS NOT NULL ${authorFilter} - ORDER BY p.image_vec <=> ${textVector}::vector + ORDER BY p.image_vec <=> ${textVector}::vector, p.provider DESC, p.external_id DESC, p.user_id DESC LIMIT ${candidateLimit} ), tag_candidates AS ( - SELECT p.id, p.user_id - FROM photos p - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + SELECT p.external_id AS id, p.user_id, p.provider + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider WHERE p.user_id = ${userId} AND p.deleted_at IS NULL + AND p.kind = 'image' AND p.s3_path IS NOT NULL AND p.classification IS NOT NULL AND p.image_vec IS NOT NULL AND p.tag_vec IS NOT NULL ${authorFilter} - ORDER BY p.tag_vec <=> ${textVector}::vector + ORDER BY p.tag_vec <=> ${textVector}::vector, p.provider DESC, p.external_id DESC, p.user_id DESC LIMIT ${candidateLimit} ), lexical_candidates AS ( - SELECT p.id, p.user_id - FROM photos p - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + SELECT p.external_id AS id, p.user_id, p.provider + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider + CROSS JOIN LATERAL ( + SELECT COALESCE(MAX( + CASE + WHEN lower(lexical_value.value) = ${queryLower} THEN 3 + WHEN lower(lexical_value.value) LIKE ${queryStartsWith} THEN 2 + WHEN lower(lexical_value.value) LIKE ${queryContains} THEN 1 + ELSE 0 + END + ), 0) AS lexical_score + FROM jsonb_array_elements_text( + COALESCE(p.classification->'characters', '[]'::jsonb) + || COALESCE(p.classification->'tags', '[]'::jsonb) + || to_jsonb(COALESCE(t.tags, ARRAY[]::text[])) + || jsonb_build_array( + COALESCE(t.text, ''), COALESCE(t.title, ''), + COALESCE(t.author_name, ''), COALESCE(t.author_username, '') + ) + ) AS lexical_value(value) + ) lexical_rank WHERE p.user_id = ${userId} AND p.deleted_at IS NULL + AND p.kind = 'image' AND p.s3_path IS NOT NULL AND ${lexicalMatch} ${authorFilter} + ORDER BY lexical_rank.lexical_score DESC, p.provider DESC, p.external_id DESC, p.user_id DESC LIMIT ${candidateLimit} ), candidate_pool AS ( - SELECT DISTINCT id, user_id + SELECT DISTINCT id, user_id, provider FROM ( - SELECT id, user_id FROM image_candidates + SELECT id, user_id, provider FROM image_candidates UNION ALL - SELECT id, user_id FROM tag_candidates + SELECT id, user_id, provider FROM tag_candidates UNION ALL - SELECT id, user_id FROM lexical_candidates + SELECT id, user_id, provider FROM lexical_candidates ) candidates ), scored AS ( SELECT - p.id AS photo_id, + p.external_id AS photo_id, + p.provider AS photo_provider, + p.user_id AS photo_user_id, ${photoDedupeKey} AS dedupe_key, p.s3_path, p.height, p.width, - t.username, - t.id AS tweet_id, + COALESCE(t.author_username, t.username) AS username, + t.external_id AS tweet_id, + t.source_url, t.created_at AS tweet_created_at, COALESCE(1.0 - (p.image_vec <=> ${textVector}::vector), 0.0) AS s_image, COALESCE(1.0 - (p.tag_vec <=> ${textVector}::vector), 0.0) AS s_tag_semantic, ${characterScore} AS s_character, ${tagLexicalScore} AS s_tag_lexical, - ${hashtagScore} AS s_hashtag, - ${tweetTextScore} AS s_tweet_text, + ${postTagScore} AS s_post_tag, + ${postTextScore} AS s_post_text, ${authorScore} AS s_author FROM candidate_pool c - JOIN photos p ON p.id = c.id AND p.user_id = c.user_id - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + JOIN media p ON p.external_id = c.id AND p.user_id = c.user_id AND p.provider = c.provider + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider ), fused AS ( SELECT photo_id, + photo_provider, + photo_user_id, dedupe_key, s3_path, tweet_id, + source_url, username, tweet_created_at, height, @@ -454,7 +579,7 @@ async function runSemanticInlineSearch( ( (s_character * 0.4) + (GREATEST(s_tag_semantic, s_tag_lexical) * 0.24) + - (GREATEST(s_hashtag, s_tweet_text) * 0.12) + + (GREATEST(s_post_tag, s_post_text) * 0.12) + (s_image * 0.1) + (s_author * 0.08) + (0.02 * EXP(LN(0.5) * (EXTRACT(EPOCH FROM (${queryTime}::timestamptz - tweet_created_at)) / (180.0 * 24 * 3600.0)))) @@ -464,215 +589,125 @@ async function runSemanticInlineSearch( deduped AS ( SELECT photo_id, + photo_provider, + photo_user_id, s3_path, tweet_id, + source_url, username, height, width, final_score, ROW_NUMBER() OVER ( PARTITION BY dedupe_key - ORDER BY final_score DESC NULLS LAST, tweet_created_at DESC, photo_id DESC + ORDER BY final_score DESC NULLS LAST, tweet_created_at DESC, photo_provider DESC, photo_id DESC, photo_user_id DESC ) AS duplicate_rank FROM fused ) - SELECT photo_id, s3_path, tweet_id, username, height, width, final_score + SELECT photo_id, photo_provider, photo_user_id, s3_path, tweet_id, source_url, username, height, width, final_score FROM deduped WHERE duplicate_rank = 1 - ORDER BY final_score DESC NULLS LAST, photo_id DESC + ORDER BY final_score DESC NULLS LAST, photo_provider DESC, photo_id DESC, photo_user_id DESC OFFSET ${photoOffset} LIMIT ${pageQueryLimit} `), - ); -} - -interface RecencyInlineSearch { - authorFilter: Prisma.Sql; - logger: Logger; - pageQueryLimit: number; - photoDedupeKey: Prisma.Sql; - photoOffset: number; - userId: string; -} - -async function runRecencyInlineSearch( - search: RecencyInlineSearch, -): Promise { - const { authorFilter, logger, pageQueryLimit, photoDedupeKey, photoOffset, userId } = search; - - return await runInlineImageQuery( - logger, - { searchMode: "recency", userId, photoOffset, pageQueryLimit }, - () => - prisma.$queryRaw(Prisma.sql` - WITH ranked AS ( + ); + } else { + const lexicalFilter = hasTextQuery ? Prisma.sql`AND ${lexicalMatch}` : Prisma.empty; + + rankedPhotos = await runInlineImageQuery( + ctx.logger, + { searchMode: "lexical", userId, photoOffset, pageQueryLimit }, + () => + prisma.$queryRaw(Prisma.sql` + WITH scored AS ( SELECT - p.id AS photo_id, + p.external_id AS photo_id, + p.provider AS photo_provider, + p.user_id AS photo_user_id, + ${photoDedupeKey} AS dedupe_key, p.s3_path, - t.id AS tweet_id, - t.username, p.height, p.width, - p.created_at AS photo_created_at, - ROW_NUMBER() OVER ( - PARTITION BY ${photoDedupeKey} - ORDER BY p.created_at DESC, p.id DESC - ) AS duplicate_rank - FROM photos p - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + COALESCE(t.author_username, t.username) AS username, + t.external_id AS tweet_id, + t.source_url, + t.created_at AS tweet_created_at, + ${characterScore} AS s_character, + ${tagLexicalScore} AS s_tag_lexical, + ${postTagScore} AS s_post_tag, + ${postTextScore} AS s_post_text, + ${authorScore} AS s_author + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider WHERE p.user_id = ${userId} AND p.deleted_at IS NULL + AND p.kind = 'image' AND p.s3_path IS NOT NULL ${authorFilter} + ${lexicalFilter} + ), + fused AS ( + SELECT + photo_id, + photo_provider, + photo_user_id, + dedupe_key, + s3_path, + tweet_id, + source_url, + username, + tweet_created_at, + height, + width, + ( + (s_author * 0.56) + + (s_character * 0.22) + + (s_tag_lexical * 0.12) + + (GREATEST(s_post_tag, s_post_text) * 0.08) + + (0.02 * EXP(LN(0.5) * (EXTRACT(EPOCH FROM (NOW() - tweet_created_at)) / (180.0 * 24 * 3600.0)))) + ) AS final_score + FROM scored + ), + deduped AS ( + SELECT + photo_id, + photo_provider, + photo_user_id, + s3_path, + tweet_id, + source_url, + username, + height, + width, + final_score, + ROW_NUMBER() OVER ( + PARTITION BY dedupe_key + ORDER BY final_score DESC NULLS LAST, tweet_created_at DESC, photo_provider DESC, photo_id DESC, photo_user_id DESC + ) AS duplicate_rank + FROM fused ) - SELECT - photo_id, - s3_path, - tweet_id, - username, - height, - width, - 0.0 AS final_score - FROM ranked + SELECT photo_id, photo_provider, photo_user_id, s3_path, tweet_id, source_url, username, height, width, final_score + FROM deduped WHERE duplicate_rank = 1 - ORDER BY photo_created_at DESC, photo_id DESC + ORDER BY final_score DESC NULLS LAST, photo_provider DESC, photo_id DESC, photo_user_id DESC OFFSET ${photoOffset} LIMIT ${pageQueryLimit} - `), - ); -} - -interface RankedInlinePhotoSearch { - authors: string[]; - logger: Logger; - photoOffset: number; - query: string; - textQuery: string; - userId: string | undefined; -} - -async function searchRankedInlinePhotos( - search: RankedInlinePhotoSearch, -): Promise { - const { authors, logger, photoOffset, query, textQuery, userId } = search; - - const queryLower = textQuery.toLowerCase(); - const hasTextQuery = queryLower.length > 0; - const queryContains = `%${queryLower}%`; - const queryStartsWith = `${queryLower}%`; - const queryStartsWithSeries = `${queryLower} (%`; - const pageQueryLimit = INLINE_QUERY_PAGE_SIZE + 1; - const candidateLimit = Math.max( - (photoOffset + pageQueryLimit) * INLINE_QUERY_CANDIDATE_MULTIPLIER, - 200, - ); - const queryTime = new Date().toISOString(); - const photoDedupeKey = Prisma.sql`COALESCE(NULLIF(p.perceptual_hash, ''), p.id)`; - - const fragmentInputs = { - queryContains, - queryLower, - queryStartsWith, - queryStartsWithSeries, - }; - const authorFilters = buildAuthorFilters(authors); - const authorFilter = authorFilters.filter; - const authorScore = authorFilters.score; - const lexicalMatch = hasTextQuery ? buildLexicalMatch(fragmentInputs) : Prisma.sql`FALSE`; - const characterScore = hasTextQuery ? buildCharacterScore(fragmentInputs) : Prisma.sql`0.0`; - const tagLexicalScore = hasTextQuery ? buildTagLexicalScore(fragmentInputs) : Prisma.sql`0.0`; - const hashtagScore = hasTextQuery ? buildHashtagScore(fragmentInputs) : Prisma.sql`0.0`; - const tweetTextScore = hasTextQuery - ? Prisma.sql`CASE WHEN lower(COALESCE(t.tweet_text, '')) LIKE ${queryContains} THEN 0.34 ELSE 0.0 END` - : Prisma.sql`0.0`; - - if (!userId) { - return []; - } - - if (!hasTextQuery) { - return await runRecencyInlineSearch({ - authorFilter, - logger, - pageQueryLimit, - photoDedupeKey, - photoOffset, - userId, - }); - } - - let textEmbedding: number[] | null = null; - - try { - textEmbedding = await getInlineQueryEmbedding(textQuery); - } catch (error) { - logger.warn({ error, query: textQuery }, "Inline image semantic search unavailable"); - } - - if (!textEmbedding) { - return await searchInlineImagesWithLegacyQuery( - logger, - userId, - query, - photoOffset, - pageQueryLimit, - ); - } - - return await runSemanticInlineSearch({ - authorFilter, - authorScore, - candidateLimit, - characterScore, - hashtagScore, - lexicalMatch, - logger, - pageQueryLimit, - photoDedupeKey, - photoOffset, - queryTime, - tagLexicalScore, - textVector: `[${textEmbedding.join(",")}]`, - tweetTextScore, - userId, - }); -} - -const cookieEncryption = new CookieEncryption( - env.COOKIE_ENCRYPTION_KEY, - env.COOKIE_ENCRYPTION_SALT, -); - -const composer = new Composer(); - -const privateChat = composer.chatType("private"); - -composer - .on("inline_query") - .filter((ctx) => !isTwitterUrl(ctx.inlineQuery.query.trim())) - .use(async (ctx) => { - const photoOffset = Number(ctx.inlineQuery.offset || "0") || 0; - const query = ctx.inlineQuery.query.trim(); - const { authors, textQuery } = parseInlineImageQuery(query); - - const rankedPhotos = await searchRankedInlinePhotos({ - authors, - logger: ctx.logger, - photoOffset, - query, - textQuery, - userId: ctx.user?.id, - }); + `), + ); + } + } + } const photosForThisPage = rankedPhotos.slice(0, INLINE_QUERY_PAGE_SIZE); - if (photosForThisPage.length === 0 && !ctx.user?.cookies) { + if (photosForThisPage.length === 0 && (!ctx.user || !(await hasTwitterCookies(ctx.user.id)))) { // User didn't setup the bot yet await ctx.answerInlineQuery( [ InlineQueryResultBuilder.article(`id:no-photos:${ctx.from?.id}`, "Oops, no photos...", { reply_markup: new InlineKeyboard().url( - SET_COOKIES_LABEL, + "Set cookies", `${env.BASE_FRONTEND_URL}/settings`, ), }).text("No photos found, did you setup the bot?"), @@ -688,16 +723,20 @@ composer const results = photosForThisPage.map((photo) => { const photoUrl = `${env.BASE_CDN_URL}/${photo.s3_path}`; const caption = photo.username - ? FormattedString.link(`@${photo.username}`, `https://x.com/i/status/${photo.tweet_id}`) - : new FormattedString(`https://x.com/i/status/${photo.tweet_id}`); - - return InlineQueryResultBuilder.photo(photo.photo_id, photoUrl, { - caption: caption.caption, - caption_entities: caption.caption_entities, - thumbnail_url: photoUrl, - photo_height: photo.height ?? undefined, - photo_width: photo.width ?? undefined, - }); + ? FormattedString.link(`@${photo.username}`, photo.source_url) + : new FormattedString(photo.source_url); + + return InlineQueryResultBuilder.photo( + createInlineImageResultId(photo.photo_provider, photo.photo_id, photo.photo_user_id), + photoUrl, + { + caption: caption.caption, + caption_entities: caption.caption_entities, + thumbnail_url: photoUrl, + photo_height: photo.height ?? undefined, + photo_width: photo.width ?? undefined, + }, + ); }); // Calculate next offset for pagination @@ -711,96 +750,18 @@ composer is_personal: true, cache_time: 30, }); - }); - -privateChat - .command("cookies") - .filter((ctx) => !ctx.user?.cookies) - .use(async (ctx) => { - const keyboard = new InlineKeyboard().webApp(SET_COOKIES_LABEL, { - url: `${env.BASE_FRONTEND_URL}/settings`, - }); - - await ctx.reply("No cookies found. Please set your cookies first.", { - reply_markup: keyboard, - }); - }); - -privateChat - .command("cookies") - .filter((ctx) => Boolean(ctx.user?.cookies)) - .use(async (ctx) => { - try { - const userCookies = ctx.user?.cookies; - - if (!(userCookies && ctx.user)) { - await ctx.reply("No cookies found."); - return; - } - - const cookiesJson = cookieEncryption.safeDecrypt(userCookies, ctx.user.telegramId.toString()); - - const cookies = Cookies.fromJSON(cookiesJson); - const cookiesString = cookies.toString(); - - await ctx.reply(`Your cookies:\n\n${cookiesString}`); - } catch (error) { - ctx.logger.error({ error }, "Failed to decrypt cookies"); - await ctx.reply("Failed to decrypt cookies. Please try setting them again."); - } - }); - -privateChat - .command("scrapper") - .filter((ctx) => !ctx.user?.cookies) - .use(async (ctx) => { - const keyboard = new InlineKeyboard().webApp(SET_COOKIES_LABEL, { - url: `${env.BASE_FRONTEND_URL}/cookies`, - }); - - await ctx.reply( - "Beep boop, you need to give me your cookies before I can send you daily images.", - { reply_markup: keyboard }, - ); - }); - -privateChat - .command("scrapper") - .filter((ctx) => Boolean(ctx.user?.cookies)) - .use(async (ctx) => { - const user = ctx.user!; - const schedulerId = `scrapper-${user.id}`; - const scheduledJob = await scrapperQueue.getJobScheduler(schedulerId); - - if (scheduledJob) { - await scrapperQueue.add( - "scrapper", - { userId: user.id, count: 0, limit: 100 }, - { deduplication: { id: schedulerId } }, - ); - - await ctx.reply("Starting to collect images, check back in a few minutes."); - } else { - ctx.logger.debug({ userId: user.id }, "Scheduled scrapper"); - - await scrapperQueue.upsertJobScheduler( - schedulerId, - { - every: 1000 * 60 * 60 * 6, - }, - { - data: { userId: user.id, count: 0, limit: 300 }, - name: schedulerId, - }, - ); + }, +); - await ctx.reply( - "You placed in the queue (runs every 6 hours). You can check your images in a few minutes in your gallery.\n\nYou can start the job anytime by sending /scrapper command again.", - { - reply_markup: webAppKeyboard("app", "View gallery"), - }, - ); - } +privateChat.command("cookies", async (ctx) => { + const connected = ctx.user ? await hasTwitterCookies(ctx.user.id) : false; + const keyboard = new InlineKeyboard().webApp(connected ? "Manage Twitter" : "Set cookies", { + url: `${env.BASE_FRONTEND_URL}/settings`, }); + const message = connected + ? "Twitter is connected. Use settings to replace or delete your cookies." + : "No cookies found. Please set your cookies first."; + await ctx.reply(message, { reply_markup: keyboard }); +}); export default composer; diff --git a/apps/server/src/handlers/pixiv.ts b/apps/server/src/handlers/pixiv.ts new file mode 100644 index 00000000..70c1b92d --- /dev/null +++ b/apps/server/src/handlers/pixiv.ts @@ -0,0 +1,98 @@ +import { withPixivClient } from "@starlight/api/services/pixiv-credential"; +import { Composer, InputFile } from "grammy"; +import { readResponseBounded } from "@/services/media-download"; +import { + convertUgoira, + extractUgoiraZip, + MAX_PIXIV_DOWNLOAD_BYTES, + parsePixivArtworkUrl, +} from "@/services/pixiv-media"; +import type { Context } from "@/types"; + +const MAX_MANGA_BYTES = 150_000_000; +const pixivHandler = new Composer(); + +const createTemporaryDirectory = async () => { + const directory = `${Bun.env.TMPDIR ?? "/tmp"}/starlight-pixiv-${Bun.randomUUIDv7()}`; + const child = Bun.spawn(["mkdir", "-m", "700", directory], { + stdout: "ignore", + stderr: "ignore", + }); + if ((await child.exited) !== 0) { + throw new Error("Failed to create Pixiv temporary directory"); + } + return directory; +}; + +const removeTemporaryDirectory = async (directory: string) => { + const child = Bun.spawn(["rm", "-rf", directory], { stdout: "ignore", stderr: "ignore" }); + await child.exited; +}; + +pixivHandler.on("message:text").filter( + (ctx) => parsePixivArtworkUrl(ctx.message.text.trim()) !== null, + async (ctx) => { + const id = parsePixivArtworkUrl(ctx.message.text.trim())!; + const user = ctx.user!; + const directory = await createTemporaryDirectory(); + try { + const handled = await withPixivClient(user.id, async (client) => { + const artwork = await client.artwork(id); + if (artwork.type === "ugoira") { + const metadata = await client.ugoira(id); + const archive = await readResponseBounded( + await client.fetchMedia(metadata.zipUrls.medium), + ); + const extracted = await extractUgoiraZip(archive, metadata.frames); + try { + const output = `${extracted.directory}/ugoira.mp4`; + await convertUgoira(extracted.concatPath, output); + await ctx.replyWithVideo(new InputFile(output), { + caption: artwork.title, + }); + } finally { + await removeTemporaryDirectory(extracted.directory); + } + return true; + } + + let aggregateBytes = 0; + const files: InputFile[] = []; + for (const [position, url] of artwork.mediaUrls.entries()) { + const bytes = await readResponseBounded( + await client.fetchMedia(url), + Math.min(MAX_PIXIV_DOWNLOAD_BYTES, MAX_MANGA_BYTES - aggregateBytes), + ); + aggregateBytes += bytes.byteLength; + const extension = new URL(url).pathname.split(".").at(-1) ?? "jpg"; + const path = `${directory}/${position}.${extension}`; + await Bun.write(path, bytes); + files.push(new InputFile(path)); + } + for (let offset = 0; offset < files.length; offset += 10) { + const chunk = files.slice(offset, offset + 10); + const single = chunk.at(0); + if (chunk.length === 1 && single) { + await ctx.replyWithDocument(single, { caption: artwork.title }); + } else { + await ctx.replyWithMediaGroup( + chunk.map((file, index) => ({ + type: "document" as const, + media: file, + caption: index === 0 ? artwork.title : undefined, + })), + ); + } + } + return true; + }); + if (handled === undefined) { + await ctx.reply("Connect Pixiv in Settings first."); + } + } finally { + await removeTemporaryDirectory(directory); + } + }, +); + +export default pixivHandler; diff --git a/apps/server/src/handlers/scrapper.ts b/apps/server/src/handlers/scrapper.ts new file mode 100644 index 00000000..68667442 --- /dev/null +++ b/apps/server/src/handlers/scrapper.ts @@ -0,0 +1,212 @@ +import { hasTwitterCookies } from "@starlight/api/services/twitter-credential"; +import { env, prisma } from "@starlight/utils"; +import { Composer, InlineKeyboard } from "grammy"; +import { webAppKeyboard } from "@/bot"; +import type { Logger } from "@/logger"; +import { pixivQueue, SCHEDULED_PIXIV_INTERVAL_SECONDS } from "@/queue/pixiv"; +import { + FEED_SCRAPPER_QUEUE, + scrapperQueue, + SCHEDULED_SCRAPPER_INTERVAL_SECONDS, +} from "@/queue/scrapper"; +import type { Context } from "@/types"; + +type ProviderCollectionResult = { + immediateStarted: boolean; + scheduleReady: boolean; +}; + +type ProviderConnection = "connected" | "disconnected" | "lookup-failed"; + +type ScrapperConnections = { + pixiv: ProviderConnection; + twitter: ProviderConnection; +}; + +const composer = new Composer(); +const privateChat = composer.chatType("private"); + +async function getScrapperConnections(ctx: Context): Promise { + const user = ctx.user!; + const [twitter, pixivCredential] = await Promise.allSettled([ + hasTwitterCookies(user.id), + prisma.providerCredential.findUnique({ + where: { userId_provider: { userId: user.id, provider: "pixiv" } }, + select: { credentialType: true }, + }), + ]); + if (twitter.status === "rejected") { + ctx.logger.warn( + { error: twitter.reason, userId: user.id, provider: "twitter" }, + "Failed to check provider connection", + ); + } + if (pixivCredential.status === "rejected") { + ctx.logger.warn( + { error: pixivCredential.reason, userId: user.id, provider: "pixiv" }, + "Failed to check provider connection", + ); + } + + return { + twitter: + twitter.status === "rejected" + ? "lookup-failed" + : twitter.value + ? "connected" + : "disconnected", + pixiv: + pixivCredential.status === "rejected" + ? "lookup-failed" + : pixivCredential.value?.credentialType === "refresh_token" + ? "connected" + : "disconnected", + }; +} + +async function startTwitterCollection( + userId: string, + updateId: number, + logger: Logger, +): Promise { + let scheduleReady = false; + + try { + const schedulerId = `scrapper-${userId}`; + const scheduledJob = await scrapperQueue.getJobScheduler(schedulerId); + await scrapperQueue.upsertJobScheduler( + schedulerId, + { every: SCHEDULED_SCRAPPER_INTERVAL_SECONDS * 1000 }, + { name: FEED_SCRAPPER_QUEUE, data: { count: 0, limit: 300, userId } }, + ); + scheduleReady = true; + if (!scheduledJob) { + logger.debug({ userId, provider: "twitter" }, "Scheduled collector"); + } + } catch (error) { + logger.warn({ error, userId, provider: "twitter" }, "Failed to schedule collector"); + } + + let immediateStarted = false; + try { + await scrapperQueue.add( + FEED_SCRAPPER_QUEUE, + { userId, count: 0, limit: scheduleReady ? 300 : 100 }, + { + deduplication: { id: `manual-scrapper-${userId}-${updateId}` }, + }, + ); + immediateStarted = true; + } catch (error) { + logger.warn({ error, userId, provider: "twitter" }, "Failed to start collector"); + } + + return { immediateStarted, scheduleReady }; +} + +async function startPixivCollection( + userId: string, + updateId: number, + logger: Logger, +): Promise { + const runId = `manual-${userId}-${updateId}`; + const [schedule, immediate] = await Promise.allSettled([ + pixivQueue.upsertJobScheduler( + `pixiv-${userId}`, + { every: SCHEDULED_PIXIV_INTERVAL_SECONDS * 1000 }, + { name: "pixiv-bookmarks", data: { userId, runId: "scheduled", count: 0, limit: 300 } }, + ), + pixivQueue.add( + "pixiv-bookmarks", + { userId, runId, count: 0, limit: 300 }, + { + deduplication: { id: `pixiv-${userId}-${runId}` }, + }, + ), + ]); + + if (schedule.status === "rejected") { + logger.warn( + { error: schedule.reason, userId, provider: "pixiv" }, + "Failed to schedule collector", + ); + } + if (immediate.status === "rejected") { + logger.warn( + { error: immediate.reason, userId, provider: "pixiv" }, + "Failed to start collector", + ); + } + + return { + immediateStarted: immediate.status === "fulfilled", + scheduleReady: schedule.status === "fulfilled", + }; +} + +privateChat.command("scrapper", async (ctx) => { + const user = ctx.user!; + const connections = await getScrapperConnections(ctx); + const hasConnectedProvider = + connections.twitter === "connected" || connections.pixiv === "connected"; + + if (!hasConnectedProvider) { + const lookupFailures = [ + connections.twitter === "lookup-failed" && "• Twitter: connection check failed.", + connections.pixiv === "lookup-failed" && "• Pixiv: connection check failed.", + ].filter((line) => line !== false); + const keyboard = new InlineKeyboard().webApp("Connect providers", { + url: `${env.BASE_FRONTEND_URL}/settings`, + }); + await ctx.reply( + lookupFailures.length > 0 + ? [ + "Collection was not started:", + ...lookupFailures, + "Try /scrapper again. Connect any disconnected providers in Settings.", + ].join("\n") + : "Connect Twitter or Pixiv in Settings before starting collection.", + { reply_markup: keyboard }, + ); + } else { + const [twitter, pixiv] = await Promise.all([ + connections.twitter === "connected" + ? startTwitterCollection(user.id, ctx.update.update_id, ctx.logger) + : Promise.resolve(null), + connections.pixiv === "connected" + ? startPixivCollection(user.id, ctx.update.update_id, ctx.logger) + : Promise.resolve(null), + ]); + const results = [ + twitter && { provider: "Twitter", ...twitter }, + pixiv && { provider: "Pixiv", ...pixiv }, + ].filter((result) => result !== null); + const lookupFailures = [ + connections.twitter === "lookup-failed" && + "• Twitter: connection check failed; collection was not started.", + connections.pixiv === "lookup-failed" && + "• Pixiv: connection check failed; collection was not started.", + ].filter((line) => line !== false); + const complete = + lookupFailures.length === 0 && + results.every((result) => result.immediateStarted && result.scheduleReady); + const lines = results.map( + (result) => + `• ${result.provider}: sync ${result.immediateStarted ? "started" : "failed"}; recurring schedule ${result.scheduleReady ? "ready" : "failed"}.`, + ); + + await ctx.reply( + [ + complete ? "Collection started:" : "Collection was only partially started:", + ...lines, + ...lookupFailures, + results.some((result) => result.immediateStarted) + ? "Check your gallery in a few minutes." + : "No immediate sync started. Try /scrapper again.", + ].join("\n"), + { reply_markup: webAppKeyboard("app", "View gallery") }, + ); + } +}); + +export default composer; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 705e6acb..3b230f5c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,14 +5,17 @@ import "@/services/runtime"; import chatMemberHandler from "@/handlers/chat-member"; import imageHandler from "@/handlers/image"; import messageHandler from "@/handlers/message"; +import pixivHandler from "@/handlers/pixiv"; +import scrapperHandler from "@/handlers/scrapper"; import startHandler from "@/handlers/start"; import tweetImageHandler from "@/handlers/tweet-image"; import videoHandler from "@/handlers/video"; import { logger } from "@/logger"; import { classificationQueue, classificationWorker } from "@/queue/classification"; import { embeddingsQueue, embeddingsWorker } from "@/queue/embeddings"; -import { imagesQueue, imagesWorker } from "@/queue/image-collector"; +import { mediaCollectorQueue, mediaCollectorWorker } from "@/queue/media-collector"; import { memoryQueue, memoryWorker } from "@/queue/memory"; +import { pixivQueue, pixivWorker } from "@/queue/pixiv"; import { scrapperQueue, scrapperWorker } from "@/queue/scrapper"; import { redis } from "@/storage"; @@ -31,20 +34,30 @@ const boundary = bot.errorBoundary((error) => { }); boundary.use(videoHandler); +boundary.use(pixivHandler); boundary.use(tweetImageHandler); +boundary.use(scrapperHandler); boundary.use(imageHandler); boundary.use(messageHandler); boundary.use(startHandler); boundary.use(chatMemberHandler); const workers = [ - imagesWorker, + mediaCollectorWorker, classificationWorker, embeddingsWorker, scrapperWorker, memoryWorker, + pixivWorker, +]; +const queues = [ + mediaCollectorQueue, + classificationQueue, + embeddingsQueue, + scrapperQueue, + memoryQueue, + pixivQueue, ]; -const queues = [imagesQueue, classificationQueue, embeddingsQueue, scrapperQueue, memoryQueue]; const runner = run(bot); for (const worker of workers) { diff --git a/apps/server/src/queue/classification-recovery.ts b/apps/server/src/queue/classification-recovery.ts new file mode 100644 index 00000000..3a7c7dad --- /dev/null +++ b/apps/server/src/queue/classification-recovery.ts @@ -0,0 +1,66 @@ +interface ClassificationQueue { + spawn( + name: string, + data: { photoId: string; provider: string; userId: string }, + options: { + idempotencyKey: string; + maxAttempts: number; + retryStrategy: unknown; + }, + ): Promise<{ created: boolean; taskID: string }>; + fetchTaskResult(taskID: string): Promise<{ state: string } | null | undefined>; + retryTask(taskID: string): Promise; +} + +interface ClassificationRecoveryDependencies { + classificationApp: ClassificationQueue; + retryStrategy: unknown; + logger: { + info(context: Record, message: string): void; + }; +} + +export async function enqueueClassification( + { classificationApp, retryStrategy, logger }: ClassificationRecoveryDependencies, + photoId: string, + provider: string, + userId: string, +) { + const idempotencyKey = `classify-${provider}-${userId}-${photoId}`; + const task = await classificationApp.spawn( + "classification", + { photoId, provider, userId }, + { + idempotencyKey, + maxAttempts: 5, + retryStrategy, + }, + ); + + if (task.created) { + return; + } + + const result = await classificationApp.fetchTaskResult(task.taskID); + if (result?.state !== "failed") { + return; + } + + // retryTask atomically changes the failed task back to pending. This keeps + // its idempotency key and lets every later terminal failure recover again. + // Omitting maxAttempts adds one attempt beyond the terminal task's count. + try { + await classificationApp.retryTask(task.taskID); + } catch (error) { + // Another collector may have recovered this task after our snapshot. Once + // it is no longer failed, that recovery is the coalesced outcome. + const latestResult = await classificationApp.fetchTaskResult(task.taskID); + if (!latestResult || latestResult.state === "failed") { + throw error; + } + } + logger.info( + { photoId, provider, userId, taskId: task.taskID }, + "Classification recovery enqueued", + ); +} diff --git a/apps/server/src/queue/classification.ts b/apps/server/src/queue/classification.ts index 2fb6853b..c3917209 100644 --- a/apps/server/src/queue/classification.ts +++ b/apps/server/src/queue/classification.ts @@ -8,6 +8,7 @@ import type { Classification } from "@/types"; interface ClassificationJobData { photoId: string; + provider?: string; requestId?: string; userId: string; } @@ -30,7 +31,7 @@ export const classificationWorker = new Worker( return; } - const { photoId, userId, requestId: incomingRequestId } = job.data; + const { photoId, provider = "twitter", userId, requestId: incomingRequestId } = job.data; const requestId = incomingRequestId || Bun.randomUUIDv7(); if (!(env.ML_BASE_URL && env.ML_API_TOKEN)) { @@ -41,8 +42,8 @@ export const classificationWorker = new Worker( logger.info({ photoId, userId, requestId }, "Classifying photo"); // Fetch photo record to get URL - const photo = await prisma.photo.findUnique({ - where: { photoId: { id: photoId, userId } }, + const photo = await prisma.media.findUnique({ + where: { mediaId: { id: photoId, provider, userId } }, select: { id: true, userId: true, @@ -105,17 +106,17 @@ export const classificationWorker = new Worker( throw error; } - await prisma.photo.update({ - where: { photoId: { id: photoId, userId } }, + await prisma.media.update({ + where: { mediaId: { id: photoId, provider, userId } }, data: { classification: data }, }); await embeddingsQueue.add( - `embed-${photoId}`, - { photoId, userId, requestId }, + `embed-${provider}-${photoId}`, + { photoId, provider, userId, requestId }, { - jobId: `embed-${photoId}-${userId}`, - deduplication: { id: `embed-${photoId}-${userId}` }, + jobId: `embed-${provider}-${photoId}-${userId}`, + deduplication: { id: `embed-${provider}-${photoId}-${userId}` }, }, ); diff --git a/apps/server/src/queue/embeddings.ts b/apps/server/src/queue/embeddings.ts index d59084b0..caa2b0c0 100644 --- a/apps/server/src/queue/embeddings.ts +++ b/apps/server/src/queue/embeddings.ts @@ -5,13 +5,14 @@ import { logger } from "@/logger"; import { runtime } from "@/services/runtime"; import { redis } from "@/storage"; -interface ClassificationJobData { +interface EmbeddingsJobData { photoId: string; + provider?: string; requestId?: string; userId: string; } -export const embeddingsQueue = new Queue("embeddings", { +export const embeddingsQueue = new Queue("embeddings", { connection: redis, defaultJobOptions: { attempts: 5, @@ -21,7 +22,7 @@ export const embeddingsQueue = new Queue("embeddings", { }, }); -export const embeddingsWorker = new Worker( +export const embeddingsWorker = new Worker( "embeddings", async (job) => { if (!env.ENABLE_EMBEDDINGS) { @@ -29,7 +30,7 @@ export const embeddingsWorker = new Worker( return; } - const { photoId, userId, requestId: incomingRequestId } = job.data; + const { photoId, provider = "twitter", userId, requestId: incomingRequestId } = job.data; const requestId = incomingRequestId || Bun.randomUUIDv7(); if (!(env.ML_BASE_URL && env.ML_API_TOKEN)) { @@ -39,9 +40,9 @@ export const embeddingsWorker = new Worker( logger.info({ photoId, userId, requestId }, "Generating photo embeddings"); - const photo = await prisma.photo.findUnique({ + const photo = await prisma.media.findUnique({ where: { - photoId: { id: photoId, userId }, + mediaId: { id: photoId, provider, userId }, classification: { not: DbNull }, }, select: { @@ -82,7 +83,7 @@ export const embeddingsWorker = new Worker( const imageVecStr = `[${(result.image ?? []).join(",")}]`; await prisma.$executeRaw( - Prisma.sql`UPDATE photos SET tag_vec = ${textVecStr}::vector, image_vec = ${imageVecStr}::vector WHERE id = ${photoId} AND user_id = ${userId}`, + Prisma.sql`UPDATE media SET tag_vec = ${textVecStr}::vector, image_vec = ${imageVecStr}::vector WHERE external_id = ${photoId} AND user_id = ${userId} AND provider = ${provider}`, ); logger.info({ photoId, userId, requestId }, "Photo embeddings generated"); diff --git a/apps/server/src/queue/image-collector.ts b/apps/server/src/queue/image-collector.ts deleted file mode 100644 index 0d3ad1ea..00000000 --- a/apps/server/src/queue/image-collector.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { prisma } from "@starlight/utils"; -import { http } from "@starlight/utils/http"; -import type { Tweet } from "@the-convocation/twitter-scraper"; -import { Queue, Worker } from "bullmq"; -import UserAgent from "user-agents"; -import { logger } from "@/logger"; -import { classificationQueue } from "@/queue/classification"; -import { findDuplicatesByImageContent } from "@/services/duplicate-detection"; -import { calculatePerceptualHash } from "@/services/image"; -import { redis, s3 } from "@/storage"; - -export interface ImageCollectorJobData { - tweet: Tweet; - // From database - userId: string; -} - -export const imagesQueue = new Queue("images-collector", { - connection: redis, - defaultJobOptions: { - attempts: 3, - backoff: { type: "exponential", delay: 10_000 }, - }, -}); - -export const imagesWorker = new Worker( - "images-collector", - async (job) => { - const { tweet, userId } = job.data; - - // Tweet guaranteed to have IDs, fucking types - const id = tweet.id!; - - logger.info({ tweetId: tweet.id, userId }, "Processing tweet"); - - if (tweet.photos.length === 0) { - logger.debug({ tweetId: tweet.id, userId }, "Tweet has no photos, skipping job"); - return; - } - - const userAgent = new UserAgent(); - - // We can safely update Tweet record here, because we created Tweet object in scrapper queue - const tweetRecord = await prisma.tweet.update({ - where: { tweetId: { userId, id } }, - data: { - tweetData: tweet, - photos: { - createMany: { - data: tweet.photos.map((photo) => ({ - id: photo.id, - originalUrl: photo.url, - })), - // Guaranteed that if we'll restart a job then we won't have additional photos in Tweet relation - skipDuplicates: true, - }, - }, - }, - include: { - photos: true, - }, - }); - - logger.info( - { tweetId: tweet.id, userId, photos: tweetRecord.photos.length }, - "Tweet upserted with photos", - ); - - const refreshedPhotoIds = new Set(); - const refreshTimestamp = new Date(); - - for (const photo of tweetRecord.photos) { - if (photo.s3Path && photo.perceptualHash) { - logger.debug( - { - tweetId: tweet.id, - photoId: photo.id, - userId, - }, - "Photo already downloaded; skipping", - ); - continue; - } - - const response = await http(photo.originalUrl, { - headers: { - "User-Agent": userAgent.toString(), - }, - }); - - if (!response.ok) { - logger.error( - { - tweetId: tweet.id, - photoUrl: photo.originalUrl, - status: response.status, - userId, - }, - "Failed to fetch photo", - ); - throw new Error(`Failed to fetch photo ${photo.originalUrl}`); - } - - const imageBuffer = await response.arrayBuffer(); - - const similarPhotos = await findDuplicatesByImageContent(imageBuffer); - - if (similarPhotos.length > 0) { - const existingPhoto = similarPhotos.find((similarPhoto) => similarPhoto.userId === userId); - - if (existingPhoto && !refreshedPhotoIds.has(existingPhoto.id)) { - await prisma.photo.update({ - where: { photoId: { id: existingPhoto.id, userId } }, - data: { updatedAt: refreshTimestamp }, - }); - refreshedPhotoIds.add(existingPhoto.id); - } - - logger.info( - { - tweetId: tweet.id, - photoId: photo.id, - userId, - refreshedPhotoId: existingPhoto?.id, - similarPhotos, - }, - "Found similar photos, skipping saving photo", - ); - } else { - const extension = photo.originalUrl.split(".").pop() ?? "jpg"; - - const photoName = `${photo.externalId}.${extension}`; - - const [, hash, metadata] = await Promise.all([ - s3.write(`media/${photoName}`, imageBuffer), - calculatePerceptualHash(imageBuffer), - new Bun.Image(imageBuffer).metadata().catch(() => ({ height: null, width: null })), - ]); - - await prisma.photo.update({ - where: { photoId: { id: photo.id, userId } }, - data: { - perceptualHash: hash, - s3Path: `media/${photoName}`, - height: metadata.height, - width: metadata.width, - }, - }); - - // Enqueue classification job - try { - await classificationQueue.add( - `classify-${photo.id}`, - { photoId: photo.id, userId }, - { - jobId: `classify-${photo.id}-${userId}`, - deduplication: { id: `classify-${photo.id}-${userId}` }, - }, - ); - } catch (error) { - logger.error( - { err: error, photoId: photo.id, userId }, - "Failed to enqueue classification job", - ); - } - - logger.info( - { - tweetId: tweet.id, - photoId: photo.id, - userId, - }, - "Photo saved to S3", - ); - } - } - }, - { - connection: redis, - concurrency: 3, - removeOnComplete: { age: 60 * 60, count: 1000 }, - removeOnFail: { age: 60 * 60 * 24, count: 5000 }, - autorun: false, - }, -); - -imagesWorker.on("failed", (job) => { - logger.error( - { err: job?.failedReason, jobId: job?.id, stack: job?.stacktrace }, - "Image collector job failed", - ); -}); diff --git a/apps/server/src/queue/media-collector.test.ts b/apps/server/src/queue/media-collector.test.ts new file mode 100644 index 00000000..e49d74f1 --- /dev/null +++ b/apps/server/src/queue/media-collector.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { enqueueClassification } from "@/queue/classification-recovery"; + +const classificationSpawn = mock(); +const fetchTaskResult = mock(); +const retryTask = mock(); +const loggerInfo = mock(); + +describe("media collector classification recovery", () => { + beforeEach(() => { + classificationSpawn.mockReset(); + fetchTaskResult.mockReset(); + retryTask.mockReset(); + loggerInfo.mockReset(); + }); + + test("recovers each terminal classification failure", async () => { + classificationSpawn.mockResolvedValue({ taskID: "failed-task", created: false }); + fetchTaskResult + .mockResolvedValueOnce({ state: "failed", failure: null }) + .mockResolvedValueOnce({ state: "failed", failure: null }) + .mockResolvedValueOnce({ state: "pending" }); + retryTask.mockResolvedValue({ taskID: "failed-task", created: false }); + + const dependencies = { + classificationApp: { spawn: classificationSpawn, fetchTaskResult, retryTask }, + retryStrategy: { kind: "exponential", baseSeconds: 30, factor: 2 }, + logger: { info: loggerInfo }, + }; + await enqueueClassification(dependencies, "media-1", "twitter", "user-1"); + // The first in-place recovery terminally fails; the next collection + // creates one more recovery on that same task. + await enqueueClassification(dependencies, "media-1", "twitter", "user-1"); + // Once that recovery is pending, further collectors only coalesce onto it. + await enqueueClassification(dependencies, "media-1", "twitter", "user-1"); + + expect(classificationSpawn).toHaveBeenCalledTimes(3); + expect(retryTask).toHaveBeenCalledTimes(2); + expect(retryTask).toHaveBeenNthCalledWith(1, "failed-task"); + expect(retryTask).toHaveBeenNthCalledWith(2, "failed-task"); + expect(classificationSpawn.mock.calls[0]?.[2]).toMatchObject({ + idempotencyKey: "classify-twitter-user-1-media-1", + maxAttempts: 5, + }); + }); +}); diff --git a/apps/server/src/queue/media-collector.ts b/apps/server/src/queue/media-collector.ts new file mode 100644 index 00000000..2ec9a5f9 --- /dev/null +++ b/apps/server/src/queue/media-collector.ts @@ -0,0 +1,132 @@ +import { prisma } from "@starlight/utils"; +import { Queue, Worker } from "bullmq"; +import sharp from "sharp"; +import { logger } from "@/logger"; +import { classificationQueue } from "@/queue/classification"; +import { findSimilarPhotos } from "@/services/duplicate-detection"; +import { calculatePerceptualHash } from "@/services/image"; +import { normalizeCollectorTags } from "@/services/collector-tags"; +import { isMediaResolved, resolveMediaFromAsset } from "@/services/media-resolution"; +import { + MAX_MEDIA_DOWNLOAD_BYTES, + MAX_POST_DOWNLOAD_BYTES, + readResponseBounded, +} from "@/services/media-download"; +import { redis, s3 } from "@/storage"; + +export interface MediaCollectorJobData { + userId: string; + post: { + provider: string; + externalId: string; + sourceUrl: string; + authorExternalId?: string; + authorName?: string; + authorUsername?: string; + title?: string; + text?: string; + tags?: string[]; + providerPayload: object; + media: Array<{ + externalId: string; + url: string; + kind?: string; + position: number; + fetchHeaders?: Record; + }>; + }; +} + +export const mediaCollectorQueue = new Queue("images-collector", { + connection: redis, + defaultJobOptions: { + attempts: 3, + backoff: { type: "exponential", delay: 10_000 }, + removeOnComplete: { age: 60 * 60, count: 1000 }, + removeOnFail: { age: 60 * 60 * 24, count: 5000 }, + }, +}); + +export const mediaCollectorWorker = new Worker( + "images-collector", + async (job) => { + const { post, userId } = job.data; + const tags = normalizeCollectorTags(post.provider, post.tags, post.providerPayload); + let downloadedBytes = 0; + const postRecord = await prisma.post.upsert({ + where: { postId: { id: post.externalId, userId, provider: post.provider } }, + create: { + id: post.externalId, + userId, + provider: post.provider, + sourceUrl: post.sourceUrl, + authorExternalId: post.authorExternalId, + authorName: post.authorName, + authorUsername: post.authorUsername, + title: post.title, + text: post.text, + tags, + username: post.authorUsername, + providerPayload: post.providerPayload, + media: { createMany: { data: post.media.map((media) => ({ id: media.externalId, position: media.position, kind: media.kind ?? "image", originalUrl: media.url })), skipDuplicates: true } }, + }, + update: { + sourceUrl: post.sourceUrl, + authorExternalId: post.authorExternalId, + authorName: post.authorName, + authorUsername: post.authorUsername, + title: post.title, + text: post.text, + tags, + username: post.authorUsername, + providerPayload: post.providerPayload, + media: { createMany: { data: post.media.map((media) => ({ id: media.externalId, position: media.position, kind: media.kind ?? "image", originalUrl: media.url })), skipDuplicates: true } }, + }, + include: { media: true }, + }); + + for (const media of postRecord.media) { + if (isMediaResolved(media)) { + if (media.kind === "image" && media.classification === null) { + await classificationQueue.add(`classify-${post.provider}-${media.id}`, { photoId: media.id, provider: post.provider, userId }, { jobId: `classify-${post.provider}-${media.id}-${userId}`, deduplication: { id: `classify-${post.provider}-${media.id}-${userId}` } }); + } + continue; + } + const input = post.media.find((item) => item.externalId === media.id); + if (!input) { + throw new Error(`Media ${media.id} is missing from collector payload`); + } + const remainingBytes = MAX_POST_DOWNLOAD_BYTES - downloadedBytes; + if (remainingBytes <= 0) { + throw new Error("Post media is too large"); + } + const bytes = await readResponseBounded(await fetch(media.originalUrl, { headers: input.fetchHeaders }), Math.min(MAX_MEDIA_DOWNLOAD_BYTES, remainingBytes)); + downloadedBytes += bytes.byteLength; + const extension = new URL(media.originalUrl).pathname.split(".").at(-1) ?? "jpg"; + const mediaPath = `media/${post.provider}/${userId}/${media.id}.${extension}`; + + if (media.kind !== "image") { + await s3.write(mediaPath, bytes); + await prisma.media.update({ where: { mediaId: { id: media.id, userId, provider: post.provider } }, data: { s3Path: mediaPath } }); + continue; + } + + const hash = await calculatePerceptualHash(bytes); + const duplicates = await findSimilarPhotos(hash); + if (duplicates.length > 0) { + const asset = duplicates[0]!; + await prisma.media.update({ where: { mediaId: { id: media.id, userId, provider: post.provider } }, data: resolveMediaFromAsset(asset) }); + logger.info({ mediaId: media.id, provider: post.provider, userId, assetMediaId: asset.id, assetUserId: asset.userId }, "Duplicate media resolved from existing asset"); + } else { + const [, metadata] = await Promise.all([s3.write(mediaPath, bytes), sharp(bytes).metadata().catch(() => ({ height: null, width: null }))]); + await prisma.media.update({ where: { mediaId: { id: media.id, userId, provider: post.provider } }, data: { perceptualHash: hash, s3Path: mediaPath, height: metadata.height, width: metadata.width } }); + } + await classificationQueue.add(`classify-${post.provider}-${media.id}`, { photoId: media.id, provider: post.provider, userId }, { jobId: `classify-${post.provider}-${media.id}-${userId}`, deduplication: { id: `classify-${post.provider}-${media.id}-${userId}` } }); + } + }, + { connection: redis, concurrency: 3, autorun: false }, +); + +mediaCollectorWorker.on("failed", (job) => { + logger.error({ err: job?.failedReason, jobId: job?.id, stack: job?.stacktrace }, "Media collector job failed"); +}); diff --git a/apps/server/src/queue/pixiv.ts b/apps/server/src/queue/pixiv.ts new file mode 100644 index 00000000..3c20ff3d --- /dev/null +++ b/apps/server/src/queue/pixiv.ts @@ -0,0 +1,75 @@ +import { withPixivClient } from "@starlight/api/services/pixiv-credential"; +import { prisma } from "@starlight/utils"; +import { Queue, Worker } from "bullmq"; +import { logger } from "@/logger"; +import { mediaCollectorQueue } from "@/queue/media-collector"; +import type { MediaCollectorJobData } from "@/queue/media-collector"; +import { mediaResolvedWhere } from "@/services/media-resolution"; +import { redis } from "@/storage"; + +const CONSECUTIVE_THRESHOLD = 15; +const PIXIV_QUEUE = "pixiv-bookmarks"; +export const SCHEDULED_PIXIV_INTERVAL_SECONDS = 60 * 60 * 6; + +export interface PixivCrawlJobData { + userId: string; + runId: string; + count: number; + limit: number; + cursor?: number; + visibility?: "public" | "private"; +} + +export const pixivQueue = new Queue(PIXIV_QUEUE, { + connection: redis, + defaultJobOptions: { + attempts: 3, + backoff: { type: "exponential", delay: 150_000 }, + removeOnComplete: { age: 60 * 60 * 24, count: 2000 }, + removeOnFail: { age: 60 * 60 * 24, count: 2000 }, + }, +}); + +export const pixivWorker = new Worker( + PIXIV_QUEUE, + async (job) => { + const { data } = job; + if (!data.visibility) { + const runId = data.runId === "scheduled" ? `scheduled-${job.id}` : data.runId; + const user = await prisma.user.findUnique({ where: { id: data.userId }, select: { pixivIncludePrivate: true, providerCredentials: { where: { provider: "pixiv", credentialType: "refresh_token" } } } }); + if (!user?.providerCredentials.length) { + return; + } + const visibilities: ("public" | "private")[] = user.pixivIncludePrivate ? ["public", "private"] : ["public"]; + await pixivQueue.addBulk(visibilities.map((visibility) => ({ name: PIXIV_QUEUE, data: { ...data, count: 0, runId, visibility }, opts: { jobId: `pixiv-${data.userId}-${runId}-${visibility}-start`, deduplication: { id: `pixiv-${data.userId}-${runId}-${visibility}-start` } } }))); + return; + } + + const page = await withPixivClient(data.userId, (client) => client.bookmarks({ cursor: data.cursor, visibility: data.visibility! })); + if (!page) { + return; + } + const knownPosts = await prisma.post.findMany({ where: { userId: data.userId, provider: "pixiv", id: { in: page.artworks.map((artwork) => artwork.id) }, media: { every: mediaResolvedWhere } }, select: { id: true } }); + const known = new Set(knownPosts.map((post) => post.id)); + let consecutiveKnown = 0; + const jobs: MediaCollectorJobData[] = []; + for (const artwork of page.artworks) { + consecutiveKnown = known.has(artwork.id) ? consecutiveKnown + 1 : 0; + jobs.push({ userId: data.userId, post: { provider: "pixiv", externalId: artwork.id, sourceUrl: artwork.sourceUrl, authorExternalId: artwork.author.id, authorName: artwork.author.name, authorUsername: artwork.author.username, title: artwork.title, text: artwork.caption, tags: artwork.tags, providerPayload: { starlightMediaType: artwork.type }, media: artwork.mediaUrls.map((url, position) => ({ externalId: `${artwork.id}:${position}`, url, position, kind: artwork.type === "ugoira" ? "animation-preview" : "image", fetchHeaders: { Referer: "https://www.pixiv.net/" } })) } }); + if (consecutiveKnown >= CONSECUTIVE_THRESHOLD) { + break; + } + } + await mediaCollectorQueue.addBulk(jobs.map((mediaJob) => ({ name: `post-pixiv-${mediaJob.post.externalId}`, data: mediaJob, opts: { jobId: `post-pixiv-${mediaJob.post.externalId}-${mediaJob.userId}`, deduplication: { id: `post-pixiv-${mediaJob.post.externalId}-${mediaJob.userId}` } } }))); + const count = data.count + page.artworks.length; + if (consecutiveKnown >= CONSECUTIVE_THRESHOLD || count >= data.limit || !page.nextCursor) { + return; + } + await pixivQueue.add(PIXIV_QUEUE, { ...data, count, cursor: page.nextCursor }, { deduplication: { id: `pixiv-${data.userId}-${data.runId}-${data.visibility}-${page.nextCursor}` } }); + }, + { connection: redis, concurrency: 1, autorun: false }, +); + +pixivWorker.on("failed", (job) => { + logger.error({ err: job?.failedReason, jobId: job?.id, stack: job?.stacktrace, userId: job?.data.userId }, "Pixiv worker failed"); +}); diff --git a/apps/server/src/queue/scrapper.ts b/apps/server/src/queue/scrapper.ts index 683c1fa7..f949cb74 100644 --- a/apps/server/src/queue/scrapper.ts +++ b/apps/server/src/queue/scrapper.ts @@ -1,18 +1,18 @@ -import { CookieEncryption } from "@starlight/crypto"; +import { getTwitterCookies } from "@starlight/api/services/twitter-credential"; import type { User } from "@starlight/utils"; import { env, prisma } from "@starlight/utils"; +import type { Tweet } from "@the-convocation/twitter-scraper"; import { Scraper } from "@the-convocation/twitter-scraper"; -import type { QueryTweetsResponse, Tweet } from "@the-convocation/twitter-scraper"; import { Queue, Worker } from "bullmq"; import { bot } from "@/bot"; import { logger } from "@/logger"; -import { imagesQueue } from "@/queue/image-collector"; +import { mediaCollectorQueue, type MediaCollectorJobData } from "@/queue/media-collector"; +import { mediaResolvedWhere } from "@/services/media-resolution"; +import { normalizeTwitterTags } from "@/services/twitter-tags"; import { Cookies, redis } from "@/storage"; -const cookieEncryption = new CookieEncryption( - env.COOKIE_ENCRYPTION_KEY, - env.COOKIE_ENCRYPTION_SALT, -); +export const SCHEDULED_SCRAPPER_INTERVAL_SECONDS = 60 * 60 * 6; +const CONSECUTIVE_THRESHOLD = 15; export interface ScrapperJobData { count: number; @@ -34,272 +34,92 @@ export const scrapperQueue = new Queue(FEED_SCRAPPER_QUEUE, { }, }); -const CONSECUTIVE_THRESHOLD = 15; - -interface ScrapeBatchResult { - consecutiveKnownTweets: number; - newTweets: { - id: string; - userId: string; - tweetData: Tweet; - }[]; - newTweetsInBatch: number; - tweetsToQueue: { tweet: Tweet; userId: string }[]; - updatedTweets: { id: string; tweetData: Tweet }[]; -} - function collectTimelineTweets( tweets: Tweet[], - existingTweetMap: Map, + existingPostIds: Set, userId: string, force = false, -): ScrapeBatchResult { - const result: ScrapeBatchResult = { - consecutiveKnownTweets: 0, - newTweets: [], - newTweetsInBatch: 0, - tweetsToQueue: [], - updatedTweets: [], - }; - - for (const [index, tweet] of tweets.entries()) { - if (tweet.id) { - const isNewTweet = !existingTweetMap.has(tweet.id); +) { + const jobs: MediaCollectorJobData[] = []; + let consecutiveKnown = 0; - if (isNewTweet) { - result.consecutiveKnownTweets = 0; - result.newTweetsInBatch++; - result.newTweets.push({ - id: tweet.id, - userId, - tweetData: tweet, - }); - } else { - result.consecutiveKnownTweets++; - result.updatedTweets.push({ - id: tweet.id, - tweetData: tweet, - }); - } - - // Only queue tweets with photos for image processing - if (tweet.photos.length > 0) { - result.tweetsToQueue.push({ tweet, userId }); - } - - // Stop if we've seen too many consecutive known tweets (unless force is enabled) - if (!force && result.consecutiveKnownTweets >= CONSECUTIVE_THRESHOLD) { - logger.info( - { - userId, - consecutiveKnownTweets: result.consecutiveKnownTweets, - newTweetsInBatch: result.newTweetsInBatch, - totalProcessed: index + 1, - }, - "Stopping scrape after consecutive known tweets", - ); - break; - } + for (const tweet of tweets) { + if (!tweet.id) { + continue; + } + consecutiveKnown = existingPostIds.has(tweet.id) ? consecutiveKnown + 1 : 0; + if (tweet.photos.length > 0) { + jobs.push({ + userId, + post: { + provider: "twitter", + externalId: tweet.id, + sourceUrl: `https://x.com/i/status/${tweet.id}`, + authorExternalId: tweet.userId, + authorName: tweet.name, + authorUsername: tweet.username, + text: tweet.text, + tags: normalizeTwitterTags(tweet), + providerPayload: tweet, + media: tweet.photos.map((photo, position) => ({ externalId: photo.id, url: photo.url, position, kind: "image" })), + }, + }); + } + if (!force && consecutiveKnown >= CONSECUTIVE_THRESHOLD) { + break; } } - return result; + return { consecutiveKnown, jobs }; } export const scrapperWorker = new Worker( FEED_SCRAPPER_QUEUE, async (job) => { const { data } = job; - const { userId } = data; - - logger.info({ userId, cursor: data.cursor, jobData: data }, "Scraping timeline"); - - let user: User; - - try { - user = await prisma.user.findUniqueOrThrow({ - where: { - id: userId, - }, - }); - } catch (error) { - logger.error({ err: error, userId }, "User not found"); - throw error; - } - - const userCookies = user.cookies; - + const user = await getUser(data.userId); + const userCookies = await getTwitterCookies(user.id); if (!userCookies) { - logger.error({ userId }, "User cookies not found"); - await scrapperQueue.removeJobScheduler(`scrapper-${userId}`); - - await bot.api.sendPhoto(user.telegramId.toString(), `${env.BASE_CDN_URL}/moom.jpg`, { - caption: - "Can't scrape your timeline, no cookies?. Please setup your them in settings again and send /scrapper command again.", - }); - + logger.error({ userId: data.userId }, "User cookies not found"); + await scrapperQueue.removeJobScheduler(`scrapper-${data.userId}`); + await bot.api.sendPhoto(user.telegramId.toString(), `${env.BASE_CDN_URL}/moom.jpg`, { caption: "Can't scrape your timeline, no cookies. Please set them in Settings and send /scrapper again." }); return; } - // Decrypt cookies with migration support - let cookiesJson: string; - try { - cookiesJson = cookieEncryption.safeDecrypt(userCookies, user.telegramId.toString()); - } catch (error) { - logger.error({ err: error, userId }, "Failed to decrypt user cookies"); - throw new Error("Failed to decrypt user cookies", { cause: error }); - } - - const cookies = Cookies.fromJSON(cookiesJson); - + const cookies = Cookies.fromJSON(userCookies); const twid = cookies.userId(); - if (!twid) { - logger.error({ userId }, "User ID not found"); throw new Error("User ID not found"); } - const scrapper = new Scraper({ experimental: { xClientTransactionId: false, xpff: false } }); await scrapper.setCookies(cookies.toString().split(";")); - - let timeline: QueryTweetsResponse; - - try { - timeline = await scrapper.fetchLikedTweets(twid, 200, data.cursor); - } catch (error) { - logger.error( - { - userId, - err: error, - }, - "Unable to fetch timeline", - ); - - throw error; + const timeline = await scrapper.fetchLikedTweets(twid, 200, data.cursor); + const postIds = timeline.tweets.flatMap((tweet) => (tweet.id ? [tweet.id] : [])); + const existingPostIds = new Set((await prisma.post.findMany({ where: { userId: data.userId, provider: "twitter", id: { in: postIds }, media: { every: mediaResolvedWhere } }, select: { id: true } })).map((post) => post.id)); + const { consecutiveKnown, jobs } = collectTimelineTweets(timeline.tweets, existingPostIds, data.userId, data.force); + if (jobs.length > 0) { + await mediaCollectorQueue.addBulk(jobs.map((mediaJob) => ({ name: `post-${mediaJob.post.provider}-${mediaJob.post.externalId}`, data: mediaJob, opts: { jobId: `post-${mediaJob.post.provider}-${mediaJob.post.externalId}-${mediaJob.userId}`, deduplication: { id: `post-${mediaJob.post.provider}-${mediaJob.post.externalId}-${mediaJob.userId}` } } }))); } - logger.info( - { - userId, - cursor: data.cursor, - tweets: timeline.tweets.length, - }, - "Scraped timeline", - ); - - // Step 1: Batch check existing tweets - const tweetIds = timeline.tweets.map((tweet) => tweet.id).filter((id) => id !== undefined); - - const existingTweets = await prisma.tweet.findMany({ - where: { - userId, - id: { in: tweetIds }, - photos: { every: { s3Path: { not: null } } }, - }, - select: { id: true, createdAt: true }, - }); - const existingTweetMap = new Map(existingTweets.map((tweet) => [tweet.id, tweet.createdAt])); - - // Step 2: Process tweets and build batch operations - const { newTweets, updatedTweets, tweetsToQueue, consecutiveKnownTweets, newTweetsInBatch } = - collectTimelineTweets(timeline.tweets, existingTweetMap, userId, data.force); - - // Step 3: Execute batch operations in transaction - await prisma.$transaction(async (tx) => { - // Batch create new tweets - if (newTweets.length > 0) { - await tx.tweet.createMany({ - data: newTweets, - skipDuplicates: true, - }); - } - - // Batch update existing tweets - if (updatedTweets.length > 0) { - await Promise.all( - updatedTweets.map((tweet) => - tx.tweet.update({ - where: { tweetId: { userId, id: tweet.id } }, - data: { tweetData: tweet.tweetData }, - }), - ), - ); - } - }); - - // Queue image processing jobs for tweets with photos - if (tweetsToQueue.length > 0) { - await imagesQueue.addBulk( - tweetsToQueue.map((imageJob) => ({ - name: `post-${imageJob.tweet.id}`, - data: imageJob, - opts: { - jobId: `post-${imageJob.tweet.id}-${imageJob.userId}`, - deduplication: { id: `post-${imageJob.tweet.id}-${imageJob.userId}` }, - }, - })), - ); - } - - data.count += timeline.tweets.length; - - // Stop if we hit consecutive threshold or other limits - if ( - (!data.force && consecutiveKnownTweets >= CONSECUTIVE_THRESHOLD) || - data.count >= data.limit || - !timeline.next - ) { - let reason: string; - if (!data.force && consecutiveKnownTweets >= CONSECUTIVE_THRESHOLD) { - reason = "consecutive_threshold"; - } else if (data.count >= data.limit) { - reason = "count_limit"; - } else { - reason = "no_next_cursor"; - } - - logger.info( - { - userId, - count: data.count, - limit: data.limit, - consecutiveKnownTweets, - newTweetsInBatch, - force: data.force, - reason, - }, - "Stopping scrape job", - ); + const count = data.count + timeline.tweets.length; + if ((!data.force && consecutiveKnown >= CONSECUTIVE_THRESHOLD) || count >= data.limit || !timeline.next) { + logger.info({ userId: data.userId, count, limit: data.limit, consecutiveKnown }, "Stopping scrape job"); return; } - - await scrapperQueue.add( - FEED_SCRAPPER_QUEUE, - { - userId, - count: data.count, - limit: data.limit, - cursor: timeline.next, - force: data.force, - }, - { - delay: 60_000, - deduplication: { id: `scrapper-${userId}-${timeline.next}` }, - }, - ); - - logger.info({ userId, count: data.count, limit: data.limit }, "Scraping next page"); - }, - { - connection: redis, - concurrency: 1, - autorun: false, + await scrapperQueue.add(FEED_SCRAPPER_QUEUE, { ...data, count, cursor: timeline.next }, { delay: 60_000, deduplication: { id: `scrapper-${data.userId}-${timeline.next}` } }); }, + { connection: redis, concurrency: 1, autorun: false }, ); scrapperWorker.on("failed", (job) => { - logger.error( - { err: job?.failedReason, jobId: job?.id, stack: job?.stacktrace, userId: job?.data.userId }, - "Scrapper job failed", - ); + logger.error({ err: job?.failedReason, jobId: job?.id, stack: job?.stacktrace, userId: job?.data.userId }, "Scrapper job failed"); }); + +async function getUser(userId: string): Promise { + try { + return await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + } catch (error) { + logger.error({ err: error, userId }, "User not found"); + throw error; + } +} diff --git a/apps/server/src/scripts/classification.ts b/apps/server/src/scripts/classification.ts index 286998c9..a1689f4e 100644 --- a/apps/server/src/scripts/classification.ts +++ b/apps/server/src/scripts/classification.ts @@ -38,14 +38,14 @@ async function main() { } const photos = ALL_PICTURES - ? await prisma.photo.findMany({ + ? await prisma.media.findMany({ where: { deletedAt: null, s3Path: { not: null } }, - select: { id: true, userId: true }, + select: { id: true, provider: true, userId: true }, orderBy: { id: "asc" }, }) - : await prisma.$queryRaw<{ id: string; userId: string }[]>` - SELECT id, user_id as "userId" - FROM photos + : await prisma.$queryRaw<{ id: string; provider: string; userId: string }[]>` + SELECT external_id AS id, provider, user_id as "userId" + FROM media WHERE deleted_at IS NULL AND s3_path IS NOT NULL AND ( @@ -64,11 +64,11 @@ async function main() { if (!DRY_RUN && photos.length > 0) { await classificationQueue.addBulk( photos.map((photo) => { - const base = `classify-${photo.id}-${photo.userId}`; + const base = `classify-${photo.provider}-${photo.id}-${photo.userId}`; const jobId = FORCE ? `${base}-${Date.now()}` : base; return { - name: `classify-${photo.id}`, - data: { photoId: photo.id, userId: photo.userId }, + name: `classify-${photo.provider}-${photo.id}`, + data: { photoId: photo.id, provider: photo.provider, userId: photo.userId }, opts: FORCE ? { jobId } : { jobId, deduplication: { id: base } }, }; }), @@ -103,7 +103,7 @@ main() await prisma.$disconnect().catch((error) => { logger.error({ error }, "Failed to disconnect from database"); }); - await redis.quit().catch((error) => { + await redis.quit().catch((error: unknown) => { logger.error({ error }, "Failed to quit Redis"); }); }); diff --git a/apps/server/src/scripts/embeddings.ts b/apps/server/src/scripts/embeddings.ts index 9a6ff752..193b4dea 100644 --- a/apps/server/src/scripts/embeddings.ts +++ b/apps/server/src/scripts/embeddings.ts @@ -32,9 +32,9 @@ async function main() { logger.info("Embeddings queue drained"); } - const photos = await prisma.$queryRaw<{ id: string; userId: string }[]>` - SELECT id, user_id as "userId" - FROM photos + const photos = await prisma.$queryRaw<{ id: string; provider: string; userId: string }[]>` + SELECT external_id AS id, provider, user_id as "userId" + FROM media WHERE deleted_at IS NULL AND s3_path IS NOT NULL AND ( @@ -49,11 +49,11 @@ async function main() { if (!DRY_RUN && photos.length > 0) { await embeddingsQueue.addBulk( photos.map((photo) => { - const base = `embed-${photo.id}-${photo.userId}`; + const base = `embed-${photo.provider}-${photo.id}-${photo.userId}`; const jobId = FORCE ? `${base}-${Date.now()}` : base; return { - name: `embed-${photo.id}`, - data: { photoId: photo.id, userId: photo.userId }, + name: `embed-${photo.provider}-${photo.id}`, + data: { photoId: photo.id, provider: photo.provider, userId: photo.userId }, opts: FORCE ? { jobId } : { jobId, deduplication: { id: base } }, }; }), @@ -87,7 +87,7 @@ main() await prisma.$disconnect().catch((error) => { logger.error({ error }, "Failed to disconnect from database"); }); - await redis.quit().catch((error) => { + await redis.quit().catch((error: unknown) => { logger.error({ error }, "Failed to quit Redis"); }); }); diff --git a/apps/server/src/scripts/update-media-dimensions.ts b/apps/server/src/scripts/update-media-dimensions.ts new file mode 100644 index 00000000..08a6dbb9 --- /dev/null +++ b/apps/server/src/scripts/update-media-dimensions.ts @@ -0,0 +1,148 @@ +import { prisma } from "@starlight/utils"; +import { logger } from "@/logger"; +import { s3 } from "@/storage"; + +// Manual script: update height/width for media missing dimensions +// Usage: bun run apps/server/src/scripts/update-media-dimensions.ts +// Optional env vars: +// DRY_RUN=1 (only log, do not update) +// BATCH_SIZE=100 (batch size for processing, default: 50) + +const DRY_RUN = process.env.DRY_RUN === "1"; +const BATCH_SIZE = Number.parseInt(process.env.BATCH_SIZE || "25", 10); + +async function main() { + logger.info( + { + dryRun: DRY_RUN, + batchSize: BATCH_SIZE, + }, + "Starting media dimensions update", + ); + + // Find media with null height or width that have s3Path + const media = await prisma.media.findMany({ + where: { + deletedAt: null, + s3Path: { not: null }, + OR: [{ height: null }, { width: null }], + }, + select: { + id: true, + userId: true, + provider: true, + s3Path: true, + height: true, + width: true, + }, + orderBy: { createdAt: "asc" }, + }); + + logger.info({ count: media.length }, "Found media missing dimensions"); + + if (media.length === 0) { + logger.info("No media need dimension updates"); + return; + } + + let updated = 0; + let failed = 0; + + // Process in batches + for (let i = 0; i < media.length; i += BATCH_SIZE) { + const batch = media.slice(i, i + BATCH_SIZE); + + logger.info( + { + batch: Math.floor(i / BATCH_SIZE) + 1, + totalBatches: Math.ceil(media.length / BATCH_SIZE), + }, + "Processing batch", + ); + + await Promise.allSettled( + batch.map(async (mediaItem) => { + try { + if (!mediaItem.s3Path) { + logger.warn({ mediaId: mediaItem.id, userId: mediaItem.userId }, "Media has no s3Path"); + return; + } + + // Download image from S3 + const imageBuffer = await s3.file(mediaItem.s3Path).arrayBuffer(); + + const metadata = await new Bun.Image(imageBuffer) + .metadata() + .catch(() => ({ height: null, width: null })); + + if (!(metadata.height && metadata.width)) { + logger.warn( + { mediaId: mediaItem.id, userId: mediaItem.userId, metadata }, + "Failed to extract dimensions", + ); + failed++; + return; + } + + if (!DRY_RUN) { + // Update media with dimensions + await prisma.media.update({ + where: { + mediaId: { + id: mediaItem.id, + userId: mediaItem.userId, + provider: mediaItem.provider, + }, + }, + data: { + height: metadata.height, + width: metadata.width, + }, + }); + } + + logger.debug( + { + mediaId: mediaItem.id, + userId: mediaItem.userId, + height: metadata.height, + width: metadata.width, + dryRun: DRY_RUN, + }, + "Updated media dimensions", + ); + + updated++; + } catch (error) { + logger.error( + { error, mediaId: mediaItem.id, userId: mediaItem.userId }, + "Failed to update media dimensions", + ); + failed++; + } + }), + ); + } + + logger.info( + { + total: media.length, + updated, + failed, + dryRun: DRY_RUN, + batchSize: BATCH_SIZE, + }, + "Finished media dimensions update", + ); +} + +main() + .catch((error) => { + logger.error({ error }, "Media dimensions update script failed"); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect().catch((error) => { + logger.error({ error }, "Failed to disconnect from database"); + }); + }); diff --git a/apps/server/src/scripts/update-photo-dimensions.ts b/apps/server/src/scripts/update-photo-dimensions.ts deleted file mode 100644 index db25b97b..00000000 --- a/apps/server/src/scripts/update-photo-dimensions.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { prisma } from "@starlight/utils"; -import { logger } from "@/logger"; -import { s3 } from "@/storage"; - -// Manual script: update height/width for photos missing dimensions -// Usage: bun run apps/server/src/scripts/update-photo-dimensions.ts -// Optional env vars: -// DRY_RUN=1 (only log, do not update) -// BATCH_SIZE=100 (batch size for processing, default: 50) - -const DRY_RUN = process.env.DRY_RUN === "1"; -const BATCH_SIZE = Math.trunc(Number(process.env.BATCH_SIZE || "25")); - -type DimensionUpdateResult = "updated" | "failed" | "skipped"; - -async function updatePhotoDimension(photo: { - id: string; - userId: string; - s3Path: string | null; -}): Promise { - try { - if (!photo.s3Path) { - logger.warn({ photoId: photo.id, userId: photo.userId }, "Photo has no s3Path"); - return "skipped"; - } - - // Download image from S3 - const imageBuffer = await s3.file(photo.s3Path).arrayBuffer(); - - const metadata = await new Bun.Image(imageBuffer) - .metadata() - .catch(() => ({ height: null, width: null })); - - if (!(metadata.height && metadata.width)) { - logger.warn( - { photoId: photo.id, userId: photo.userId, metadata }, - "Failed to extract dimensions", - ); - return "failed"; - } - - if (!DRY_RUN) { - // Update photo with dimensions - await prisma.photo.update({ - where: { photoId: { id: photo.id, userId: photo.userId } }, - data: { - height: metadata.height, - width: metadata.width, - }, - }); - } - - logger.debug( - { - photoId: photo.id, - userId: photo.userId, - height: metadata.height, - width: metadata.width, - dryRun: DRY_RUN, - }, - "Updated photo dimensions", - ); - - return "updated"; - } catch (error) { - logger.error( - { error, photoId: photo.id, userId: photo.userId }, - "Failed to update photo dimensions", - ); - return "failed"; - } -} - -async function main() { - logger.info( - { - dryRun: DRY_RUN, - batchSize: BATCH_SIZE, - }, - "Starting photo dimensions update", - ); - - // Find photos with null height or width that have s3Path - const photos = await prisma.photo.findMany({ - where: { - deletedAt: null, - s3Path: { not: null }, - OR: [{ height: null }, { width: null }], - }, - select: { - id: true, - userId: true, - s3Path: true, - height: true, - width: true, - }, - orderBy: { createdAt: "asc" }, - }); - - logger.info({ count: photos.length }, "Found photos missing dimensions"); - - if (photos.length === 0) { - logger.info("No photos need dimension updates"); - return; - } - - let updated = 0; - let failed = 0; - - // Process in batches - for (let i = 0; i < photos.length; i += BATCH_SIZE) { - const batch = photos.slice(i, i + BATCH_SIZE); - - logger.info( - { - batch: Math.floor(i / BATCH_SIZE) + 1, - totalBatches: Math.ceil(photos.length / BATCH_SIZE), - }, - "Processing batch", - ); - - const results = await Promise.all(batch.map((photo) => updatePhotoDimension(photo))); - - for (const result of results) { - if (result === "updated") { - updated++; - } else if (result === "failed") { - failed++; - } - } - } - - logger.info( - { - total: photos.length, - updated, - failed, - dryRun: DRY_RUN, - batchSize: BATCH_SIZE, - }, - "Finished photo dimensions update", - ); -} - -main() - .catch((error) => { - logger.error({ error }, "Photo dimensions update script failed"); - process.exitCode = 1; - }) - .finally(async () => { - await prisma.$disconnect().catch((error) => { - logger.error({ error }, "Failed to disconnect from database"); - }); - }); diff --git a/apps/server/src/services/collector-tags.ts b/apps/server/src/services/collector-tags.ts new file mode 100644 index 00000000..408b0482 --- /dev/null +++ b/apps/server/src/services/collector-tags.ts @@ -0,0 +1,18 @@ +import { normalizeTags } from "@/services/tag-normalization"; + +export const normalizeCollectorTags = ( + provider: string, + tags: unknown, + providerPayload: object, +): string[] => { + if (Array.isArray(tags)) { + return normalizeTags(tags.filter((tag): tag is string => typeof tag === "string")); + } + if (tags !== undefined || provider !== "twitter") { + return []; + } + const hashtags = (providerPayload as { hashtags?: unknown }).hashtags; + return Array.isArray(hashtags) + ? normalizeTags(hashtags.filter((tag): tag is string => typeof tag === "string")) + : []; +}; diff --git a/apps/server/src/services/duplicate-detection.test.ts b/apps/server/src/services/duplicate-detection.test.ts new file mode 100644 index 00000000..c900c3bd --- /dev/null +++ b/apps/server/src/services/duplicate-detection.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const findMany = mock(); + +mock.module("@starlight/utils", () => ({ + prisma: { media: { findMany } }, +})); +mock.module("@/logger", () => ({ + logger: { debug: mock() }, +})); + +const { findSimilarPhotos } = await import("@/services/duplicate-detection"); + +const targetHash = "0000000000000000"; + +describe("findSimilarPhotos", () => { + beforeEach(() => { + findMany.mockReset(); + }); + + test("returns matching assets from a full candidate bucket", async () => { + findMany + .mockResolvedValueOnce( + Array.from({ length: 50 }, (_, index) => ({ + id: `media-${index}`, + userId: "user", + perceptualHash: targetHash, + s3Path: `media/twitter/user/${index}.jpg`, + originalUrl: `https://example.test/${index}.jpg`, + postId: `post-${index}`, + height: 100, + width: 100, + post: { sourceUrl: "https://example.test/post" }, + })), + ) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const matches = await findSimilarPhotos(targetHash); + + expect(matches).toHaveLength(50); + expect(matches[0]).toMatchObject({ + perceptualHash: targetHash, + s3Path: "media/twitter/user/0.jpg", + }); + expect(findMany.mock.calls.map(([query]) => query.take)).toEqual([50, 200, 1000]); + }); +}); diff --git a/apps/server/src/services/duplicate-detection.ts b/apps/server/src/services/duplicate-detection.ts index a0ef10c1..7f148ec9 100644 --- a/apps/server/src/services/duplicate-detection.ts +++ b/apps/server/src/services/duplicate-detection.ts @@ -1,14 +1,17 @@ import { prisma } from "@starlight/utils"; import { logger } from "@/logger"; -import { calculateHashDistance, calculatePerceptualHash } from "./image"; +import { calculateHashDistance, calculatePerceptualHash } from "@/services/image"; interface SimilarPhoto { distance: number; id: string; originalUrl: string; perceptualHash: string; - s3Path?: string; - tweetId: string; + s3Path: string; + height: number | null; + width: number | null; + sourceUrl: string; + postId: string; userId: string; } @@ -23,16 +26,18 @@ export async function findSimilarPhotos( { len: 8, field: "hashBucket8" as const, maxCandidates: 200 }, { len: 4, field: "hashBucket4" as const, maxCandidates: 1000 }, ]; + const similarPhotos = new Map(); for (const { len, field, maxCandidates } of buckets) { - const prefix = targetHash.slice(0, len); + const prefix = targetHash.substring(0, len); logger.debug({ prefix, field, maxCandidates }, "Searching for similar photos"); - const candidates = await prisma.photo.findMany({ + const candidates = await prisma.media.findMany({ where: { [field]: prefix, perceptualHash: { not: null }, + s3Path: { not: null }, deletedAt: null, NOT: excludePhotoId && excludeUserId @@ -47,7 +52,10 @@ export async function findSimilarPhotos( perceptualHash: true, s3Path: true, originalUrl: true, - tweetId: true, + postId: true, + height: true, + width: true, + post: { select: { sourceUrl: true } }, }, take: maxCandidates, }); @@ -56,32 +64,28 @@ export async function findSimilarPhotos( continue; } - // If we got results and didn't hit the limit, process them - if (candidates.length < maxCandidates) { - const similarPhotos: SimilarPhoto[] = []; + for (const candidate of candidates) { + const distance = calculateHashDistance(targetHash, candidate.perceptualHash!); - for (const candidate of candidates) { - const distance = calculateHashDistance(targetHash, candidate.perceptualHash!); - - if (distance <= maxDistance) { - similarPhotos.push({ - id: candidate.id, - userId: candidate.userId, - perceptualHash: candidate.perceptualHash!, - distance, - s3Path: candidate.s3Path || undefined, - originalUrl: candidate.originalUrl, - tweetId: candidate.tweetId, - }); - } + if (distance <= maxDistance) { + const similarPhoto = { + id: candidate.id, + userId: candidate.userId, + perceptualHash: candidate.perceptualHash!, + distance, + s3Path: candidate.s3Path!, + originalUrl: candidate.originalUrl, + postId: candidate.postId, + sourceUrl: candidate.post.sourceUrl, + height: candidate.height, + width: candidate.width, + }; + similarPhotos.set(`${candidate.id}:${candidate.userId}:${candidate.s3Path}`, similarPhoto); } - - // Sort by distance (most similar first) - return similarPhotos.toSorted((a, b) => a.distance - b.distance); } } - return []; + return [...similarPhotos.values()].sort((a, b) => a.distance - b.distance); } export async function findDuplicatesByImageContent( diff --git a/apps/server/src/services/media-download.ts b/apps/server/src/services/media-download.ts new file mode 100644 index 00000000..3acf701a --- /dev/null +++ b/apps/server/src/services/media-download.ts @@ -0,0 +1,42 @@ +export const MAX_MEDIA_DOWNLOAD_BYTES = 50_000_000; +export const MAX_POST_DOWNLOAD_BYTES = 200_000_000; + +export const readResponseBounded = async (response: Response, limit = MAX_MEDIA_DOWNLOAD_BYTES) => { + if (!response.ok) { + throw new Error(`Media request failed (${response.status})`); + } + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > limit) { + await response.body?.cancel(); + throw new Error("Media is too large"); + } + if (!response.body) { + throw new Error("Media response had no body"); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + total += chunk.value.byteLength; + if (total > limit) { + await reader.cancel("Media is too large"); + throw new Error("Media is too large"); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +}; diff --git a/apps/server/src/services/media-resolution.ts b/apps/server/src/services/media-resolution.ts new file mode 100644 index 00000000..0aaae438 --- /dev/null +++ b/apps/server/src/services/media-resolution.ts @@ -0,0 +1,24 @@ +import { Prisma, type Media } from "@starlight/utils"; + +export const mediaResolvedWhere = { + s3Path: { not: null }, + OR: [{ kind: { not: "image" } }, { perceptualHash: { not: null } }], +} satisfies Prisma.MediaWhereInput; + +export function isMediaResolved(media: Pick): boolean { + return media.s3Path !== null && (media.kind !== "image" || media.perceptualHash !== null); +} + +export function resolveMediaFromAsset( + asset: Pick & { + perceptualHash: string; + s3Path: string; + }, +) { + return { + s3Path: asset.s3Path, + perceptualHash: asset.perceptualHash, + height: asset.height, + width: asset.width, + }; +} diff --git a/apps/server/src/services/pixiv-media.test.ts b/apps/server/src/services/pixiv-media.test.ts new file mode 100644 index 00000000..9be88bb4 --- /dev/null +++ b/apps/server/src/services/pixiv-media.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import JSZip from "jszip"; +import { readResponseBounded } from "@/services/media-download"; +import { buildFfmpegConcat, extractUgoiraZip, parsePixivArtworkUrl } from "@/services/pixiv-media"; + +describe("parsePixivArtworkUrl", () => { + test("accepts only canonical HTTPS artwork URLs", () => { + expect(parsePixivArtworkUrl("https://www.pixiv.net/artworks/12345")).toBe("12345"); + expect(parsePixivArtworkUrl("https://pixiv.net/artworks/12345/")).toBe("12345"); + expect(parsePixivArtworkUrl("http://www.pixiv.net/artworks/12345")).toBeNull(); + expect(parsePixivArtworkUrl("https://pixiv.net.evil.test/artworks/12345")).toBeNull(); + expect(parsePixivArtworkUrl("https://www.pixiv.net/users/12345")).toBeNull(); + }); +}); + +describe("ugoira conversion input", () => { + test("preserves variable frame delays", () => { + expect( + buildFfmpegConcat([ + { file: "a.jpg", delay: 40 }, + { file: "b.jpg", delay: 125 }, + ]), + ).toContain("duration 0.04\nfile 'b.jpg'\nduration 0.125"); + }); + + test("rejects traversal in frame metadata", async () => { + const zip = new JSZip(); + zip.file("frame.jpg", "data"); + const archive = await zip.generateAsync({ type: "arraybuffer" }); + expect(extractUgoiraZip(archive, [{ file: "../frame.jpg", delay: 100 }])).rejects.toThrow( + "Unsafe ugoira archive path", + ); + }); + + test("bounds declared and streamed download sizes", async () => { + const declared = new Response("small", { + headers: { "content-length": "100" }, + }); + await expect(readResponseBounded(declared, 10)).rejects.toThrow("too large"); + const streamed = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(8)); + controller.enqueue(new Uint8Array(8)); + controller.close(); + }, + }), + ); + await expect(readResponseBounded(streamed, 10)).rejects.toThrow("too large"); + }); + + test("bounds cumulative extraction and removes failed temp directories", async () => { + const glob = new Bun.Glob("starlight-ugoira-*"); + const temporaryDirectory = Bun.env.TMPDIR ?? "/tmp"; + const before = new Set(await Array.fromAsync(glob.scan({ cwd: temporaryDirectory }))); + const zip = new JSZip(); + zip.file("a.jpg", new Uint8Array(8)); + zip.file("b.jpg", new Uint8Array(8)); + const archive = await zip.generateAsync({ type: "arraybuffer" }); + expect( + extractUgoiraZip( + archive, + [ + { file: "a.jpg", delay: 100 }, + { file: "b.jpg", delay: 100 }, + ], + { uncompressed: 10 }, + ), + ).rejects.toThrow("too large"); + const after = await Array.fromAsync(glob.scan({ cwd: temporaryDirectory })); + const leakedDirectories: string[] = []; + for (const entry of after) { + if (!before.has(entry)) { + leakedDirectories.push(entry); + } + } + expect(leakedDirectories).toEqual([]); + }); +}); diff --git a/apps/server/src/services/pixiv-media.ts b/apps/server/src/services/pixiv-media.ts new file mode 100644 index 00000000..091ce38c --- /dev/null +++ b/apps/server/src/services/pixiv-media.ts @@ -0,0 +1,189 @@ +import JSZip from "jszip"; +import { MAX_MEDIA_DOWNLOAD_BYTES } from "@/services/media-download"; + +const PIXIV_HOSTS = new Set(["pixiv.net", "www.pixiv.net"]); +export const MAX_PIXIV_DOWNLOAD_BYTES = MAX_MEDIA_DOWNLOAD_BYTES; +export const MAX_UGOIRA_UNCOMPRESSED_BYTES = 200_000_000; +const MAX_UGOIRA_FRAMES = 1000; +const FFMPEG_TIMEOUT_MS = 120_000; + +const createTemporaryDirectory = async () => { + const directory = `${Bun.env.TMPDIR ?? "/tmp"}/starlight-ugoira-${Bun.randomUUIDv7()}`; + const child = Bun.spawn(["mkdir", "-m", "700", directory], { + stdout: "ignore", + stderr: "ignore", + }); + if ((await child.exited) !== 0) { + throw new Error("Failed to create ugoira temporary directory"); + } + return directory; +}; + +const removeTemporaryDirectory = async (directory: string) => { + const child = Bun.spawn(["rm", "-rf", directory], { stdout: "ignore", stderr: "ignore" }); + await child.exited; +}; + +const writeBoundedEntry = async ( + entry: JSZip.JSZipObject, + path: string, + limit: number, + total: { value: number }, +) => { + const stream = entry.nodeStream("nodebuffer"); + const sink = Bun.file(path).writer(); + await new Promise((resolve, reject) => { + let finished = false; + const finish = (error?: Error) => { + if (finished) { + return; + } + finished = true; + void Promise.resolve(sink.end(error)).then(() => (error ? reject(error) : resolve()), reject); + }; + + stream.on("data", (chunk: Uint8Array) => { + if (finished) { + return; + } + total.value += chunk.byteLength; + if (total.value > limit) { + finish(new Error("Ugoira is too large")); + return; + } + sink.write(chunk); + }); + stream.on("error", finish); + stream.on("end", () => finish()); + }); +}; + +export const parsePixivArtworkUrl = (value: string) => { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== "https:" || !PIXIV_HOSTS.has(url.hostname.toLowerCase())) { + return null; + } + const match = /^\/artworks\/(\d+)\/?$/.exec(url.pathname); + return match?.[1] ?? null; +}; + +export const buildFfmpegConcat = (frames: Array<{ file: string; delay: number }>) => { + if (frames.length === 0 || frames.some((frame) => frame.delay <= 0)) { + throw new Error("Invalid ugoira frame timing"); + } + const lines: string[] = []; + for (const frame of frames) { + if (frame.file.includes("'") || frame.file.includes("\n")) { + throw new Error("Invalid ugoira frame filename"); + } + lines.push(`file '${frame.file}'`, `duration ${frame.delay / 1000}`); + } + lines.push(`file '${frames.at(-1)?.file}'`); + return `${lines.join("\n")}\n`; +}; + +export const extractUgoiraZip = async ( + archive: ArrayBuffer | Uint8Array, + frames: Array<{ file: string; delay: number }>, + limits: { compressed?: number; uncompressed?: number; frames?: number } = {}, +) => { + const compressedLimit = limits.compressed ?? MAX_PIXIV_DOWNLOAD_BYTES; + const uncompressedLimit = limits.uncompressed ?? MAX_UGOIRA_UNCOMPRESSED_BYTES; + const frameLimit = limits.frames ?? MAX_UGOIRA_FRAMES; + if (archive.byteLength > compressedLimit || frames.length > frameLimit) { + throw new Error("Ugoira is too large"); + } + for (const frame of frames) { + if (frame.file.includes("/") || frame.file.includes("\\")) { + throw new Error("Unsafe ugoira archive path"); + } + } + const zip = await JSZip.loadAsync(archive); + for (const entry of Object.values(zip.files)) { + if (entry.unsafeOriginalName && entry.unsafeOriginalName !== entry.name) { + throw new Error("Unsafe ugoira archive path"); + } + } + const directory = await createTemporaryDirectory(); + const total = { value: 0 }; + try { + for (const frame of frames) { + const entry = zip.file(frame.file); + if (!entry || entry.dir) { + throw new Error(`Missing ugoira frame ${frame.file}`); + } + await writeBoundedEntry(entry, `${directory}/${frame.file}`, uncompressedLimit, total); + } + const concatPath = `${directory}/frames.txt`; + await Bun.write( + concatPath, + buildFfmpegConcat(frames.map((frame) => ({ ...frame, file: `${directory}/${frame.file}` }))), + ); + return { directory, concatPath }; + } catch (error) { + await removeTemporaryDirectory(directory); + throw error; + } +}; + +export const convertUgoira = async (concatPath: string, output: string) => { + // biome-ignore lint/correctness/noUndeclaredVariables: Server runtime is Bun. + const child = Bun.spawn( + [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concatPath, + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-fs", + String(MAX_PIXIV_DOWNLOAD_BYTES), + output, + ], + { stdout: "ignore", stderr: "pipe" }, + ); + const stderr = new Response(child.stderr).arrayBuffer(); + let timeout: ReturnType | undefined; + try { + const result = await Promise.race([ + child.exited, + new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => { + resolve("timeout"); + }, FFMPEG_TIMEOUT_MS); + }), + ]); + if (result === "timeout") { + child.kill(); + await child.exited; + await stderr; + throw new Error("Ugoira conversion timed out"); + } + await stderr; + if (result !== 0) { + throw new Error("Failed to convert ugoira"); + } + if ((await Bun.file(output).stat()).size > MAX_PIXIV_DOWNLOAD_BYTES) { + throw new Error("Converted ugoira is too large"); + } + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; diff --git a/apps/server/src/services/tag-normalization.ts b/apps/server/src/services/tag-normalization.ts new file mode 100644 index 00000000..5d1ac837 --- /dev/null +++ b/apps/server/src/services/tag-normalization.ts @@ -0,0 +1,12 @@ +export const normalizeTags = (tags: readonly string[]): string[] => { + const normalized: string[] = []; + const seen = new Set(); + for (const tag of tags) { + const value = tag.trim(); + if (value && !seen.has(value)) { + seen.add(value); + normalized.push(value); + } + } + return normalized; +}; diff --git a/apps/server/src/services/twitter-tags.ts b/apps/server/src/services/twitter-tags.ts new file mode 100644 index 00000000..9f89bce9 --- /dev/null +++ b/apps/server/src/services/twitter-tags.ts @@ -0,0 +1,5 @@ +import type { Tweet } from "@the-convocation/twitter-scraper"; +import { normalizeTags } from "@/services/tag-normalization"; + +export const normalizeTwitterTags = (tweet: Pick): string[] => + normalizeTags(tweet.hashtags); diff --git a/apps/server/src/services/video.ts b/apps/server/src/services/video.ts index 83d31804..d5cb154e 100644 --- a/apps/server/src/services/video.ts +++ b/apps/server/src/services/video.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { env } from "@starlight/utils"; import { http } from "@starlight/utils/http"; import { create } from "youtube-dl-exec"; @@ -17,9 +16,7 @@ export interface VideoInformation { } async function createVideoInformation(filePath: string): Promise { - const parsedPath = path.parse(filePath); - - const infoJsonPath = path.join(parsedPath.dir, `${parsedPath.name}.info.json`); + const infoJsonPath = filePath.replace(/\.mp4$/, ".info.json"); logger.debug({ infoJsonPath }, "Creating video information"); @@ -45,7 +42,7 @@ export async function downloadVideoFromUrl( metadata: VideoMetadata = {}, ): Promise { const uuid = Bun.randomUUIDv7(); - const filePath = path.join(folder, `${uuid}.mp4`); + const filePath = `${folder}/${uuid}.mp4`; logger.debug({ url }, "Downloading video directly from URL"); @@ -87,7 +84,7 @@ export async function downloadVideo(url: string, folder: string): Promise\d+)/u; -const DOMAIN_REGEX = /https?:\/\/(?.+?)\//u; - export class Cookies { readonly cookies: Cookie[]; @@ -35,21 +18,17 @@ export class Cookies { } static fromJSON(data: string): Cookies { - const parsed = Schema.decodeSync(FirefoxCookiesFromJson)(data); - - return new Cookies(parsed.map((cookie) => new Cookie(mapToRFC6265Cookie(cookie)))); + return new Cookies(parseTwitterCookies(data).map((cookie) => new Cookie(cookie))); } userId() { - const twidValue = this.cookies.find((cookie) => cookie.key === "twid")?.value; - - if (!twidValue) { - return; - } - - const decoded = decodeURIComponent(twidValue); - const match = decoded.match(TWID_REGEX); - return match?.groups?.twidValue; + return getTwitterUserId( + this.cookies.map((cookie) => ({ + domain: cookie.domain ?? "", + key: cookie.key, + value: cookie.value, + })), + ); } } @@ -58,16 +37,3 @@ export const s3 = new Bun.S3Client({ secretAccessKey: env.AWS_SECRET_ACCESS_KEY, endpoint: env.AWS_ENDPOINT, }); - -function extractDomain(hostRaw: string): string { - const match = hostRaw.match(DOMAIN_REGEX); - return match?.groups?.domain ?? "x.com"; -} - -export function mapToRFC6265Cookie(firefoxCookie: FirefoxCookieRecord): RFC6265Cookie { - return { - key: firefoxCookie["Name raw"], - value: firefoxCookie["Content raw"], - domain: extractDomain(firefoxCookie["Host raw"]), - }; -} diff --git a/apps/web/src/components/tweet-image-grid.tsx b/apps/web/src/components/post-media-grid.tsx similarity index 79% rename from apps/web/src/components/tweet-image-grid.tsx rename to apps/web/src/components/post-media-grid.tsx index ede77884..19edc1bc 100644 --- a/apps/web/src/components/tweet-image-grid.tsx +++ b/apps/web/src/components/post-media-grid.tsx @@ -1,4 +1,4 @@ -import type { TweetData } from "@starlight/api/src/types/tweets"; +import type { PostData } from "@starlight/api/src/types/posts"; import { X } from "lucide-react"; import type { UIElementData } from "photoswipe"; import type { PhotoSwipe } from "photoswipe/lightbox"; @@ -17,19 +17,19 @@ import { import { Button } from "@/components/ui/button"; import { Carousel } from "@/components/ui/skiper-ui/carousel"; -interface TweetImageGridProps { - onDeleteImage?: (photoId: string) => void; +interface PostMediaGridProps { + onDeleteMedia?: (mediaId: string) => void; showActions?: boolean; showArtistOnHover?: boolean; - tweet: TweetData; + post: PostData; } -export function TweetImageGrid({ - tweet, +export function PostMediaGrid({ + post, showActions = false, showArtistOnHover = false, - onDeleteImage, -}: TweetImageGridProps) { + onDeleteMedia, +}: PostMediaGridProps) { const [isImageLoading, setIsImageLoading] = useState<{ [key: string]: boolean; }>({}); @@ -42,7 +42,7 @@ export function TweetImageGrid({ const handleArtistClick = (e: React.MouseEvent) => { e.stopPropagation(); - window.open(tweet.sourceUrl, "_blank", "noopener,noreferrer"); + window.open(post.sourceUrl, "_blank", "noopener,noreferrer"); }; const uiElements: UIElementData[] = [ @@ -98,8 +98,8 @@ export function TweetImageGrid({ }, ]; - if (tweet.photos.length === 1) { - const [photo] = tweet.photos; + if (post.media.length === 1) { + const media = post.media[0]; return ( {({ ref, open }) => (
@@ -124,23 +124,23 @@ export function TweetImageGrid({ onClick={open} type="button" /> - {isImageLoading[photo.id] && ( + {isImageLoading[media.id] && (
)} {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onLoad and onLoadStart are used only for image loading state */} {photo.alt} handleImageLoad(photo.id, false)} - onLoadStart={() => handleImageLoad(photo.id, true)} + height={media.height || 400} + onLoad={() => handleImageLoad(media.id, false)} + onLoadStart={() => handleImageLoad(media.id, true)} ref={ref} - src={photo.url} - width={photo.width || 400} + src={media.url} + width={media.width || 400} />
@@ -158,20 +158,20 @@ export function TweetImageGrid({ onClick={(e) => handleArtistClick(e)} type="button" > - {tweet.artist} + {post.artist}
- {showActions && onDeleteImage && ( + {showActions && onDeleteMedia && ( )}
@@ -183,7 +183,7 @@ export function TweetImageGrid({ { - if (deleteConfirm) onDeleteImage?.(deleteConfirm); + if (deleteConfirm) onDeleteMedia?.(deleteConfirm); setDeleteConfirm(null); }} onOpenChange={(open) => !open && setDeleteConfirm(null)} @@ -192,13 +192,13 @@ export function TweetImageGrid({ ); } - const convertedPhotos = tweet.photos.map((photo) => ({ - src: photo.url, - alt: photo.alt, - id: photo.id, - height: photo.height, - width: photo.width, - is_nsfw: photo.is_nsfw, + const convertedMedia = post.media.map((media) => ({ + src: media.url, + alt: media.alt, + id: media.id, + height: media.height, + width: media.width, + is_nsfw: media.is_nsfw, })); return ( @@ -210,7 +210,7 @@ export function TweetImageGrid({ withCaption={false} > ( handleImageLoad(item.id || "", false)} - onLoadStart={() => handleImageLoad(item.id || "", true)} - ref={ref} + onLoad={() => handleImageLoad(item.id || "", false)} + onLoadStart={() => handleImageLoad(item.id || "", true)} + ref={ref} src={item.src} width={item.width || 400} /> -
-
+
+
- {showActions && onDeleteImage && ( + {showActions && onDeleteMedia && ( )}
@@ -288,7 +288,7 @@ export function TweetImageGrid({ { - if (deleteConfirm) onDeleteImage?.(deleteConfirm); + if (deleteConfirm) onDeleteMedia?.(deleteConfirm); setDeleteConfirm(null); }} onOpenChange={(open) => !open && setDeleteConfirm(null)} diff --git a/apps/web/src/hooks/use-posts.ts b/apps/web/src/hooks/use-posts.ts new file mode 100644 index 00000000..fa09445d --- /dev/null +++ b/apps/web/src/hooks/use-posts.ts @@ -0,0 +1,41 @@ +import type { PostData, PostsPageResult } from "@starlight/api/src/types/posts"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { orpc } from "@/utils/orpc"; + +const EMPTY_POSTS: PostData[] = []; + +interface UsePostsOptions { + limit?: number; + username?: string; +} + +export function usePosts(options: UsePostsOptions = {}) { + const { username, limit = 30 } = options; + + const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = + useInfiniteQuery( + orpc.posts.list.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + username, + cursor: pageParam, + limit, + }), + queryKey: ["posts", { username }], + initialPageParam: undefined, + getNextPageParam: (lastPage: PostsPageResult) => lastPage.nextCursor ?? undefined, + retry: false, + gcTime: 10 * 60 * 1000, + }), + ); + const posts = data?.pages.flatMap((page) => page.posts) ?? EMPTY_POSTS; + + return { + posts, + isLoading: status === "pending", + isFetching, + isFetchingNextPage, + hasNextPage, + error, + fetchNextPage, + }; +} diff --git a/apps/web/src/hooks/use-search.ts b/apps/web/src/hooks/use-search.ts index 69a2057c..4f68de31 100644 --- a/apps/web/src/hooks/use-search.ts +++ b/apps/web/src/hooks/use-search.ts @@ -1,8 +1,8 @@ -import type { SearchPageResult, TweetData } from "@starlight/api/src/types/tweets"; +import type { SearchPageResult, PostData } from "@starlight/api/src/types/posts"; import { useInfiniteQuery } from "@tanstack/react-query"; import { orpc } from "@/utils/orpc"; -const EMPTY_RESULTS: TweetData[] = []; +const EMPTY_RESULTS: PostData[] = []; interface UseSearchOptions { limit?: number; @@ -15,15 +15,15 @@ export function useSearch(options: UseSearchOptions) { const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery( - orpc.tweets.search.infiniteOptions({ - input: (pageParam: string | null | undefined) => ({ + orpc.posts.search.infiniteOptions({ + input: (pageParam: string | undefined) => ({ query, - cursor: pageParam ?? undefined, + cursor: pageParam, limit, ownOnly, }), queryKey: ["search", { query, ownOnly }], - initialPageParam: null, + initialPageParam: undefined, getNextPageParam: (lastPage: SearchPageResult) => lastPage.nextCursor ?? undefined, retry: false, gcTime: 10 * 60 * 1000, diff --git a/apps/web/src/hooks/use-tweets.ts b/apps/web/src/hooks/use-tweets.ts deleted file mode 100644 index 40ae097e..00000000 --- a/apps/web/src/hooks/use-tweets.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { TweetData, TweetsPageResult } from "@starlight/api/src/types/tweets"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { orpc } from "@/utils/orpc"; - -const EMPTY_TWEETS: TweetData[] = []; - -interface UseTweetsOptions { - limit?: number; - username?: string; -} - -export function useTweets(options: UseTweetsOptions = {}) { - const { username, limit = 30 } = options; - - const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = - useInfiniteQuery( - orpc.tweets.list.infiniteOptions({ - input: (pageParam: string | null | undefined) => ({ - username, - cursor: pageParam ?? undefined, - limit, - }), - queryKey: ["tweets", { username }], - initialPageParam: null, - getNextPageParam: (lastPage: TweetsPageResult) => lastPage.nextCursor ?? undefined, - retry: false, - gcTime: 10 * 60 * 1000, - }), - ); - const tweets = data?.pages.flatMap((page) => page.tweets) ?? EMPTY_TWEETS; - - return { - tweets, - isLoading: status === "pending", - isFetching, - isFetchingNextPage, - hasNextPage, - error, - fetchNextPage, - }; -} diff --git a/apps/web/src/lib/pagination.ts b/apps/web/src/lib/pagination.ts index 0a65a297..36072c67 100644 --- a/apps/web/src/lib/pagination.ts +++ b/apps/web/src/lib/pagination.ts @@ -1,6 +1,6 @@ export interface CursorData { createdAt: string; - lastTweetId: string; + lastPostId: string; } export const CursorPagination = { diff --git a/apps/web/src/routes/app.tsx b/apps/web/src/routes/app.tsx index 90663c8a..31b0488f 100644 --- a/apps/web/src/routes/app.tsx +++ b/apps/web/src/routes/app.tsx @@ -1,5 +1,5 @@ import type { ProfileResult } from "@starlight/api/routers/index"; -import type { TweetData, TweetsPageResult } from "@starlight/api/types/tweets"; +import type { PostData, PostsPageResult } from "@starlight/api/types/posts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { AlertTriangle, Search } from "lucide-react"; @@ -10,19 +10,19 @@ import { NotFound } from "@/components/not-found"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useSearch } from "@/hooks/use-search"; -import { useTweets } from "@/hooks/use-tweets"; +import { usePosts } from "@/hooks/use-posts"; import { cn } from "@/lib/utils"; import { useTelegramContext } from "@/providers/telegram-buttons-provider"; import { client, orpc } from "@/utils/orpc"; -const TweetImageGrid = lazy(() => - import("@/components/tweet-image-grid").then((m) => ({ default: m.TweetImageGrid })), +const PostMediaGrid = lazy(() => + import("@/components/post-media-grid").then((m) => ({ default: m.PostMediaGrid })), ); const MASONRY_ITEM_HEIGHT_ESTIMATE = 360; const MASONRY_OVERSCAN_BY = 1.25; -function TwitterArtViewer() { +function MediaGallery() { const { updateButtons, rawInitData } = useTelegramContext(); const queryClient = useQueryClient(); @@ -64,7 +64,7 @@ function TwitterArtViewer() { }; }, [updateButtons, profile]); - // Search hook - search only own tweets in TMA + // Search hook - search only own posts in TMA const { results: searchResults, isLoading: isSearchLoading, @@ -75,17 +75,17 @@ function TwitterArtViewer() { const isSearchActive = urlQuery.trim().length > 0; - const { tweets, isLoading, isFetchingNextPage, hasNextPage, error, fetchNextPage } = useTweets(); + const { posts, isLoading, isFetchingNextPage, hasNextPage, error, fetchNextPage } = usePosts(); - const { mutate: deletePhoto } = useMutation({ - mutationFn: (photoId: string) => client.tweets.delete({ photoId }), + const { mutate: deleteMedia } = useMutation({ + mutationFn: (mediaId: string) => client.media.delete({ mediaId }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tweets"] }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); }, }); - const handleDeleteImage = (photoId: string) => { - deletePhoto(photoId); + const handleDeleteMedia = (mediaId: string) => { + deleteMedia(mediaId); }; const handleSearch = (e: React.FormEvent) => { @@ -94,7 +94,7 @@ function TwitterArtViewer() { setUrlQuery(trimmedQuery || null, { history: "push" }); }; - // Infinite loader for regular tweets + // Infinite loader for regular posts const infiniteLoader = useInfiniteLoader( async (_startIndex, _stopIndex, _items) => { if (hasNextPage && !isFetchingNextPage) { @@ -122,9 +122,9 @@ function TwitterArtViewer() { }, ); - const renderMasonryItem = ({ data, width }: { data: TweetData; width: number }) => ( + const renderMasonryItem = ({ data, width }: { data: PostData; width: number }) => (
- +
); @@ -133,16 +133,16 @@ function TwitterArtViewer() { return (
} - title="Failed to load tweets (。•́︿•̀。)" + title="Failed to load posts (。•́︿•̀。)" />
); } // Determine which data to display - const displayItems = isSearchActive ? searchResults : tweets; + const displayItems = isSearchActive ? searchResults : posts; const displayLoading = isSearchActive ? isSearchLoading : isLoading; const currentInfiniteLoader = isSearchActive ? searchInfiniteLoader : infiniteLoader; @@ -153,7 +153,7 @@ function TwitterArtViewer() {
{/** biome-ignore lint/correctness/useImageSize: animated loader uses CSS sizing intentionally */} Searching for cute anime girls… @@ -168,7 +168,7 @@ function TwitterArtViewer() { ? "No results found for your search. Try different keywords." : "Did you setup cookies? Try again later." } - title={isSearchActive ? "No search results" : "No photos found"} + title={isSearchActive ? "No search results" : "No media found"} />
)} @@ -181,7 +181,7 @@ function TwitterArtViewer() { tweet.id} + itemKey={(post) => post.id} items={displayItems} onRender={currentInfiniteLoader} overscanBy={MASONRY_OVERSCAN_BY} @@ -242,19 +242,19 @@ export const Route = createFileRoute("/app")({ await Promise.all([ queryClient.fetchQuery(profileOptions), queryClient.fetchInfiniteQuery( - orpc.tweets.list.infiniteOptions({ - input: (pageParam: string | null | undefined) => ({ - cursor: pageParam ?? undefined, + orpc.posts.list.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + cursor: pageParam, limit: 30, }), - queryKey: ["tweets", { username: null }], - initialPageParam: null, - getNextPageParam: (lastPage: TweetsPageResult) => lastPage.nextCursor ?? undefined, + queryKey: ["posts", { username: undefined }], + initialPageParam: undefined, + getNextPageParam: (lastPage: PostsPageResult) => lastPage.nextCursor ?? undefined, retry: false, gcTime: 10 * 60 * 1000, }), ), ]); }, - component: TwitterArtViewer, + component: MediaGallery, }); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4031d172..1f82f518 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,4 +1,4 @@ -import type { TweetData } from "@starlight/api/src/types/tweets"; +import type { PostData } from "@starlight/api/src/types/posts"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { Search } from "lucide-react"; @@ -12,26 +12,26 @@ import { cn } from "@/lib/utils"; import { LayoutManager } from "@/utils/layout"; import { orpc } from "@/utils/orpc"; -const TweetImageGrid = lazy(() => - import("@/components/tweet-image-grid").then((m) => ({ default: m.TweetImageGrid })), +const PostMediaGrid = lazy(() => + import("@/components/post-media-grid").then((m) => ({ default: m.PostMediaGrid })), ); const MASONRY_ITEM_HEIGHT_ESTIMATE = 360; const MASONRY_OVERSCAN_BY = 1.25; -const renderMasonryItem = ({ data, width }: { data: TweetData; width: number }) => ( +const renderMasonryItem = ({ data, width }: { data: PostData; width: number }) => (
- +
); -// Generate non-overlapping positions for random images; skipped during SSR. -function placeRandomImages(tweets: TweetData[]) { - if (tweets.length === 0 || typeof window === "undefined") { +// Generate non-overlapping positions for random posts; skipped during SSR. +function placeRandomPosts(posts: PostData[]) { + if (posts.length === 0 || typeof window === "undefined") { return []; } - return new LayoutManager(100, 100).placeTweets(tweets); + return new LayoutManager(100, 100).placePosts(posts); } const examples = [ @@ -66,8 +66,8 @@ export default function DiscoverPage() { }); const randomQuery = useQuery({ - ...orpc.tweets.random.queryOptions({ retry: false }), - queryKey: ["tweets-random"], + ...orpc.posts.random.queryOptions({ retry: false }), + queryKey: ["posts-random"], enabled: true, staleTime: Number.POSITIVE_INFINITY, gcTime: Number.POSITIVE_INFINITY, @@ -99,8 +99,8 @@ export default function DiscoverPage() { }, ); - const randomImages = randomQuery.data || []; - const placedData = placeRandomImages(randomImages); + const randomPosts = randomQuery.data || []; + const placedData = placeRandomPosts(randomPosts); const isHomeIdle = !isLoading && results.length === 0; const showHeroCollage = @@ -117,7 +117,7 @@ export default function DiscoverPage() { tweet.id} + itemKey={(post) => post.id} items={results} onRender={infiniteLoader} overscanBy={MASONRY_OVERSCAN_BY} @@ -126,7 +126,7 @@ export default function DiscoverPage() {
) : ( - // Hero Section with centered search and floating images on large screen + // Hero Section with centered search and floating media on large screen
@@ -134,7 +134,7 @@ export default function DiscoverPage() {
{/** biome-ignore lint/correctness/useImageSize: animated loader uses CSS sizing intentionally */} Searching for cute anime girls… @@ -158,7 +158,7 @@ export default function DiscoverPage() { setInputValue(e.target.value)} - placeholder="Search for images…" + placeholder="Search for images…" type="text" value={inputValue} /> @@ -168,9 +168,9 @@ export default function DiscoverPage() { type="submit" > {isLoading ? ( - + ) : ( - + )} Search @@ -207,11 +207,11 @@ export default function DiscoverPage() {
{placedData.map(({ position, index }, i) => { - const tweet = randomImages[index]; + const post = randomPosts[index]; return (
- +
); })} @@ -267,8 +267,8 @@ export default function DiscoverPage() { export const Route = createFileRoute("/")({ loader: ({ context: { queryClient } }) => { queryClient.prefetchQuery({ - ...orpc.tweets.random.queryOptions({ retry: false }), - queryKey: ["tweets-random"], + ...orpc.posts.random.queryOptions({ retry: false }), + queryKey: ["posts-random"], }); }, component: DiscoverPage, diff --git a/apps/web/src/routes/profile/$slug.tsx b/apps/web/src/routes/profile/$slug.tsx index ecd8fd8c..587dbabd 100644 --- a/apps/web/src/routes/profile/$slug.tsx +++ b/apps/web/src/routes/profile/$slug.tsx @@ -1,28 +1,28 @@ -import type { TweetData, TweetsPageResult } from "@starlight/api/src/types/tweets"; +import type { PostData, PostsPageResult } from "@starlight/api/src/types/posts"; import { createFileRoute, useParams } from "@tanstack/react-router"; import { Masonry, useInfiniteLoader } from "masonic"; import { lazy, Suspense } from "react"; import { NotFound } from "@/components/not-found"; -import { useTweets } from "@/hooks/use-tweets"; +import { usePosts } from "@/hooks/use-posts"; import { orpc } from "@/utils/orpc"; -const TweetImageGrid = lazy(() => - import("@/components/tweet-image-grid").then((m) => ({ default: m.TweetImageGrid })), +const PostMediaGrid = lazy(() => + import("@/components/post-media-grid").then((m) => ({ default: m.PostMediaGrid })), ); const MASONRY_ITEM_HEIGHT_ESTIMATE = 360; const MASONRY_OVERSCAN_BY = 1.25; -const renderMasonryItem = ({ data, width }: { data: TweetData; width: number }) => ( +const renderMasonryItem = ({ data, width }: { data: PostData; width: number }) => (
- +
); function SharedProfileViewer() { const { slug } = useParams({ from: "/profile/$slug" }); - const { tweets, isLoading, isFetchingNextPage, hasNextPage, error, fetchNextPage } = useTweets({ + const { posts, isLoading, isFetchingNextPage, hasNextPage, error, fetchNextPage } = usePosts({ username: slug, }); @@ -58,7 +58,7 @@ function SharedProfileViewer() { return (
- {!isLoading && tweets.length === 0 && ( + {!isLoading && posts.length === 0 && (
)} - {tweets.length > 0 && ( + {posts.length > 0 && (
tweet.id} - items={tweets} + itemKey={(post) => post.id} + items={posts} onRender={infiniteLoader} overscanBy={MASONRY_OVERSCAN_BY} render={renderMasonryItem} @@ -96,14 +96,14 @@ export const Route = createFileRoute("/profile/$slug")({ } await queryClient.fetchInfiniteQuery( - orpc.tweets.list.infiniteOptions({ - input: (pageParam: string | null | undefined) => ({ - cursor: pageParam ?? undefined, + orpc.posts.list.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + cursor: pageParam, limit: 30, }), - queryKey: ["tweets", { username: slug }], - initialPageParam: null, - getNextPageParam: (lastPage: TweetsPageResult) => lastPage.nextCursor ?? undefined, + queryKey: ["posts", { username: slug }], + initialPageParam: undefined, + getNextPageParam: (lastPage: PostsPageResult) => lastPage.nextCursor ?? undefined, retry: false, gcTime: 10 * 60 * 1000, }), diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 718cf7c5..b822a05f 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,7 +1,7 @@ import type { ProfileResult } from "@starlight/api/routers/index"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { AlertCircle, Cookie, Trash2 } from "lucide-react"; +import { AlertCircle, Cookie, KeyRound, Trash2 } from "lucide-react"; import { useState, useSyncExternalStore } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -32,6 +32,7 @@ export const Route = createFileRoute("/settings")({ function RouteComponent() { const [newCookies, setNewCookies] = useState(""); + const [pixivToken, setPixivToken] = useState(""); const [displayError, setDisplayError] = useState(null); const { rawInitData } = useTelegramContext(); @@ -93,6 +94,38 @@ function RouteComponent() { }), ); + const savePixivMutation = useMutation( + orpc.pixiv.save.mutationOptions({ + onSuccess: () => { + queryClient.setQueryData(["profile"], (old: ProfileResult) => ({ + ...old, + hasPixivCredential: true, + })); + setPixivToken(""); + }, + }), + ); + const deletePixivMutation = useMutation( + orpc.pixiv.delete.mutationOptions({ + onSuccess: () => { + queryClient.setQueryData(["profile"], (old: ProfileResult) => ({ + ...old, + hasPixivCredential: false, + })); + }, + }), + ); + const pixivPrivateMutation = useMutation( + orpc.pixiv.privateBookmarks.mutationOptions({ + onSuccess: (_data, variables) => { + queryClient.setQueryData(["profile"], (old: ProfileResult) => ({ + ...old, + pixivIncludePrivate: variables.enabled, + })); + }, + }), + ); + if (isLoading && !profile) { return ; } @@ -104,13 +137,18 @@ function RouteComponent() { const isSubmitting = saveCookiesMutation.isPending || deleteCookiesMutation.isPending || - visibilityMutation.isPending; + visibilityMutation.isPending || + savePixivMutation.isPending || + deletePixivMutation.isPending || + pixivPrivateMutation.isPending; + const pixivError = profile?.hasPixivCredential + ? (deletePixivMutation.error?.message ?? pixivPrivateMutation.error?.message) + : savePixivMutation.error?.message; return (
- {/* Cookie Management Section */} +
+

+ Pixiv +

+ {pixivError && ( + + + {pixivError} + + )} + {profile?.hasPixivCredential ? ( + <> + + + Pixiv is connected. + + + + + ) : ( +
{ + event.preventDefault(); + savePixivMutation.mutate({ refreshToken: pixivToken }); + }} + > + + + + )} +
+ {/* Profile Visibility Section */} Authentication Cookies - - {/* Cookie Success/Error Messages */} {cookieError && ( @@ -235,32 +333,23 @@ function CookiesSection({ Authentication cookies are saved.
-
) : (
- {!profile?.hasValidCookies && ( - - - - Connect your Twitter account by adding authentication cookies - - - )} - + + + + Connect your Twitter account by adding authentication cookies + +
{ - e.preventDefault(); + onSubmit={(event) => { + event.preventDefault(); onSave(newCookies); }} > @@ -278,7 +367,6 @@ function CookiesSection({ /> {displayError &&

{displayError}

}
-
); } -// The origin cannot change during a page's lifetime, so subscribing would be -// a no-op; only the snapshots matter to useSyncExternalStore. function subscribeToOrigin() { - return () => { - // Nothing to clean up. - }; + return () => {}; } function ProfileLinkBlock({ username }: { username: string }) { - // window is unavailable during SSR; useSyncExternalStore renders the - // path-only form on the server and upgrades to the absolute URL on mount. const origin = useSyncExternalStore( subscribeToOrigin, () => window.location.origin, diff --git a/apps/web/src/utils/layout.ts b/apps/web/src/utils/layout.ts index d1275447..40e64e3c 100644 --- a/apps/web/src/utils/layout.ts +++ b/apps/web/src/utils/layout.ts @@ -1,4 +1,4 @@ -import type { TweetData } from "@starlight/api/src/types/tweets"; +import type { PostData } from "@starlight/api/src/types/posts"; export class LayoutManager { pageWidth: number; @@ -109,7 +109,7 @@ export class LayoutManager { return null; } - placeTweets(tweets: TweetData[]) { + placePosts(posts: PostData[]) { const results: { position: { top: number; left: number }; index: number; @@ -117,16 +117,16 @@ export class LayoutManager { const CONTAINER_WIDTH_PERCENT = 20; // Place in original order - for (let i = 0; i < tweets.length; i++) { - const tweet = tweets[i]; - if (!tweet.photos.length) { + for (let i = 0; i < posts.length; i++) { + const post = posts[i]; + if (!post.media.length) { continue; } - const [firstPhoto] = tweet.photos; + const firstMedia = post.media[0]; let aspect = 0.8; - if (firstPhoto.width && firstPhoto.height && firstPhoto.width > 0) { - aspect = firstPhoto.height / firstPhoto.width; + if (firstMedia.width && firstMedia.height && firstMedia.width > 0) { + aspect = firstMedia.height / firstMedia.width; } const computedHeight = CONTAINER_WIDTH_PERCENT * aspect; diff --git a/bun.lock b/bun.lock index fe76c63a..fee68ca7 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "date-fns": "^4.4.0", "effect": "4.0.0-rc.111", "grammy": "catalog:", + "jszip": "^3.10.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "sharp": "^0.35.3", @@ -117,6 +118,7 @@ "packages/api": { "name": "@starlight/api", "dependencies": { + "@book000/pixivts": "^0.63.0", "@orpc/client": "catalog:", "@orpc/server": "catalog:", "@starlight/crypto": "workspace:*", @@ -230,6 +232,8 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@book000/pixivts": ["@book000/pixivts@0.63.0", "", {}, "sha512-UymyhIyXrN9nPvdzDNgxBluh9hzCgWMPDr3FALCTYW4zE84iShAhcAagtmo6dTzB9FOeGDgfi1+TOwvvN7tzUQ=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], @@ -1834,6 +1838,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="], @@ -1968,6 +1974,8 @@ "jsx-ast-utils-x": ["jsx-ast-utils-x@0.1.0", "", {}, "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -2008,6 +2016,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -2218,6 +2228,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -2346,7 +2358,7 @@ "react-stately": ["react-stately@3.49.0", "", { "dependencies": { "@internationalized/date": "^3.12.3", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A=="], - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -2408,7 +2420,7 @@ "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -2458,6 +2470,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], @@ -2522,7 +2536,7 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -2840,10 +2854,14 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "archiver/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "archiver-utils/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "binary-version/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -2854,6 +2872,10 @@ "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "compress-commons/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "crc32-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "d3-scale/d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], "debug-logfmt/pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="], @@ -2900,8 +2922,6 @@ "htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "listhen/crossws": ["crossws@0.4.12", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-aypfsr6t0uNvkqaZc6zvBfXzC6pLI0/sIulpkV6RwCVtZqG5ebBzv4weImKK0VNCj91Wl9F5j7p5WU4MNrybng=="], "listhen/std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], @@ -2936,6 +2956,8 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], @@ -2984,6 +3006,8 @@ "youch/cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "zip-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], @@ -3022,6 +3046,10 @@ "archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "archiver/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "binary-version/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], "binary-version/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], @@ -3036,6 +3064,10 @@ "cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "compress-commons/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "crc32-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "debug-logfmt/pretty-ms/parse-ms": ["parse-ms@2.1.0", "", {}, "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="], "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], @@ -3044,12 +3076,6 @@ "htmlparser2/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], - "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - - "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3060,6 +3086,8 @@ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "zip-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@prisma/config/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -3068,8 +3096,16 @@ "archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "archiver-utils/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "archiver/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "binary-version/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "compress-commons/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "crc32-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -3078,6 +3114,8 @@ "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "zip-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/packages/api/package.json b/packages/api/package.json index 70fbf26c..17754a3d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -24,6 +24,7 @@ "typescript": "^7.0.2" }, "dependencies": { + "@book000/pixivts": "^0.63.0", "@orpc/client": "catalog:", "@orpc/server": "catalog:", "@starlight/crypto": "workspace:*", diff --git a/packages/api/src/routers/cookies.ts b/packages/api/src/routers/cookies.ts index 0b529db0..85e63a46 100644 --- a/packages/api/src/routers/cookies.ts +++ b/packages/api/src/routers/cookies.ts @@ -1,19 +1,14 @@ import { ORPCError } from "@orpc/client"; -import { CookieEncryption } from "@starlight/crypto"; -import { env, prisma } from "@starlight/utils"; +import { prisma } from "@starlight/utils"; import { z } from "zod"; -import { protectedProcedure } from "../middlewares/auth"; -import type { AuthContext } from "../middlewares/auth"; +import { type AuthContext, protectedProcedure } from "../middlewares/auth"; +import { normalizeTwitterCookies } from "../services/twitter-cookies"; +import { encryptTwitterCookies, getTwitterCookies } from "../services/twitter-credential"; const cookiesSchema = z.object({ cookies: z.string(), }); -const cookieEncryption = new CookieEncryption( - env.COOKIE_ENCRYPTION_KEY, - env.COOKIE_ENCRYPTION_SALT, -); - export const saveCookies = protectedProcedure .input(cookiesSchema) .handler(async ({ input, context }) => { @@ -24,23 +19,30 @@ export const saveCookies = protectedProcedure }); } - // Attempt to decode cookies; accept any non-empty string - if (!input.cookies?.trim()) { + let cookies: string; + try { + cookies = normalizeTwitterCookies(input.cookies); + } catch { throw new ORPCError("BAD_REQUEST", { message: "Invalid cookies", status: 400, }); } - // Encrypt and store under telegramId scoped key - const encryptedCookies = cookieEncryption.encrypt(input.cookies, context.user.id.toString()); + const userId = context.databaseUserId; + const encryptedCookies = encryptTwitterCookies(cookies, userId); - await prisma.user.update({ - where: { - id: context.databaseUserId, + await prisma.providerCredential.upsert({ + where: { userId_provider: { userId, provider: "twitter" } }, + create: { + userId, + provider: "twitter", + credentialType: "cookies", + encryptedSecret: encryptedCookies, }, - data: { - cookies: encryptedCookies, + update: { + credentialType: "cookies", + encryptedSecret: encryptedCookies, }, }); }); @@ -51,32 +53,9 @@ export const verifyCookies = async ({ context }: { context: AuthContext }) => { return { hasValidCookies: false }; } - const user = await prisma.user.findUnique({ - where: { - id: context.databaseUserId, - }, - select: { - cookies: true, - }, - }); - - const storedCookies = user?.cookies; + const cookies = await getTwitterCookies(context.databaseUserId); - if (!storedCookies) { - return { hasValidCookies: false }; - } - - try { - cookieEncryption.safeDecrypt(storedCookies, context.user.id.toString()); - } catch { - await prisma.user.update({ - where: { - id: context.databaseUserId, - }, - data: { - cookies: null, - }, - }); + if (!cookies) { return { hasValidCookies: false }; } @@ -96,12 +75,7 @@ export const deleteCookies = protectedProcedure.handler(async ({ context }) => { }); } - await prisma.user.update({ - where: { - id: context.databaseUserId, - }, - data: { - cookies: null, - }, + await prisma.providerCredential.deleteMany({ + where: { userId: context.databaseUserId, provider: "twitter" }, }); }); diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index a40d64b3..8fbb326c 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -1,8 +1,9 @@ import type { InferRouterInputs, InferRouterOutputs, RouterClient } from "@orpc/server"; import { deleteCookies, saveCookies } from "./cookies"; import { changeProfileVisibility, getUserProfile } from "./profiles"; +import { deletePixivCredential, savePixivCredential, setPixivPrivateBookmarks } from "./pixiv"; import { randomImages, searchImages } from "./search"; -import { deletePhoto, listUserTweets } from "./tweets"; +import { deleteMedia, listUserPosts } from "./posts"; export const appRouter = { profiles: { @@ -13,12 +14,19 @@ export const appRouter = { save: saveCookies, delete: deleteCookies, }, - tweets: { - list: listUserTweets, - delete: deletePhoto, + pixiv: { + save: savePixivCredential, + delete: deletePixivCredential, + privateBookmarks: setPixivPrivateBookmarks, + }, + posts: { + list: listUserPosts, search: searchImages, random: randomImages, }, + media: { + delete: deleteMedia, + }, }; export type AppRouter = typeof appRouter; export type AppRouterClient = RouterClient; diff --git a/packages/api/src/routers/pixiv.ts b/packages/api/src/routers/pixiv.ts new file mode 100644 index 00000000..93b98c70 --- /dev/null +++ b/packages/api/src/routers/pixiv.ts @@ -0,0 +1,59 @@ +import { ORPCError } from "@orpc/client"; +import { prisma } from "@starlight/utils"; +import { z } from "zod"; +import { protectedProcedure } from "../middlewares/auth"; +import { PixivAdapter } from "../services/pixiv"; +import { encryptPixivToken, withPixivLock } from "../services/pixiv-credential"; + +export const savePixivCredential = protectedProcedure + .input(z.object({ refreshToken: z.string().trim().min(20) })) + .handler(async ({ input, context }) => { + const userId = context.databaseUserId; + await withPixivLock(userId, async () => { + let client: PixivAdapter; + try { + client = await PixivAdapter.connect(input.refreshToken); + } catch { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid Pixiv refresh token", + status: 400, + }); + } + await prisma.providerCredential.upsert({ + where: { userId_provider: { userId, provider: "pixiv" } }, + create: { + userId, + provider: "pixiv", + credentialType: "refresh_token", + externalUserId: client.externalUserId, + encryptedSecret: encryptPixivToken(client.refreshToken, userId), + }, + update: { + credentialType: "refresh_token", + externalUserId: client.externalUserId, + encryptedSecret: encryptPixivToken(client.refreshToken, userId), + }, + }); + }); + return { success: true }; + }); + +export const deletePixivCredential = protectedProcedure.handler(async ({ context }) => { + const userId = context.databaseUserId; + await withPixivLock(userId, async () => { + await prisma.providerCredential.deleteMany({ + where: { userId, provider: "pixiv" }, + }); + }); + return { success: true }; +}); + +export const setPixivPrivateBookmarks = protectedProcedure + .input(z.object({ enabled: z.boolean() })) + .handler(async ({ input, context }) => { + await prisma.user.update({ + where: { id: context.databaseUserId }, + data: { pixivIncludePrivate: input.enabled }, + }); + return { success: true }; + }); diff --git a/packages/api/src/routers/tweets.ts b/packages/api/src/routers/posts.ts similarity index 57% rename from packages/api/src/routers/tweets.ts rename to packages/api/src/routers/posts.ts index 17a70106..546fb9e4 100644 --- a/packages/api/src/routers/tweets.ts +++ b/packages/api/src/routers/posts.ts @@ -1,21 +1,20 @@ import { ORPCError } from "@orpc/client"; -import { prisma } from "@starlight/utils"; -import type { Prisma, User } from "@starlight/utils"; +import { type Prisma, prisma, type User } from "@starlight/utils"; import { z } from "zod"; import { no } from ".."; import { maybeAuthProcedure, protectedProcedure } from "../middlewares/auth"; -import { Cursor, CursorPayloadSchema } from "../utils/cursor"; -import type { CursorPayload } from "../utils/cursor"; -import { transformTweets } from "../utils/transformations"; +import { Cursor, CursorPayloadSchema, type CursorPayload } from "../utils/cursor"; +import { parseMediaPublicId } from "../utils/public-id"; +import { transformPosts } from "../utils/transformations"; -const TweetsQuery = z.object({ +const PostsQuery = z.object({ username: z.string().optional(), cursor: z.string().optional(), limit: z.number().min(1).max(100).default(30), }); -export const listUserTweets = maybeAuthProcedure - .input(TweetsQuery) +export const listUserPosts = maybeAuthProcedure + .input(PostsQuery) .handler(async ({ input, context }) => { const { user } = context; @@ -37,13 +36,13 @@ export const listUserTweets = maybeAuthProcedure }); } } else if (user) { - // Own tweets (authenticated, no username provided) + // Own posts (authenticated, no username provided) targetUser = await prisma.user.findUnique({ where: { telegramId: user.id }, select: { id: true, telegramId: true, username: true, isPublic: true }, }); } else { - // Anonymous cannot request own tweets without specifying a username + // Anonymous cannot request own posts without specifying a username throw new ORPCError("UNAUTHORIZED", { message: "Unauthorized", status: 401, @@ -60,7 +59,7 @@ export const listUserTweets = maybeAuthProcedure }); } - return await retrieveUserTweets({ + return await retrieveUserPosts({ // biome-ignore lint/style/noNonNullAssertion: We know targetUser is not null userId: targetUser!.id, cursor, @@ -68,9 +67,9 @@ export const listUserTweets = maybeAuthProcedure }); }); -export const retrieveUserTweets = no +export const retrieveUserPosts = no .input( - TweetsQuery.omit({ username: true }).extend({ + PostsQuery.omit({ username: true }).extend({ userId: z.string(), }), ) @@ -84,13 +83,13 @@ export const retrieveUserTweets = no if (!cursorData) { return { - tweets: [], + posts: [], nextCursor: null, }; } } - const whereClause: Prisma.TweetWhereInput = { + const whereClause: Prisma.PostWhereInput = { userId, }; @@ -98,21 +97,24 @@ export const retrieveUserTweets = no const cursorDate = new Date(cursorData.createdAt); whereClause.OR = [ { createdAt: { lt: cursorDate } }, - { createdAt: cursorDate, id: { lt: cursorData.lastTweetId } }, + { createdAt: cursorDate, id: { lt: cursorData.lastPostId } }, + { + createdAt: cursorDate, + id: cursorData.lastPostId, + provider: { lt: cursorData.provider ?? "twitter" }, + }, ]; } - const tweets = await prisma.tweet.findMany({ + const posts = await prisma.post.findMany({ where: { ...whereClause, - ...prisma.tweet.available(), + ...prisma.post.available(), }, include: { - photos: { - where: prisma.photo.available(), - orderBy: { - createdAt: "desc", - }, + media: { + where: prisma.media.available(), + orderBy: [{ position: "asc" }, { id: "asc" }], }, }, orderBy: [ @@ -122,57 +124,75 @@ export const retrieveUserTweets = no { id: "desc", }, + { provider: "desc" }, ], take: limit, }); - const transformedTweets = transformTweets(tweets); + const transformedPosts = transformPosts(posts); let nextCursor: string | null = null; - if (tweets.length === limit) { - // biome-ignore lint/style/noNonNullAssertion: We know there's at least one tweet - const lastTweet = tweets.at(-1)!; + if (posts.length === limit) { + // biome-ignore lint/style/noNonNullAssertion: We know there's at least one post + const lastPost = posts.at(-1)!; nextCursor = Cursor.create({ - lastTweetId: lastTweet.id, - createdAt: lastTweet.createdAt.toISOString(), + lastPostId: lastPost.id, + provider: lastPost.provider, + createdAt: lastPost.createdAt.toISOString(), }); } return { - tweets: transformedTweets, + posts: transformedPosts, nextCursor, }; } catch { return { - tweets: [], + posts: [], nextCursor: null, }; } }) .callable(); -export const deletePhoto = protectedProcedure - .input(z.object({ photoId: z.string() })) +export const deleteMedia = protectedProcedure + .input(z.object({ mediaId: z.string() })) .handler(async ({ input, context }) => { - const photo = await prisma.photo.findFirst({ + const mediaId = parseMediaPublicId(input.mediaId); + if (!mediaId) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid media ID", + status: 400, + }); + } + const { provider, externalId, userId } = mediaId; + if (userId && userId !== context.databaseUserId) { + throw new ORPCError("NOT_FOUND", { + message: "Media not found", + status: 404, + }); + } + const media = await prisma.media.findFirst({ where: { - id: input.photoId, + id: externalId, + provider, userId: context.databaseUserId, deletedAt: null, }, }); - if (!photo) { + if (!media) { throw new ORPCError("NOT_FOUND", { - message: "Photo not found", + message: "Media not found", status: 404, }); } - await prisma.photo.update({ + await prisma.media.update({ where: { - photoId: { - id: input.photoId, + mediaId: { + id: externalId, + provider, userId: context.databaseUserId, }, }, diff --git a/packages/api/src/routers/profiles.ts b/packages/api/src/routers/profiles.ts index 1fffff09..f6e8e189 100644 --- a/packages/api/src/routers/profiles.ts +++ b/packages/api/src/routers/profiles.ts @@ -27,6 +27,8 @@ const UserProfileSchema = z.object({ isPublic: z.boolean(), }), hasValidCookies: z.boolean(), + hasPixivCredential: z.boolean(), + pixivIncludePrivate: z.boolean(), postingChannel: z .object({ id: z.bigint(), @@ -52,6 +54,11 @@ export const getUserProfile = protectedProcedure isPublic: true, createdAt: true, updatedAt: true, + pixivIncludePrivate: true, + providerCredentials: { + where: { provider: "pixiv", credentialType: "refresh_token" }, + select: { provider: true }, + }, }, }), verifyCookies({ context }), @@ -71,5 +78,7 @@ export const getUserProfile = protectedProcedure isPublic: userProfile.isPublic, }, hasValidCookies: hasValidCookies.hasValidCookies, + hasPixivCredential: userProfile.providerCredentials.length > 0, + pixivIncludePrivate: userProfile.pixivIncludePrivate, }; }); diff --git a/packages/api/src/routers/search.ts b/packages/api/src/routers/search.ts index f45acf0b..14a90972 100644 --- a/packages/api/src/routers/search.ts +++ b/packages/api/src/routers/search.ts @@ -6,12 +6,18 @@ import { maybeAuthProcedure } from "../middlewares/auth"; import { resolveQueryEmbedding } from "../services/embedding-cache"; import * as EmbeddingsService from "../services/embeddings"; import { runtime } from "../services/runtime"; -import type { SearchResult } from "../types/tweets"; -import { Cursor, SearchCursorPayloadSchema } from "../utils/cursor"; -import type { SearchCursorPayload } from "../utils/cursor"; +import type { SearchResult } from "../types/posts"; +import { Cursor, SearchCursorPayloadSchema, type SearchCursorPayload } from "../utils/cursor"; import { paginateSearchResults } from "../utils/search-pagination"; import { transformSearchResults } from "../utils/transformations"; +const galleryDedupePartitionSql = "user_id, dedupe_key"; +const galleryRepresentativeOrderSql = + "final_score DESC NULLS LAST, provider DESC, media_id DESC, user_id DESC, post_created_at DESC, post_id DESC"; + +const galleryDedupeKeySql = (mediaAlias: string) => + `COALESCE(NULLIF(${mediaAlias}.perceptual_hash, ''), jsonb_build_array(${mediaAlias}.provider, ${mediaAlias}.external_id, ${mediaAlias}.user_id)::text)`; + export const searchImages = maybeAuthProcedure .input( z.object({ @@ -30,6 +36,7 @@ export const searchImages = maybeAuthProcedure const query = input.query.trim(); const { cursor, limit, ownOnly } = input; + // If ownOnly is true, require authentication if (ownOnly && !user) { throw new ORPCError("UNAUTHORIZED", { message: "Authentication required for personal search", @@ -37,6 +44,7 @@ export const searchImages = maybeAuthProcedure }); } + // Get database user ID if searching the authenticated user's posts let databaseUserId: string | null = null; if (ownOnly && user) { const dbUser = await prisma.user.findUnique({ @@ -86,6 +94,7 @@ export const searchImages = maybeAuthProcedure const candidateLimit = Math.max(limit * 8, 200); const hasLexicalQuery = queryLower.length > 0; + // Build user filter based on ownOnly flag const userFilter = ownOnly && databaseUserId ? Prisma.sql`p.user_id = ${databaseUserId}` @@ -94,6 +103,7 @@ export const searchImages = maybeAuthProcedure const baseFilter = Prisma.sql` p.deleted_at IS NULL AND p.s3_path IS NOT NULL + AND p.kind = 'image' AND p.classification IS NOT NULL AND p.image_vec IS NOT NULL AND p.tag_vec IS NOT NULL @@ -118,13 +128,16 @@ export const searchImages = maybeAuthProcedure OR lower(general_tag.value) LIKE ${queryStartsWith} OR lower(general_tag.value) LIKE ${queryContains} ) - OR lower(COALESCE(t.tweet_text, '')) LIKE ${queryContains} + OR lower(COALESCE(t.text, '')) LIKE ${queryContains} + OR lower(COALESCE(t.title, '')) LIKE ${queryContains} + OR lower(COALESCE(t.author_name, '')) LIKE ${queryContains} + OR lower(COALESCE(t.author_username, '')) LIKE ${queryContains} OR EXISTS ( SELECT 1 - FROM jsonb_array_elements_text(COALESCE(t.tweet_data->'hashtags', '[]'::jsonb)) AS hashtag(value) - WHERE lower(hashtag.value) = ${queryLower} - OR lower(hashtag.value) LIKE ${queryStartsWith} - OR lower(hashtag.value) LIKE ${queryContains} + FROM unnest(t.tags) AS post_tag(value) + WHERE lower(post_tag.value) = ${queryLower} + OR lower(post_tag.value) LIKE ${queryStartsWith} + OR lower(post_tag.value) LIKE ${queryContains} ) ) ` @@ -133,55 +146,81 @@ export const searchImages = maybeAuthProcedure const paginationClause = cursorData ? Prisma.sql`WHERE ( final_score < ${cursorData.lastScore} - OR (final_score = ${cursorData.lastScore} AND tweet_id < ${cursorData.lastTweetId}) + OR (final_score = ${cursorData.lastScore} AND post_provider < ${cursorData.lastProvider}) + OR (final_score = ${cursorData.lastScore} AND post_provider = ${cursorData.lastProvider} AND post_id < ${cursorData.lastPostId}) + OR (final_score = ${cursorData.lastScore} AND post_provider = ${cursorData.lastProvider} AND post_id = ${cursorData.lastPostId} AND user_id < ${cursorData.lastUserId}) )` : Prisma.empty; const images = await prisma.$queryRaw(Prisma.sql` WITH image_candidates AS ( - SELECT p.id, p.user_id - FROM photos p + SELECT p.external_id AS id, p.user_id, p.provider + FROM media p WHERE ${baseFilter} - ORDER BY p.image_vec <=> ${textQuery}::vector + ORDER BY p.image_vec <=> ${textQuery}::vector, p.provider DESC, p.external_id DESC, p.user_id DESC LIMIT ${candidateLimit} ), tag_candidates AS ( - SELECT p.id, p.user_id - FROM photos p + SELECT p.external_id AS id, p.user_id, p.provider + FROM media p WHERE ${baseFilter} - ORDER BY p.tag_vec <=> ${textQuery}::vector + ORDER BY p.tag_vec <=> ${textQuery}::vector, p.provider DESC, p.external_id DESC, p.user_id DESC LIMIT ${candidateLimit} ), lexical_candidates AS ( - SELECT p.id, p.user_id - FROM photos p - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + SELECT p.external_id AS id, p.user_id, p.provider + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider + CROSS JOIN LATERAL ( + SELECT COALESCE(MAX( + CASE + WHEN lower(lexical_value.value) = ${queryLower} THEN 3 + WHEN lower(lexical_value.value) LIKE ${queryStartsWith} THEN 2 + WHEN lower(lexical_value.value) LIKE ${queryContains} THEN 1 + ELSE 0 + END + ), 0) AS lexical_score + FROM jsonb_array_elements_text( + COALESCE(p.classification->'characters', '[]'::jsonb) + || COALESCE(p.classification->'tags', '[]'::jsonb) + || to_jsonb(COALESCE(t.tags, ARRAY[]::text[])) + || jsonb_build_array( + COALESCE(t.text, ''), COALESCE(t.title, ''), + COALESCE(t.author_name, ''), COALESCE(t.author_username, '') + ) + ) AS lexical_value(value) + ) lexical_rank WHERE ${baseFilter} AND ${lexicalMatch} + ORDER BY lexical_rank.lexical_score DESC, p.provider DESC, p.external_id DESC, p.user_id DESC LIMIT ${candidateLimit} ), candidate_pool AS ( - SELECT DISTINCT id, user_id + SELECT DISTINCT id, user_id, provider FROM ( - SELECT id, user_id FROM image_candidates + SELECT id, user_id, provider FROM image_candidates UNION ALL - SELECT id, user_id FROM tag_candidates + SELECT id, user_id, provider FROM tag_candidates UNION ALL - SELECT id, user_id FROM lexical_candidates + SELECT id, user_id, provider FROM lexical_candidates ) candidates ), - scored AS ( + scored AS ( SELECT - p.id AS photo_id, + p.external_id AS media_id, + p.provider, p.user_id, - COALESCE(NULLIF(p.perceptual_hash, ''), p.id) AS dedupe_key, + p.kind, + ${Prisma.raw(galleryDedupeKeySql("p"))} AS dedupe_key, p.height, p.width, p.original_url, p.s3_path, - t.username, - t.created_at AS tweet_created_at, - t.id AS tweet_id, + COALESCE(t.author_username, t.username) AS username, + t.created_at AS post_created_at, + t.external_id AS post_id, + t.provider AS post_provider, + t.source_url, COALESCE((p.classification->'nsfw'->>'is_nsfw')::boolean, false) AS is_nsfw, COALESCE(1.0 - (p.image_vec <=> ${textQuery}::vector), 0.0) AS s_image, COALESCE(1.0 - (p.tag_vec <=> ${textQuery}::vector), 0.0) AS s_tag_semantic, @@ -221,113 +260,134 @@ export const searchImages = maybeAuthProcedure ( SELECT MAX( CASE - WHEN lower(hashtag.value) = ${queryLower} THEN 0.76 - WHEN lower(hashtag.value) LIKE ${queryStartsWith} THEN 0.62 - WHEN lower(hashtag.value) LIKE ${queryContains} THEN 0.5 + WHEN lower(post_tag.value) = ${queryLower} THEN 0.76 + WHEN lower(post_tag.value) LIKE ${queryStartsWith} THEN 0.62 + WHEN lower(post_tag.value) LIKE ${queryContains} THEN 0.5 ELSE 0.0 END ) - FROM jsonb_array_elements_text(COALESCE(t.tweet_data->'hashtags', '[]'::jsonb)) AS hashtag(value) + FROM unnest(t.tags) AS post_tag(value) ), 0.0 - ) AS s_hashtag, - CASE WHEN lower(COALESCE(t.tweet_text, '')) LIKE ${queryContains} THEN 0.34 ELSE 0.0 END AS s_tweet_text + ) AS s_post_tag, + GREATEST( + CASE WHEN lower(COALESCE(t.text, '')) LIKE ${queryContains} THEN 0.34 ELSE 0.0 END, + CASE WHEN lower(COALESCE(t.title, '')) LIKE ${queryContains} THEN 0.34 ELSE 0.0 END, + CASE WHEN lower(COALESCE(t.author_name, '')) LIKE ${queryContains} THEN 0.3 ELSE 0.0 END, + CASE WHEN lower(COALESCE(t.author_username, '')) LIKE ${queryContains} THEN 0.3 ELSE 0.0 END + ) AS s_post_text FROM candidate_pool c - JOIN photos p ON p.id = c.id AND p.user_id = c.user_id - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id + JOIN media p ON p.external_id = c.id AND p.user_id = c.user_id AND p.provider = c.provider + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider ), fused AS ( SELECT - photo_id, + media_id, + provider, user_id, + kind, dedupe_key, height, width, original_url, s3_path, username, - tweet_created_at, - tweet_id, + post_created_at, + post_id, + post_provider, + source_url, is_nsfw, ( (s_character * 0.44) + (GREATEST(s_tag_semantic, s_tag_lexical) * 0.28) + - (GREATEST(s_hashtag, s_tweet_text) * 0.12) + + (GREATEST(s_post_tag, s_post_text) * 0.12) + (s_image * 0.1) + LEAST(0.04, GREATEST(0.0, aesthetic * (1.0 - style_real_life) * (0.65 + (style_anime * 0.35))) * 0.04) + - (0.02 * EXP(LN(0.5) * (EXTRACT(EPOCH FROM (${queryTime}::timestamptz - tweet_created_at)) / (180.0 * 24 * 3600.0)))) + (0.02 * EXP(LN(0.5) * (EXTRACT(EPOCH FROM (${queryTime}::timestamptz - post_created_at)) / (180.0 * 24 * 3600.0)))) ) AS final_score FROM scored ), deduped AS ( SELECT - photo_id, + media_id, + provider, user_id, + kind, height, width, original_url, s3_path, username, - tweet_created_at, - tweet_id, + post_created_at, + post_id, + post_provider, + source_url, is_nsfw, final_score, ROW_NUMBER() OVER ( - PARTITION BY dedupe_key - ORDER BY final_score DESC NULLS LAST, tweet_created_at DESC, photo_id DESC, user_id DESC + PARTITION BY ${Prisma.raw(galleryDedupePartitionSql)} + ORDER BY ${Prisma.raw(galleryRepresentativeOrderSql)} ) AS duplicate_rank FROM fused ), post_candidates AS ( SELECT - tweet_id, + post_id, user_id, + post_provider, final_score, ROW_NUMBER() OVER ( - PARTITION BY tweet_id - ORDER BY final_score DESC NULLS LAST, user_id DESC + PARTITION BY post_id, user_id, post_provider + ORDER BY final_score DESC NULLS LAST ) AS post_rank FROM deduped WHERE duplicate_rank = 1 ), ranked_posts AS ( - SELECT tweet_id, user_id, final_score + SELECT post_id, user_id, post_provider, final_score FROM post_candidates WHERE post_rank = 1 ), paged_posts AS ( - SELECT tweet_id, user_id, final_score + SELECT post_id, user_id, post_provider, final_score FROM ranked_posts ${paginationClause} - ORDER BY final_score DESC NULLS LAST, tweet_id DESC + ORDER BY final_score DESC NULLS LAST, post_provider DESC, post_id DESC, user_id DESC LIMIT ${limit + 1} ) SELECT - p.id AS photo_id, + p.external_id AS media_id, + p.provider, + p.user_id, + p.kind, p.height, p.width, p.original_url, p.s3_path, - t.username, - t.created_at AS tweet_created_at, - t.id AS tweet_id, + COALESCE(t.author_username, t.username) AS username, + t.created_at AS post_created_at, + t.external_id AS post_id, + t.provider AS post_provider, + t.source_url, COALESCE((p.classification->'nsfw'->>'is_nsfw')::boolean, false) AS is_nsfw, paged_posts.final_score FROM paged_posts - JOIN tweets t ON t.id = paged_posts.tweet_id AND t.user_id = paged_posts.user_id - JOIN photos p ON p.tweet_id = paged_posts.tweet_id AND p.user_id = paged_posts.user_id + JOIN posts t ON t.external_id = paged_posts.post_id AND t.user_id = paged_posts.user_id AND t.provider = paged_posts.post_provider + JOIN media p ON p.post_external_id = paged_posts.post_id AND p.user_id = paged_posts.user_id AND p.provider = paged_posts.post_provider WHERE p.deleted_at IS NULL AND p.s3_path IS NOT NULL - ORDER BY paged_posts.final_score DESC NULLS LAST, paged_posts.tweet_id DESC, p.created_at DESC, p.id DESC + ORDER BY paged_posts.final_score DESC NULLS LAST, paged_posts.post_provider DESC, paged_posts.post_id DESC, paged_posts.user_id DESC, p.position, p.created_at DESC, p.external_id DESC `); const page = paginateSearchResults(images, limit); - const transformedResults = transformSearchResults(page.rows); + const transformedResults = transformSearchResults(page.rows, env.BASE_CDN_URL); let nextCursor: string | null = null; if (page.hasNextPage && page.lastPost) { nextCursor = Cursor.create({ lastScore: page.lastPost.final_score, - lastTweetId: page.lastPost.tweet_id, + lastProvider: page.lastPost.post_provider, + lastPostId: page.lastPost.post_id, + lastUserId: page.lastPost.user_id, queryTime, }); } @@ -339,63 +399,108 @@ export const searchImages = maybeAuthProcedure }); export const randomImages = publicProcedure.handler(async () => { - // Rank on narrow tuples so window sorts stay in memory; display columns are - // joined back only for the surviving top500. Decay math must stay float8 — - // EXTRACT() yields numeric and EXP/LN on numeric is ~100ms slower at this scale. const images = await prisma.$queryRaw` - WITH core AS ( - SELECT - p.id AS photo_id, + WITH base AS ( + SELECT + p.external_id AS id, p.user_id, - t.id AS tweet_id, - t.created_at AS ts, - (p.classification->>'aesthetic')::float AS aesthetic, - (p.classification->'style'->>'anime')::float AS style_anime, - (p.classification->'style'->>'real_life')::float AS style_real_life, - (p.classification->'style'->>'other')::float AS style_other - FROM photos p - JOIN tweets t ON t.id = p.tweet_id AND t.user_id = p.user_id - WHERE p.deleted_at IS NULL - AND p.classification IS NOT NULL + p.provider, + p.kind, + ${Prisma.raw(galleryDedupeKeySql("p"))} AS dedupe_key, + p.height, + p.width, + p.s3_path, + p.original_url, + COALESCE(t.author_username, t.username) AS username, + t.created_at as post_created_at, + t.external_id as post_id, + t.provider as post_provider, + t.source_url, + (p.classification->>'aesthetic')::float AS aesthetic, + (p.classification->'style'->>'anime')::float AS style_anime, + (p.classification->'style'->>'real_life')::float AS style_real_life, + (p.classification->'style'->>'other')::float AS style_other, + (p.classification->'nsfw'->>'is_nsfw')::boolean AS is_nsfw + FROM media p + JOIN posts t ON t.external_id = p.post_external_id AND t.user_id = p.user_id AND t.provider = p.provider + WHERE p.classification IS NOT NULL + AND p.deleted_at IS NULL + AND p.kind = 'image' AND p.user_id IN (SELECT id FROM users WHERE is_public = true) AND NOT (p.classification->'nsfw'->>'is_nsfw')::boolean - ), - scored AS ( - SELECT *, aesthetic * style_anime * (1.0 - style_real_life) * (1.0 - style_other) AS effective - FROM core - ), - ranked AS ( - SELECT *, - ROW_NUMBER() OVER (ORDER BY effective DESC) AS rank_style, - ROW_NUMBER() OVER (ORDER BY ts DESC) AS rank_recency - FROM scored - ), - fused AS ( - SELECT photo_id, user_id, tweet_id, ts, - ((1.0 / (rank_style + 60) * 0.9) + (1.0 / (rank_recency + 60) * 0.1)) * effective * - EXP(LN(0.5::float8) * (EXTRACT(EPOCH FROM (NOW() - ts))::float8 / 2592000.0)) AS final_score - FROM ranked - ), - top500 AS ( - SELECT * FROM fused ORDER BY final_score DESC LIMIT 500 - ) - SELECT - z.photo_id, - p.height, - p.width, - p.s3_path, - p.original_url, - t.username, - t.created_at AS tweet_created_at, - z.tweet_id, - COALESCE((p.classification->'nsfw'->>'is_nsfw')::boolean, false) AS is_nsfw, - z.final_score - FROM top500 z - JOIN tweets t ON t.id = z.tweet_id AND t.user_id = z.user_id - JOIN photos p ON p.id = z.photo_id AND p.user_id = z.user_id - ORDER BY RANDOM() - LIMIT 30 + ), + ranked AS ( + SELECT *, + aesthetic * style_anime * + (1.0 - style_real_life) * + (1.0 - style_other) AS effective, + ROW_NUMBER() OVER ( + ORDER BY + aesthetic * style_anime * + (1.0 - style_real_life) * + (1.0 - style_other) DESC, + provider DESC, id DESC, user_id DESC + ) AS rank_style, + ROW_NUMBER() OVER (ORDER BY post_created_at DESC, provider DESC, id DESC, user_id DESC) AS rank_recency + FROM base + ), + fused AS ( + SELECT + id as media_id, + user_id, + provider, + kind, + dedupe_key, + height, + width, + s3_path, + original_url, + username, + post_created_at, + post_id, + post_provider, + source_url, + is_nsfw, + ( + (1.0 / (rank_style + 60) * 0.9) + + (1.0 / (rank_recency + 60) * 0.1) + ) * effective * + EXP(LN(0.5) * (EXTRACT(EPOCH FROM (NOW() - post_created_at)) / (30.0 * 24 * 3600.0))) AS final_score + FROM ranked + ), + deduped AS ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY ${Prisma.raw(galleryDedupePartitionSql)} + ORDER BY ${Prisma.raw(galleryRepresentativeOrderSql)} + ) AS duplicate_rank + FROM fused + ), + top500 AS ( + SELECT * FROM deduped + WHERE duplicate_rank = 1 + ORDER BY ${Prisma.raw(galleryRepresentativeOrderSql)} + LIMIT 500 + ) + SELECT + media_id, + user_id, + provider, + kind, + original_url, + s3_path, + username, + height, + width, + post_created_at, + post_id, + post_provider, + source_url, + is_nsfw, + final_score + FROM top500 + ORDER BY RANDOM() + LIMIT 30; `; - return transformSearchResults(images); + return transformSearchResults(images, env.BASE_CDN_URL); }); diff --git a/packages/api/src/services/pixiv-credential-core.ts b/packages/api/src/services/pixiv-credential-core.ts new file mode 100644 index 00000000..013b33ff --- /dev/null +++ b/packages/api/src/services/pixiv-credential-core.ts @@ -0,0 +1,54 @@ +export const createPixivCredentialService = + (dependencies: { + connect: (token: string) => Promise; + decryptLegacy: (secret: string, userId: string) => string; + decryptScoped: (secret: string, userId: string) => string; + encrypt: (token: string, userId: string) => string; + find: (userId: string) => Promise<{ + credentialType: string; + encryptedSecret: string; + } | null>; + updateMatching: ( + userId: string, + encryptedSecret: string, + replacement: string, + ) => Promise<{ count: number }>; + withLock: (userId: string, operation: () => Promise) => Promise; + }) => + async (userId: string, operation: (client: Client) => Promise) => { + const client = await dependencies.withLock(userId, async () => { + const credential = await dependencies.find(userId); + if (!credential || credential.credentialType !== "refresh_token") { + return; + } + + let token: string; + let migrated = false; + try { + token = dependencies.decryptScoped(credential.encryptedSecret, userId); + } catch { + token = dependencies.decryptLegacy(credential.encryptedSecret, userId); + migrated = true; + } + + const client = await dependencies.connect(token); + if (migrated || client.refreshToken !== token) { + const updated = await dependencies.updateMatching( + userId, + credential.encryptedSecret, + dependencies.encrypt(client.refreshToken, userId), + ); + if (updated.count === 0) { + throw new Error("Pixiv credential changed during token rotation"); + } + } + + return client; + }); + + if (!client) { + return; + } + + return operation(client); + }; diff --git a/packages/api/src/services/pixiv-credential.ts b/packages/api/src/services/pixiv-credential.ts new file mode 100644 index 00000000..ed086da5 --- /dev/null +++ b/packages/api/src/services/pixiv-credential.ts @@ -0,0 +1,42 @@ +import { CookieEncryption } from "@starlight/crypto"; +import { env, prisma } from "@starlight/utils"; +import { PixivAdapter } from "./pixiv"; +import { createPixivCredentialService } from "./pixiv-credential-core"; + +const PURPOSE = "provider:pixiv:refresh-token"; +const encryption = new CookieEncryption(env.COOKIE_ENCRYPTION_KEY, env.COOKIE_ENCRYPTION_SALT); + +export const encryptPixivToken = (token: string, userId: string) => + encryption.encryptScoped(token, userId, PURPOSE); + +export const withPixivLock = async (userId: string, operation: () => Promise) => { + return prisma.$transaction( + async (transaction) => { + await transaction.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`pixiv:${userId}`}, 0))`; + return operation(); + }, + { timeout: 120_000 }, + ); +}; + +export const withPixivClient = createPixivCredentialService({ + withLock: withPixivLock, + find: (userId) => + prisma.providerCredential.findUnique({ + where: { userId_provider: { userId, provider: "pixiv" } }, + }), + decryptScoped: (secret, userId) => encryption.decryptScoped(secret, userId, PURPOSE), + decryptLegacy: (secret, userId) => encryption.decrypt(secret, userId), + connect: PixivAdapter.connect, + encrypt: encryptPixivToken, + updateMatching: (userId, encryptedSecret, replacement) => + prisma.providerCredential.updateMany({ + where: { + userId, + provider: "pixiv", + credentialType: "refresh_token", + encryptedSecret, + }, + data: { encryptedSecret: replacement }, + }), +}); diff --git a/packages/api/src/services/pixiv.ts b/packages/api/src/services/pixiv.ts new file mode 100644 index 00000000..f78a756f --- /dev/null +++ b/packages/api/src/services/pixiv.ts @@ -0,0 +1,122 @@ +import { + BookmarkRestrict, + PixivClient, + type PixivIllustItem, + parseNextUrl, +} from "@book000/pixivts"; + +export type PixivArtwork = { + id: string; + title: string; + caption: string; + type: "illust" | "manga" | "ugoira"; + sourceUrl: string; + author: { id: string; name: string; username: string }; + tags: string[]; + mediaUrls: string[]; +}; + +export type PixivBookmarkPage = { + artworks: PixivArtwork[]; + nextCursor?: number; +}; + +export const normalizePixivTags = (tags: PixivIllustItem["tags"]): string[] => { + const normalized: string[] = []; + const seen = new Set(); + for (const tag of tags) { + const value = tag.name.trim(); + if (value && !seen.has(value)) { + seen.add(value); + normalized.push(value); + } + } + return normalized; +}; + +const mapArtwork = (illust: PixivIllustItem): PixivArtwork => ({ + id: String(illust.id), + title: illust.title, + caption: illust.caption, + type: illust.type, + sourceUrl: `https://www.pixiv.net/artworks/${illust.id}`, + author: { + id: String(illust.user.id), + name: illust.user.name, + username: illust.user.account, + }, + tags: normalizePixivTags(illust.tags), + mediaUrls: + illust.metaPages.length > 0 + ? illust.metaPages.map((page) => page.imageUrls.original) + : [illust.metaSinglePage.originalImageUrl].filter( + (url): url is string => typeof url === "string", + ), +}); + +export class PixivAdapter { + readonly #client: PixivClient; + + private constructor(client: PixivClient) { + this.#client = client; + } + + static async connect(refreshToken: string) { + const client = await PixivClient.of(refreshToken); + return new PixivAdapter(client); + } + + get externalUserId() { + return String(this.#client.userId); + } + + get refreshToken() { + return this.#client.getRefreshToken(); + } + + async bookmarks(options: { + cursor?: number; + visibility: "public" | "private"; + }): Promise { + const result = await this.#client.users.bookmarks.illusts({ + userId: this.#client.userId, + restrict: + options.visibility === "private" ? BookmarkRestrict.PRIVATE : BookmarkRestrict.PUBLIC, + maxBookmarkId: options.cursor, + }); + if (result.isErr) { + throw new Error(`Pixiv bookmark request failed: ${result.error.type}`); + } + const next = result.value.nextUrl + ? parseNextUrl(result.value.nextUrl).maxBookmarkId + : undefined; + return { + artworks: result.value.illusts.map(mapArtwork), + nextCursor: typeof next === "number" ? next : undefined, + }; + } + + async artwork(id: string) { + const result = await this.#client.illusts.detail({ illustId: Number(id) }); + if (result.isErr) { + throw new Error(`Pixiv artwork request failed: ${result.error.type}`); + } + return mapArtwork(result.value.illust); + } + + async ugoira(id: string) { + const result = await this.#client.ugoira.metadata({ illustId: Number(id) }); + if (result.isErr) { + throw new Error(`Pixiv ugoira request failed: ${result.error.type}`); + } + return result.value.ugoiraMetadata; + } + + async fetchMedia(url: string) { + const result = await this.#client.images.fetch(url); + if (result.isErr) { + throw new Error(`Pixiv media request failed: ${result.error.type}`); + } + return result.value; + } +} diff --git a/packages/api/src/services/twitter-cookies.ts b/packages/api/src/services/twitter-cookies.ts new file mode 100644 index 00000000..1e911277 --- /dev/null +++ b/packages/api/src/services/twitter-cookies.ts @@ -0,0 +1,105 @@ +const FIREFOX_HOST_REGEX = /https?:\/\/(.+?)\//; +const TWID_REGEX = /^u=(\d+)$/; + +export interface TwitterCookie { + domain: string; + key: string; + value: string; +} + +const isTwitterCookie = (value: unknown): value is TwitterCookie => { + if (!value || typeof value !== "object") { + return false; + } + + const cookie = value as Record; + return ( + typeof cookie.domain === "string" && + typeof cookie.key === "string" && + cookie.key.length > 0 && + typeof cookie.value === "string" + ); +}; + +const isFirefoxCookie = ( + value: unknown, +): value is { "Content raw": string; "Host raw": string; "Name raw": string } => { + if (!value || typeof value !== "object") { + return false; + } + + const cookie = value as Record; + return ( + typeof cookie["Host raw"] === "string" && + typeof cookie["Name raw"] === "string" && + cookie["Name raw"].length > 0 && + typeof cookie["Content raw"] === "string" + ); +}; + +const normalizeCookie = (value: unknown): TwitterCookie | undefined => { + if (isTwitterCookie(value)) { + return value; + } + if (!isFirefoxCookie(value)) { + return; + } + + const domain = value["Host raw"].match(FIREFOX_HOST_REGEX)?.[1]; + if (!domain) { + return; + } + + return { + domain, + key: value["Name raw"], + value: value["Content raw"], + }; +}; + +const isTwitterDomain = (domain: string): boolean => { + const normalizedDomain = domain.toLowerCase().replace(/^\./, ""); + return ( + normalizedDomain === "x.com" || + normalizedDomain.endsWith(".x.com") || + normalizedDomain === "twitter.com" || + normalizedDomain.endsWith(".twitter.com") + ); +}; + +export const getTwitterUserId = (cookies: TwitterCookie[]): string | undefined => { + const twid = cookies.find((cookie) => cookie.key === "twid"); + if (!(twid && isTwitterDomain(twid.domain))) { + return; + } + + try { + return decodeURIComponent(twid.value).match(TWID_REGEX)?.[1]; + } catch { + return; + } +}; + +export const parseTwitterCookies = (data: string): TwitterCookie[] => { + const parsed: unknown = JSON.parse(data); + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error("Invalid Twitter cookies"); + } + const normalizedCookies: TwitterCookie[] = []; + for (const value of parsed) { + const cookie = normalizeCookie(value); + if (!cookie) { + throw new Error("Invalid Twitter cookies"); + } + normalizedCookies.push(cookie); + } + + if (!getTwitterUserId(normalizedCookies)) { + throw new Error("Twitter cookies do not contain a valid twid"); + } + + return normalizedCookies; +}; + +export const normalizeTwitterCookies = (data: string): string => + JSON.stringify(parseTwitterCookies(data)); diff --git a/packages/api/src/services/twitter-credential-core.ts b/packages/api/src/services/twitter-credential-core.ts new file mode 100644 index 00000000..cad621e4 --- /dev/null +++ b/packages/api/src/services/twitter-credential-core.ts @@ -0,0 +1,76 @@ +import { normalizeTwitterCookies } from "./twitter-cookies"; + +interface TwitterCredential { + credentialType: string; + encryptedSecret: string; + provider: string; + user: { telegramId: bigint }; +} + +export const createTwitterCredentialService = (dependencies: { + decrypt: ( + secret: string, + userId: string, + telegramId: string, + ) => { + data: string; + usedLegacyEncryption: boolean; + }; + deleteMatching: (userId: string, encryptedSecret: string) => Promise<{ count: number }>; + encrypt: (cookies: string, userId: string) => string; + find: (userId: string) => Promise; + updateMatching: ( + userId: string, + encryptedSecret: string, + replacement: string, + ) => Promise<{ count: number }>; +}) => { + const read = async (userId: string, retryOnCasLoss: boolean): Promise => { + const credential = await dependencies.find(userId); + if ( + !credential || + credential.provider !== "twitter" || + credential.credentialType !== "cookies" + ) { + return; + } + const originalEncryptedSecret = credential.encryptedSecret; + + let decrypted: { data: string; usedLegacyEncryption: boolean }; + let normalizedCookies: string; + try { + decrypted = dependencies.decrypt( + originalEncryptedSecret, + userId, + credential.user.telegramId.toString(), + ); + normalizedCookies = normalizeTwitterCookies(decrypted.data); + } catch { + const deleted = await dependencies.deleteMatching(userId, originalEncryptedSecret); + if (deleted.count === 0 && retryOnCasLoss) { + return read(userId, false); + } + return; + } + + if (decrypted.usedLegacyEncryption) { + const updated = await dependencies.updateMatching( + userId, + originalEncryptedSecret, + dependencies.encrypt(normalizedCookies, userId), + ); + if (updated.count === 0 && retryOnCasLoss) { + return read(userId, false); + } + if (updated.count === 0) { + return; + } + } + + return normalizedCookies; + }; + + return { + get: (userId: string) => read(userId, true), + }; +}; diff --git a/packages/api/src/services/twitter-credential.ts b/packages/api/src/services/twitter-credential.ts new file mode 100644 index 00000000..dd6a631e --- /dev/null +++ b/packages/api/src/services/twitter-credential.ts @@ -0,0 +1,41 @@ +import { CookieEncryption } from "@starlight/crypto"; +import { env, prisma } from "@starlight/utils"; +import { createTwitterCredentialService } from "./twitter-credential-core"; + +export { createTwitterCredentialService } from "./twitter-credential-core"; + +const TWITTER_COOKIES_PURPOSE = "provider:twitter:cookies:v1"; +const encryption = new CookieEncryption(env.COOKIE_ENCRYPTION_KEY, env.COOKIE_ENCRYPTION_SALT); + +const twitterCredentials = createTwitterCredentialService({ + find: (userId) => + prisma.providerCredential.findUnique({ + where: { userId_provider: { userId, provider: "twitter" } }, + select: { + credentialType: true, + encryptedSecret: true, + provider: true, + user: { select: { telegramId: true } }, + }, + }), + decrypt: (secret, userId, telegramId) => + encryption.decryptScopedOrLegacy(secret, userId, TWITTER_COOKIES_PURPOSE, telegramId), + encrypt: (cookies, userId) => encryption.encryptScoped(cookies, userId, TWITTER_COOKIES_PURPOSE), + updateMatching: (userId, encryptedSecret, replacement) => + prisma.providerCredential.updateMany({ + where: { userId, provider: "twitter", credentialType: "cookies", encryptedSecret }, + data: { encryptedSecret: replacement }, + }), + deleteMatching: (userId, encryptedSecret) => + prisma.providerCredential.deleteMany({ + where: { userId, provider: "twitter", credentialType: "cookies", encryptedSecret }, + }), +}); + +export const encryptTwitterCookies = (cookies: string, userId: string) => + encryption.encryptScoped(cookies, userId, TWITTER_COOKIES_PURPOSE); + +export const getTwitterCookies = (userId: string) => twitterCredentials.get(userId); + +export const hasTwitterCookies = async (userId: string) => + Boolean(await twitterCredentials.get(userId)); diff --git a/packages/api/src/types/posts.ts b/packages/api/src/types/posts.ts new file mode 100644 index 00000000..131d8ea5 --- /dev/null +++ b/packages/api/src/types/posts.ts @@ -0,0 +1,50 @@ +export type SearchResult = { + media_id: string; + provider: string; + user_id: string; + kind: string; + original_url: string; + s3_path: string; + username: string; + post_id: string; + post_provider: string; + source_url: string; + post_created_at: Date; + is_nsfw: boolean; + height: number; + width: number; + final_score: number; +}; + +export type MediaData = { + id: string; + externalId: string; + provider: string; + kind: string; + url: string; + is_nsfw?: boolean; + height?: number; + width?: number; + alt: string; +}; + +export type PostData = { + id: string; + externalId: string; + provider: string; + artist: string; + date: string; + media: MediaData[]; + hasMultipleMedia: boolean; + sourceUrl: string; +}; + +export type PostsPageResult = { + posts: PostData[]; + nextCursor: string | null; +}; + +export type SearchPageResult = { + results: PostData[]; + nextCursor: string | null; +}; diff --git a/packages/api/src/types/tweets.ts b/packages/api/src/types/tweets.ts deleted file mode 100644 index 14cecaac..00000000 --- a/packages/api/src/types/tweets.ts +++ /dev/null @@ -1,40 +0,0 @@ -export interface SearchResult { - photo_id: string; - original_url: string; - s3_path: string; - username: string; - tweet_id: string; - tweet_created_at: Date; - is_nsfw: boolean; - height: number; - width: number; - final_score: number; -} - -export interface PhotoData { - id: string; - url: string; - is_nsfw?: boolean; - height?: number; - width?: number; - alt: string; -} - -export interface TweetData { - id: string; - artist: string; - date: string; - photos: PhotoData[]; - hasMultipleImages: boolean; - sourceUrl?: string; -} - -export interface TweetsPageResult { - tweets: TweetData[]; - nextCursor: string | null; -} - -export interface SearchPageResult { - results: TweetData[]; - nextCursor: string | null; -} diff --git a/packages/api/src/utils/cursor.ts b/packages/api/src/utils/cursor.ts index bfb2bb55..ef218049 100644 --- a/packages/api/src/utils/cursor.ts +++ b/packages/api/src/utils/cursor.ts @@ -1,20 +1,67 @@ import { z } from "zod"; export const CursorPayloadSchema = z.object({ + lastPostId: z.string().min(1), + provider: z.string().optional(), createdAt: z.iso.datetime(), - lastTweetId: z.string().min(1), }); export type CursorPayload = z.infer; export const SearchCursorPayloadSchema = z.object({ lastScore: z.number().finite(), - lastTweetId: z.string().min(1), + lastProvider: z.string().trim().min(1), + lastPostId: z.string().trim().min(1), + lastUserId: z.string().trim().min(1), queryTime: z.iso.datetime(), }); export type SearchCursorPayload = z.infer; +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +const isValidQueryTime = (value: unknown): value is string => { + if (typeof value !== "string" || !ISO_TIMESTAMP.test(value)) { + return false; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value; +}; + +export const isSearchCursorPayload = (value: unknown): value is SearchCursorPayload => { + if (!value || typeof value !== "object") { + return false; + } + const payload = value as Record; + return ( + typeof payload.lastScore === "number" && + Number.isFinite(payload.lastScore) && + typeof payload.lastProvider === "string" && + payload.lastProvider.trim().length > 0 && + typeof payload.lastPostId === "string" && + payload.lastPostId.trim().length > 0 && + typeof payload.lastUserId === "string" && + payload.lastUserId.trim().length > 0 && + isValidQueryTime(payload.queryTime) + ); +}; + +export const isAfterSearchCursor = ( + item: { finalScore: number; provider: string; postId: string; userId: string }, + cursor: SearchCursorPayload, +): boolean => { + if (item.finalScore !== cursor.lastScore) { + return item.finalScore < cursor.lastScore; + } + if (item.provider !== cursor.lastProvider) { + return item.provider < cursor.lastProvider; + } + if (item.postId !== cursor.lastPostId) { + return item.postId < cursor.lastPostId; + } + return item.userId < cursor.lastUserId; +}; + export const Cursor = { create(data: T): string { return Buffer.from(JSON.stringify(data)).toString("base64url"); diff --git a/packages/api/src/utils/public-id.ts b/packages/api/src/utils/public-id.ts new file mode 100644 index 00000000..bdf8a847 --- /dev/null +++ b/packages/api/src/utils/public-id.ts @@ -0,0 +1,39 @@ +const PUBLIC_ID_PREFIX = "~"; +const LEGACY_TWITTER_MEDIA_ID = /^\d+$/; + +export const createPublicId = ( + kind: "post" | "media", + provider: string, + externalId: string, + userId: string, +): string => + `${PUBLIC_ID_PREFIX}${Buffer.from(JSON.stringify(["v1", kind, provider, externalId, userId])).toString("base64url")}`; + +export const parseMediaPublicId = ( + id: string, +): { provider: string; externalId: string; userId?: string } | null => { + if (!id.startsWith(PUBLIC_ID_PREFIX)) { + return LEGACY_TWITTER_MEDIA_ID.test(id) ? { provider: "twitter", externalId: id } : null; + } + + try { + const encoded = id.slice(PUBLIC_ID_PREFIX.length); + const bytes = Buffer.from(encoded, "base64url"); + const decoded: unknown = JSON.parse(bytes.toString()); + if ( + bytes.toString("base64url") !== encoded || + !Array.isArray(decoded) || + decoded.length !== 5 || + decoded[0] !== "v1" || + decoded[1] !== "media" || + decoded + .slice(2) + .some((value) => typeof value !== "string" || value.trim() !== value || value.length === 0) + ) { + return null; + } + return { provider: decoded[2], externalId: decoded[3], userId: decoded[4] }; + } catch { + return null; + } +}; diff --git a/packages/api/src/utils/search-pagination.ts b/packages/api/src/utils/search-pagination.ts index 356b4c7d..ff0abf3d 100644 --- a/packages/api/src/utils/search-pagination.ts +++ b/packages/api/src/utils/search-pagination.ts @@ -1,24 +1,26 @@ -import type { SearchResult } from "../types/tweets"; +import type { SearchResult } from "../types/posts"; export const paginateSearchResults = (results: SearchResult[], limit: number) => { const selectedPosts = new Set(); const rows: SearchResult[] = []; let hasNextPage = false; - let lastPost: Pick | undefined; + let lastPost: + | Pick + | undefined; for (const result of results) { - const isDuplicate = selectedPosts.has(result.tweet_id); - const hasReachedLimit = selectedPosts.size >= limit; - - if (isDuplicate) { + const key = `${result.post_provider}:${result.post_id}:${result.user_id}`; + if (selectedPosts.has(key)) { rows.push(result); - } else if (hasReachedLimit) { + continue; + } + if (selectedPosts.size >= limit) { hasNextPage = true; - } else { - selectedPosts.add(result.tweet_id); - rows.push(result); - lastPost = result; + continue; } + selectedPosts.add(key); + rows.push(result); + lastPost = result; } return { hasNextPage, lastPost, rows }; diff --git a/packages/api/src/utils/search-transformations.ts b/packages/api/src/utils/search-transformations.ts new file mode 100644 index 00000000..f13a3435 --- /dev/null +++ b/packages/api/src/utils/search-transformations.ts @@ -0,0 +1,79 @@ +import { format } from "date-fns"; +import type { PostData, SearchResult } from "../types/posts"; +import { createPublicId } from "./public-id"; + +export const transformSearchResultsPure = ( + results: SearchResult[], + baseCdnUrl: string, +): PostData[] => { + const grouped = new Map< + string, + { + id: string; + provider: string; + userId: string; + username: string; + sourceUrl: string; + createdAt: Date; + media: Array<{ + id: string; + kind: string; + provider: string; + originalUrl: string; + s3Url?: string; + isNsfw?: boolean; + height?: number; + width?: number; + }>; + } + >(); + for (const result of results) { + const key = JSON.stringify([result.post_provider, result.post_id, result.user_id]); + let post = grouped.get(key); + if (!post) { + post = { + id: result.post_id, + provider: result.post_provider, + userId: result.user_id, + username: result.username, + sourceUrl: result.source_url, + createdAt: result.post_created_at, + media: [], + }; + grouped.set(key, post); + } + post.media.push({ + id: result.media_id, + kind: result.kind, + provider: result.provider, + originalUrl: result.original_url, + s3Url: result.s3_path ? `${baseCdnUrl}/${result.s3_path}` : undefined, + isNsfw: result.is_nsfw, + height: result.height, + width: result.width, + }); + } + return Array.from(grouped.values(), (post) => { + const media = post.media.map((item) => ({ + id: createPublicId("media", item.provider, item.id, post.userId), + externalId: item.id, + kind: item.kind, + provider: item.provider, + url: item.s3Url ?? item.originalUrl, + is_nsfw: item.isNsfw, + height: item.height, + width: item.width, + alt: `${post.username}-${item.id}.${item.originalUrl.split(".").pop() ?? "jpg"}`, + })); + return { + id: createPublicId("post", post.provider, post.id, post.userId), + externalId: post.id, + provider: post.provider, + artist: post.username ? `@${post.username}` : "@good_artist", + date: format(post.createdAt, "MMM d, yyyy"), + media, + hasMultipleMedia: media.length > 1, + sourceUrl: post.sourceUrl, + }; + }); +}; diff --git a/packages/api/src/utils/transformations.ts b/packages/api/src/utils/transformations.ts index e5cc3a85..97408259 100644 --- a/packages/api/src/utils/transformations.ts +++ b/packages/api/src/utils/transformations.ts @@ -1,92 +1,53 @@ -import { env } from "@starlight/utils"; -import type { Photo, Tweet } from "@starlight/utils"; +import type { Media, Post } from "@starlight/utils"; import { format } from "date-fns"; -import type { SearchResult, TweetData } from "../types/tweets"; - -function transformTweetsBase>( - tweets: T[], - getPhotos: (tweet: T) => (Pick & { - s3Url?: string; - is_nsfw?: boolean; - height?: number | null; - width?: number | null; - })[], -): TweetData[] { - return tweets.map((tweet) => { - const photos = getPhotos(tweet).map((photo) => { - const extension = photo.originalUrl.split(".").pop() ?? "jpg"; - - return { - id: photo.id, - url: photo.s3Url || photo.originalUrl, - is_nsfw: photo.is_nsfw, - height: photo.height ?? undefined, - width: photo.width ?? undefined, - alt: `${tweet.username}-${photo.id}.${extension}`, - }; - }); +import type { PostData, SearchResult } from "../types/posts"; +import { createPublicId } from "./public-id"; +import { transformSearchResultsPure } from "./search-transformations"; + +type TransformMedia = Pick & { + s3Url?: string; + is_nsfw?: boolean; + height?: number | null; + width?: number | null; +}; +type TransformPost = Pick< + Post, + "authorUsername" | "createdAt" | "id" | "provider" | "sourceUrl" | "userId" | "username" +>; + +const transformPostsBase = ( + posts: T[], + getMedia: (post: T) => TransformMedia[], +): PostData[] => + posts.map((post) => { + const artist = post.authorUsername ?? post.username; + const media = getMedia(post).map((item) => ({ + id: createPublicId("media", item.provider, item.id, post.userId), + externalId: item.id, + provider: item.provider, + kind: item.kind, + url: item.s3Url ?? item.originalUrl, + is_nsfw: item.is_nsfw, + height: item.height ?? undefined, + width: item.width ?? undefined, + alt: `${artist ?? "artist"}-${item.id}.${item.originalUrl.split(".").at(-1) ?? "jpg"}`, + })); return { - id: tweet.id, - artist: tweet.username ? `@${tweet.username}` : "@good_artist", - date: format(tweet.createdAt, "MMM d, yyyy"), - photos, - hasMultipleImages: photos.length > 1, - sourceUrl: `https://x.com/i/status/${tweet.id}`, + id: createPublicId("post", post.provider, post.id, post.userId), + externalId: post.id, + provider: post.provider, + artist: artist ? `@${artist}` : "@good_artist", + date: format(post.createdAt, "MMM d, yyyy"), + media, + hasMultipleMedia: media.length > 1, + sourceUrl: post.sourceUrl, }; }); -} - -export const transformTweets = ( - tweets: (Tweet & { - photos: (Photo & { - s3Url: string | undefined; - height?: number | null; - width?: number | null; - })[]; - })[], -) => transformTweetsBase(tweets, (t) => t.photos); -export const transformSearchResults = (results: SearchResult[]): TweetData[] => { - const grouped: Record< - string, - { - id: string; - username: string; - createdAt: Date; - photos: { - id: string; - originalUrl: string; - s3Url?: string; - is_nsfw?: boolean; - height?: number; - width?: number; - }[]; - } - > = {}; +export const transformPosts = ( + posts: Array }>, +) => transformPostsBase(posts, (post) => post.media); - for (const result of results) { - let tweet = grouped[result.tweet_id]; - - if (!tweet) { - tweet = { - id: result.tweet_id, - username: result.username, - createdAt: result.tweet_created_at, - photos: [], - }; - grouped[result.tweet_id] = tweet; - } - - tweet.photos.push({ - id: result.photo_id, - originalUrl: result.original_url, - s3Url: result.s3_path ? `${env.BASE_CDN_URL}/${result.s3_path}` : undefined, - is_nsfw: result.is_nsfw, - height: result.height, - width: result.width, - }); - } - - return transformTweetsBase(Object.values(grouped), (tweet) => tweet.photos); -}; +export const transformSearchResults = (results: SearchResult[], baseCdnUrl: string) => + transformSearchResultsPure(results, baseCdnUrl); diff --git a/packages/api/tests/search-pagination.test.ts b/packages/api/tests/search-pagination.test.ts index 155a2588..db36da44 100644 --- a/packages/api/tests/search-pagination.test.ts +++ b/packages/api/tests/search-pagination.test.ts @@ -1,15 +1,26 @@ import { describe, expect, test } from "bun:test"; -import type { SearchResult } from "../src/types/tweets"; -import { Cursor, SearchCursorPayloadSchema } from "../src/utils/cursor"; +import type { SearchResult } from "../src/types/posts"; +import { + isAfterSearchCursor, + isSearchCursorPayload, + type SearchCursorPayload, + Cursor, + SearchCursorPayloadSchema, +} from "../src/utils/cursor"; import { paginateSearchResults } from "../src/utils/search-pagination"; -const row = (tweetId: string, photoId: string, finalScore: number): SearchResult => ({ - photo_id: photoId, - original_url: `https://example.com/${photoId}.jpg`, - s3_path: `photos/${photoId}.jpg`, +const row = (postId: string, mediaId: string, finalScore: number): SearchResult => ({ + media_id: mediaId, + provider: "twitter", + user_id: "user-1", + kind: "image", + original_url: `https://example.com/${mediaId}.jpg`, + s3_path: `photos/${mediaId}.jpg`, username: "artist", - tweet_id: tweetId, - tweet_created_at: new Date("2026-01-01"), + post_id: postId, + post_provider: "twitter", + source_url: `https://example.com/posts/${postId}`, + post_created_at: new Date("2026-01-01"), is_nsfw: false, height: 100, width: 100, @@ -28,9 +39,9 @@ describe("search post pagination", () => { 1, ); - expect(page.rows.map((result) => result.photo_id)).toEqual(["photo-1", "photo-2"]); + expect(page.rows.map((result) => result.media_id)).toEqual(["photo-1", "photo-2"]); expect(page.hasNextPage).toBe(true); - expect(page.lastPost).toMatchObject({ tweet_id: "post-1", final_score: 0.9 }); + expect(page.lastPost).toMatchObject({ post_id: "post-1", final_score: 0.9 }); }); test("does not create a cursor when the page contains exactly the limit", () => { @@ -56,3 +67,67 @@ describe("search post pagination", () => { ).toBeNull(); }); }); + +const cursor: SearchCursorPayload = { + lastScore: 0.8, + lastProvider: "twitter", + lastPostId: "10", + lastUserId: "b", + queryTime: "2026-01-01T00:00:00.000Z", +}; + +describe("search cursor ordering", () => { + test("matches score/provider/post/user descending lexicographic order", () => { + expect( + isAfterSearchCursor({ finalScore: 0.7, provider: "z", postId: "99", userId: "z" }, cursor), + ).toBe(true); + expect( + isAfterSearchCursor( + { finalScore: 0.8, provider: "pixiv", postId: "99", userId: "z" }, + cursor, + ), + ).toBe(true); + expect( + isAfterSearchCursor( + { finalScore: 0.8, provider: "twitter", postId: "10", userId: "a" }, + cursor, + ), + ).toBe(true); + expect( + isAfterSearchCursor( + { finalScore: 0.8, provider: "twitter", postId: "09", userId: "z" }, + cursor, + ), + ).toBe(true); + expect( + isAfterSearchCursor( + { finalScore: 0.8, provider: "twitter", postId: "10", userId: "c" }, + cursor, + ), + ).toBe(false); + expect( + isAfterSearchCursor( + { finalScore: 0.8, provider: "twitter", postId: "10", userId: "b" }, + cursor, + ), + ).toBe(false); + }); + + test("rejects obsolete incomplete cursors", () => { + expect( + isSearchCursorPayload({ lastScore: 1, lastPhotoId: "1", queryTime: cursor.queryTime }), + ).toBe(false); + }); + + test("validates finite scores, non-empty identity, and canonical ISO query time", () => { + expect(isSearchCursorPayload(cursor)).toBe(true); + expect(isSearchCursorPayload({ ...cursor, queryTime: "January 1, 2026" })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, queryTime: "2026-02-30T00:00:00.000Z" })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, lastProvider: "" })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, lastProvider: " " })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, lastPostId: "" })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, lastUserId: "" })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, lastScore: Number.NaN })).toBe(false); + expect(isSearchCursorPayload({ ...cursor, lastScore: Number.POSITIVE_INFINITY })).toBe(false); + }); +}); diff --git a/packages/api/tests/transformations.test.ts b/packages/api/tests/transformations.test.ts new file mode 100644 index 00000000..0217c7a9 --- /dev/null +++ b/packages/api/tests/transformations.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; +import { transformSearchResultsPure } from "../src/utils/search-transformations"; +import { createPublicId, parseMediaPublicId } from "../src/utils/public-id"; +import { transformPosts } from "../src/utils/transformations"; + +describe("transformSearchResults", () => { + test("uses identical public IDs in gallery and global search", () => { + const createdAt = new Date("2026-01-01"); + const galleryPost = transformPosts([ + { + id: "post:1", + provider: "provider:one", + userId: "owner:one", + authorUsername: "artist", + username: "artist", + createdAt, + sourceUrl: "https://example.com/post/1", + media: [ + { + id: "media:1", + provider: "provider:one", + kind: "image", + originalUrl: "https://example.com/1.jpg", + }, + ], + }, + ]).at(0); + const searchPost = transformSearchResultsPure( + [ + { + media_id: "media:1", + kind: "image", + provider: "provider:one", + user_id: "owner:one", + original_url: "https://example.com/1.jpg", + s3_path: "media/1.jpg", + username: "artist", + post_id: "post:1", + post_provider: "provider:one", + source_url: "https://example.com/post/1", + post_created_at: createdAt, + is_nsfw: false, + height: 100, + width: 100, + final_score: 1, + }, + ], + "https://cdn.example.com", + ).at(0); + + expect(galleryPost?.id).toBe(searchPost?.id); + expect(galleryPost?.media.at(0)?.id).toBe(searchPost?.media.at(0)?.id); + expect(galleryPost?.id).not.toBe(createPublicId("post", "provider:two", "post:1", "owner:one")); + expect(galleryPost?.id).not.toBe(createPublicId("post", "provider:one", "post:1", "owner:two")); + }); + + test("does not merge colliding provider identifiers", () => { + const shared = { + media_id: "1", + kind: "image", + original_url: "https://example.com/1.jpg", + s3_path: "media/1.jpg", + username: "artist", + post_id: "1", + source_url: "https://example.com/post/1", + post_created_at: new Date("2026-01-01"), + is_nsfw: false, + height: 100, + width: 100, + final_score: 1, + }; + const posts = transformSearchResultsPure( + [ + { ...shared, provider: "twitter", post_provider: "twitter", user_id: "owner" }, + { ...shared, provider: "pixiv", post_provider: "pixiv", user_id: "owner" }, + ], + "https://cdn.example.com", + ); + expect(posts).toHaveLength(2); + expect(new Set(posts.map((post) => post.id)).size).toBe(2); + expect(posts.at(0)).toMatchObject({ + id: createPublicId("post", "twitter", "1", "owner"), + externalId: "1", + media: [{ id: createPublicId("media", "twitter", "1", "owner"), externalId: "1" }], + }); + expect(posts.at(1)).toMatchObject({ + id: createPublicId("post", "pixiv", "1", "owner"), + externalId: "1", + media: [{ id: createPublicId("media", "pixiv", "1", "owner"), externalId: "1" }], + }); + }); + + test("does not merge identifiers shared by different owners", () => { + const shared = { + media_id: "same-media", + kind: "image", + provider: "twitter", + original_url: "https://example.com/1.jpg", + s3_path: "media/1.jpg", + username: "artist", + post_id: "same-post", + post_provider: "twitter", + source_url: "https://x.com/i/status/same-post", + post_created_at: new Date("2026-01-01"), + is_nsfw: false, + height: 100, + width: 100, + final_score: 1, + }; + const posts = transformSearchResultsPure( + [ + { ...shared, user_id: "owner-a" }, + { ...shared, user_id: "owner-b" }, + ], + "https://cdn.example.com", + ); + expect(posts).toHaveLength(2); + expect(new Set(posts.map((post) => post.id)).size).toBe(2); + expect(new Set(posts.flatMap((post) => post.media.map((media) => media.id))).size).toBe(2); + const firstMediaId = posts.at(0)?.media.at(0)?.id; + expect(firstMediaId).toBeDefined(); + if (!firstMediaId) { + throw new Error("Expected a transformed media"); + } + expect(parseMediaPublicId(firstMediaId)).toEqual({ + provider: "twitter", + externalId: "same-media", + userId: "owner-a", + }); + }); + + test("keeps an identity stable regardless of neighboring collisions", () => { + const row = { + media_id: "media:with:separators", + kind: "image", + provider: "provider:with:separators", + user_id: "owner-a", + original_url: "https://example.com/1.jpg", + s3_path: "media/1.jpg", + username: "artist", + post_id: "post:1", + post_provider: "provider:with:separators", + source_url: "https://example.com/post/1", + post_created_at: new Date("2026-01-01"), + is_nsfw: false, + height: 100, + width: 100, + final_score: 1, + }; + const collision = { ...row, user_id: "owner-b" }; + const alone = transformSearchResultsPure([row], "https://cdn.example.com").at(0); + const besideCollision = transformSearchResultsPure( + [row, collision], + "https://cdn.example.com", + ).at(0); + expect(alone?.id).toBe(besideCollision?.id); + expect(alone?.media.at(0)?.id).toBe(besideCollision?.media.at(0)?.id); + expect(parseMediaPublicId(alone?.media.at(0)?.id ?? "")).toEqual({ + provider: "provider:with:separators", + externalId: "media:with:separators", + userId: "owner-a", + }); + }); + + test("parses legacy Twitter and current media IDs only", () => { + expect(parseMediaPublicId("123")).toEqual({ provider: "twitter", externalId: "123" }); + expect(parseMediaPublicId(createPublicId("media", "pixiv", "456", "owner"))).toEqual({ + provider: "pixiv", + externalId: "456", + userId: "owner", + }); + }); + + test("rejects malformed or non-media public IDs", () => { + expect(parseMediaPublicId("")).toBeNull(); + expect(parseMediaPublicId(" ")).toBeNull(); + expect(parseMediaPublicId("+1")).toBeNull(); + expect(parseMediaPublicId("1.0")).toBeNull(); + expect(parseMediaPublicId("1a")).toBeNull(); + expect(parseMediaPublicId("pixiv:456")).toBeNull(); + expect(parseMediaPublicId("~not-base64url")).toBeNull(); + expect(parseMediaPublicId(createPublicId("post", "twitter", "123", "owner"))).toBeNull(); + expect( + parseMediaPublicId( + `~${Buffer.from(JSON.stringify(["v2", "media", "twitter", "123", "owner"])).toString("base64url")}`, + ), + ).toBeNull(); + expect(parseMediaPublicId(createPublicId("media", "twitter", "", "owner"))).toBeNull(); + expect(parseMediaPublicId(`${createPublicId("media", "twitter", "123", "owner")}=`)).toBeNull(); + expect( + parseMediaPublicId( + `~${Buffer.from(JSON.stringify(["media", "twitter", "789", "owner"])).toString("base64url")}`, + ), + ).toBeNull(); + }); +}); diff --git a/packages/api/tests/twitter-cookies.test.ts b/packages/api/tests/twitter-cookies.test.ts new file mode 100644 index 00000000..7145bf84 --- /dev/null +++ b/packages/api/tests/twitter-cookies.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { + getTwitterUserId, + normalizeTwitterCookies, + parseTwitterCookies, +} from "../src/services/twitter-cookies"; + +const normalizedCookie = (domain: string, value = "u%3D123456") => [ + { domain, key: "auth_token", value: "token" }, + { domain, key: "twid", value }, +]; + +describe("Twitter cookies", () => { + test("normalizes a Firefox Cookie Quick Manager export", () => { + const exported = [ + { + "Host raw": "https://x.com/", + "Name raw": "auth_token", + "Content raw": "token", + }, + { + "Host raw": "https://x.com/", + "Name raw": "twid", + "Content raw": "u%3D123456", + }, + ]; + + expect(parseTwitterCookies(JSON.stringify(exported))).toEqual(normalizedCookie("x.com")); + }); + + test("preserves the current normalized shape", () => { + const cookies = normalizedCookie(".x.com"); + expect(parseTwitterCookies(JSON.stringify(cookies))).toEqual(cookies); + expect(normalizeTwitterCookies(JSON.stringify(cookies))).toBe(JSON.stringify(cookies)); + expect(getTwitterUserId(normalizedCookie("x.com", "u=123456"))).toBe("123456"); + }); + + test.each([ + "x.com", + ".x.com", + "api.x.com", + ".api.x.com", + "twitter.com", + ".twitter.com", + "mobile.twitter.com", + ])("accepts the Twitter domain boundary: %s", (domain) => { + expect(getTwitterUserId(normalizedCookie(domain))).toBe("123456"); + }); + + test.each(["evilx.com", ".evilx.com", "x.com.evil.test", "eviltwitter.com"])( + "rejects a Twitter domain lookalike: %s", + (domain) => { + expect(() => parseTwitterCookies(JSON.stringify(normalizedCookie(domain)))).toThrow(); + }, + ); + + test.each(["prefixu%3D123456", "u%3D123456suffix", "x%3Du%3D123456", "%E0%A4%A"])( + "rejects a malformed twid value: %s", + (value) => { + expect(() => parseTwitterCookies(JSON.stringify(normalizedCookie("x.com", value)))).toThrow(); + }, + ); + + test.each([ + [{ "Host raw": "https://x.com/", "Name raw": "twid" }], + [{ "Host raw": 42, "Name raw": "twid", "Content raw": "u%3D123456" }], + [{ "Host raw": "x.com", "Name raw": "twid", "Content raw": "u%3D123456" }], + ])("rejects a malformed Firefox export", (cookies) => { + expect(() => parseTwitterCookies(JSON.stringify(cookies))).toThrow("Invalid Twitter cookies"); + }); +}); diff --git a/packages/api/tests/twitter-credential.test.ts b/packages/api/tests/twitter-credential.test.ts new file mode 100644 index 00000000..10e7e709 --- /dev/null +++ b/packages/api/tests/twitter-credential.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { createPixivCredentialService } from "../src/services/pixiv-credential-core"; +import { createTwitterCredentialService } from "../src/services/twitter-credential-core"; + +const userId = "user-id"; +const telegramId = 42n; +const cookies = JSON.stringify([ + { domain: ".x.com", key: "auth_token", value: "token" }, + { domain: ".x.com", key: "twid", value: "u%3D123456" }, +]); +const replacementCookies = JSON.stringify([ + { domain: ".x.com", key: "auth_token", value: "replacement" }, + { domain: ".x.com", key: "twid", value: "u%3D654321" }, +]); +const firefoxCookies = JSON.stringify([ + { "Host raw": "https://x.com/", "Name raw": "auth_token", "Content raw": "token" }, + { "Host raw": "https://x.com/", "Name raw": "twid", "Content raw": "u%3D123456" }, +]); +const normalizedFirefoxCookies = JSON.stringify([ + { domain: "x.com", key: "auth_token", value: "token" }, + { domain: "x.com", key: "twid", value: "u%3D123456" }, +]); + +describe("Twitter credential service", () => { + let credential: { + credentialType: string; + encryptedSecret: string; + provider: string; + user: { telegramId: bigint }; + } | null; + let concurrentAction: "delete" | "save" | undefined; + + beforeEach(() => { + credential = { + credentialType: "cookies", + encryptedSecret: `legacy:${cookies}`, + provider: "twitter", + user: { telegramId }, + }; + concurrentAction = undefined; + }); + + const createService = () => + createTwitterCredentialService({ + find: () => Promise.resolve(credential), + decrypt: (secret) => { + if (secret === "corrupt") { + throw new Error("invalid authentication tag"); + } + return { + data: secret.replace(/^(legacy|scoped):/, ""), + usedLegacyEncryption: !secret.startsWith("scoped:"), + }; + }, + encrypt: (cookies) => `scoped:${cookies}`, + updateMatching: (_id, original, replacement) => { + if (concurrentAction === "save") { + credential = { + credentialType: "cookies", + encryptedSecret: `scoped:${replacementCookies}`, + provider: "twitter", + user: { telegramId }, + }; + } else if (concurrentAction === "delete") { + credential = null; + } + if (!credential || credential.encryptedSecret !== original) { + return Promise.resolve({ count: 0 }); + } + credential.encryptedSecret = replacement; + return Promise.resolve({ count: 1 }); + }, + deleteMatching: (_id, original) => { + if (concurrentAction === "save") { + credential = { + credentialType: "cookies", + encryptedSecret: `scoped:${replacementCookies}`, + provider: "twitter", + user: { telegramId }, + }; + } else if (concurrentAction === "delete") { + credential = null; + } + if (!credential || credential.encryptedSecret !== original) { + return Promise.resolve({ count: 0 }); + } + credential = null; + return Promise.resolve({ count: 1 }); + }, + }); + + test("upgrades legacy encryption with compare-and-swap", async () => { + expect(await createService().get(userId)).toBe(cookies); + expect(credential?.encryptedSecret).toBe(`scoped:${cookies}`); + }); + + test("preserves valid plaintext legacy cookie JSON", async () => { + if (credential) { + credential.encryptedSecret = cookies; + } + + expect(await createService().get(userId)).toBe(cookies); + expect(credential?.encryptedSecret).toBe(`scoped:${cookies}`); + }); + + test("normalizes Firefox plaintext while retaining its original CAS value", async () => { + if (credential) { + credential.encryptedSecret = firefoxCookies; + } + + expect(await createService().get(userId)).toBe(normalizedFirefoxCookies); + expect(credential?.encryptedSecret).toBe(`scoped:${normalizedFirefoxCookies}`); + }); + + test("a concurrent save wins a legacy upgrade race", async () => { + concurrentAction = "save"; + expect(await createService().get(userId)).toBe(replacementCookies); + expect(credential?.encryptedSecret).toBe(`scoped:${replacementCookies}`); + }); + + test("a concurrent delete wins a legacy upgrade race", async () => { + concurrentAction = "delete"; + expect(await createService().get(userId)).toBeUndefined(); + expect(credential).toBeNull(); + }); + + test("rejects an invalid credential type", async () => { + if (credential) { + credential.credentialType = "refresh_token"; + } + expect(await createService().get(userId)).toBeUndefined(); + }); + + test("rejects an invalid provider", async () => { + if (credential) { + credential.provider = "pixiv"; + } + expect(await createService().get(userId)).toBeUndefined(); + }); + + test("removes a cryptographically corrupt credential", async () => { + if (credential) { + credential.encryptedSecret = "corrupt"; + } + expect(await createService().get(userId)).toBeUndefined(); + expect(credential).toBeNull(); + }); + + test.each(["not-hex-garbage", "abc123", '[{"domain":".x.com"'])( + "removes malformed or truncated cookie data: %s", + async (invalid) => { + if (credential) { + credential.encryptedSecret = invalid; + } + + expect(await createService().get(userId)).toBeUndefined(); + expect(credential).toBeNull(); + }, + ); + + test("a concurrent valid save wins corruption cleanup and is returned", async () => { + if (credential) { + credential.encryptedSecret = "not-hex-garbage"; + } + concurrentAction = "save"; + + expect(await createService().get(userId)).toBe(replacementCookies); + expect(credential?.encryptedSecret).toBe(`scoped:${replacementCookies}`); + }); +}); + +describe("Pixiv credential service", () => { + const state = { + client: { refreshToken: "rotated-token" }, + lockActive: false, + persistToken: () => Promise.resolve({ count: 1 }), + }; + const updateMatching = mock((_userId: string, _encryptedSecret: string, _replacement: string) => + state.persistToken(), + ); + const withPixivClient = createPixivCredentialService({ + withLock: async (_userId: string, operation: () => Promise) => { + state.lockActive = true; + try { + return await operation(); + } finally { + state.lockActive = false; + } + }, + find: () => + Promise.resolve({ credentialType: "refresh_token", encryptedSecret: "original-token" }), + decryptScoped: (token) => token, + decryptLegacy: (token) => token, + connect: () => Promise.resolve(state.client), + encrypt: (token) => token, + updateMatching, + }); + + test("persists a rotated token before starting the operation", async () => { + state.client.refreshToken = "rotated-token"; + state.persistToken = () => Promise.resolve({ count: 1 }); + updateMatching.mockClear(); + + const result = await withPixivClient("user", () => { + expect(state.lockActive).toBe(false); + expect(updateMatching).toHaveBeenCalledWith("user", "original-token", "rotated-token"); + return Promise.resolve("bookmarks"); + }); + + expect(result).toBe("bookmarks"); + }); + + test("does not start the operation when persistence fails", async () => { + const persistenceError = new Error("database unavailable"); + state.client.refreshToken = "rotated-token"; + state.persistToken = () => Promise.reject(persistenceError); + updateMatching.mockClear(); + const operation = mock(() => Promise.resolve("bookmarks")); + + await expect(withPixivClient("user", operation)).rejects.toBe(persistenceError); + expect(operation).not.toHaveBeenCalled(); + }); + + test("does not overwrite a concurrently changed credential", async () => { + state.client.refreshToken = "rotated-token"; + state.persistToken = () => Promise.resolve({ count: 0 }); + updateMatching.mockClear(); + const operation = mock(() => Promise.resolve("bookmarks")); + + await expect(withPixivClient("user", operation)).rejects.toThrow( + "Pixiv credential changed during token rotation", + ); + expect(operation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/crypto/src/cookie-encryption.ts b/packages/crypto/src/cookie-encryption.ts index de98e8e0..4cd9bdd0 100644 --- a/packages/crypto/src/cookie-encryption.ts +++ b/packages/crypto/src/cookie-encryption.ts @@ -31,8 +31,10 @@ export class CookieEncryption { /** * Derive a unique key for a specific user */ - private deriveKey(userId: string): Uint8Array { - const info = utf8ToBytes(`cookie-encryption-${userId}`); + private deriveKey(userId: string, purpose?: string): Uint8Array { + const info = utf8ToBytes( + purpose ? `starlight-secret-v1:${purpose}:${userId}` : `cookie-encryption-${userId}`, + ); return hkdf(sha256, this.masterKey, this.salt, info, 32); } @@ -62,6 +64,12 @@ export class CookieEncryption { return bytesToHex(ciphertext); } + encryptScoped(data: string, userId: string, purpose: string): string { + const key = this.deriveKey(userId, purpose); + const cipher = managedNonce(xchacha20poly1305)(key); + return bytesToHex(cipher.encrypt(utf8ToBytes(data))); + } + /** * Decrypt cookie data for a specific user */ @@ -73,6 +81,31 @@ export class CookieEncryption { return bytesToUtf8(plaintext); } + decryptScoped(encryptedHex: string, userId: string, purpose: string): string { + const key = this.deriveKey(userId, purpose); + const cipher = managedNonce(xchacha20poly1305)(key); + return bytesToUtf8(cipher.decrypt(hexToBytes(encryptedHex))); + } + + decryptScopedOrLegacy( + data: string, + userId: string, + purpose: string, + legacyUserId = userId, + ): { data: string; usedLegacyEncryption: boolean } { + try { + return { + data: this.decryptScoped(data, userId, purpose), + usedLegacyEncryption: false, + }; + } catch { + return { + data: this.safeDecrypt(data, legacyUserId), + usedLegacyEncryption: true, + }; + } + } + /** * Safely decrypt with fallback to unencrypted data */ diff --git a/packages/utils/prisma.config.ts b/packages/utils/prisma.config.ts index f2bbd77d..207efcc5 100644 --- a/packages/utils/prisma.config.ts +++ b/packages/utils/prisma.config.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import dotenv from "dotenv"; import { defineConfig } from "prisma/config"; @@ -8,7 +7,7 @@ dotenv.config({ }); export default defineConfig({ - schema: path.join("prisma", "schema.prisma"), + schema: "prisma/schema.prisma", datasource: { // Fallback keeps generate working in build stages that have no DATABASE_URL url: process.env.DATABASE_URL ?? "postgresql://prisma:prisma@localhost:5432/prisma", diff --git a/packages/utils/prisma/migrations/20260815204820_provider_neutral_posts_pixiv/migration.sql b/packages/utils/prisma/migrations/20260815204820_provider_neutral_posts_pixiv/migration.sql new file mode 100644 index 00000000..e370b9d9 --- /dev/null +++ b/packages/utils/prisma/migrations/20260815204820_provider_neutral_posts_pixiv/migration.sql @@ -0,0 +1,119 @@ +-- RenameTable +ALTER TABLE "tweets" RENAME TO "posts"; +ALTER TABLE "photos" RENAME TO "media"; + +-- RenameColumn +ALTER TABLE "posts" RENAME COLUMN "id" TO "external_id"; +ALTER TABLE "posts" RENAME COLUMN "tweet_data" TO "provider_payload"; +ALTER TABLE "posts" RENAME COLUMN "tweet_text" TO "text"; +ALTER TABLE "media" RENAME COLUMN "id" TO "external_id"; +ALTER TABLE "media" RENAME COLUMN "tweet_id" TO "post_external_id"; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "pixiv_include_private" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "posts" +ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'twitter', +ADD COLUMN "source_url" TEXT, +ADD COLUMN "author_external_id" TEXT, +ADD COLUMN "author_name" TEXT, +ADD COLUMN "author_username" TEXT, +ADD COLUMN "title" TEXT, +ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], +ALTER COLUMN "text" DROP EXPRESSION, +ALTER COLUMN "username" DROP EXPRESSION; +ALTER TABLE "media" +ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'twitter', +ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'image', +ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0; + +-- Backfill provider-neutral post fields from the legacy Twitter payload. +UPDATE "posts" SET + "source_url" = 'https://x.com/i/status/' || "external_id", + "author_external_id" = "provider_payload"->>'userId', + "author_name" = "provider_payload"->>'name', + "author_username" = COALESCE("provider_payload"->>'username', "username"); + +-- Preserve every legacy media row's order while satisfying the new unique position. +WITH positions AS ( + SELECT + "external_id", + "user_id", + ROW_NUMBER() OVER ( + PARTITION BY "post_external_id", "user_id" + ORDER BY "created_at", "external_id" + ) - 1 AS position + FROM "media" +) +UPDATE "media" SET "position" = positions.position +FROM positions +WHERE "media"."external_id" = positions."external_id" + AND "media"."user_id" = positions."user_id"; + +ALTER TABLE "posts" +ALTER COLUMN "source_url" SET NOT NULL, +ALTER COLUMN "provider" DROP DEFAULT; +ALTER TABLE "media" ALTER COLUMN "provider" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "provider_credentials" ( + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "credential_type" TEXT NOT NULL, + "encrypted_secret" TEXT NOT NULL, + "external_user_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "provider_credentials_pkey" PRIMARY KEY ("user_id","provider") +); + +-- Move legacy Twitter cookies before removing their source column. +INSERT INTO "provider_credentials" ( + "user_id", + "provider", + "credential_type", + "encrypted_secret", + "created_at", + "updated_at" +) +SELECT + "id", + 'twitter', + 'cookies', + "cookies", + "created_at", + "updated_at" +FROM "users" +WHERE "cookies" IS NOT NULL; + +ALTER TABLE "users" DROP COLUMN "cookies"; + +-- ReplacePrimaryKey +ALTER TABLE "media" DROP CONSTRAINT "photos_tweet_id_user_id_fkey"; +ALTER TABLE "posts" DROP CONSTRAINT "tweets_pkey"; +ALTER TABLE "media" DROP CONSTRAINT "photos_pkey"; +ALTER TABLE "posts" ADD CONSTRAINT "posts_pkey" PRIMARY KEY ("external_id", "user_id", "provider"); +ALTER TABLE "media" ADD CONSTRAINT "media_pkey" PRIMARY KEY ("external_id", "user_id", "provider"); + +-- RenameForeignKey +ALTER TABLE "media" RENAME CONSTRAINT "photos_user_id_fkey" TO "media_user_id_fkey"; +ALTER TABLE "posts" RENAME CONSTRAINT "tweets_user_id_fkey" TO "posts_user_id_fkey"; + +-- RenameIndex +ALTER INDEX "photos_created_at_idx" RENAME TO "media_created_at_idx"; +ALTER INDEX "photos_deleted_at_idx" RENAME TO "media_deleted_at_idx"; +ALTER INDEX "photos_hash_bucket_12_idx" RENAME TO "media_hash_bucket_12_idx"; +ALTER INDEX "photos_hash_bucket_4_idx" RENAME TO "media_hash_bucket_4_idx"; +ALTER INDEX "photos_hash_bucket_8_idx" RENAME TO "media_hash_bucket_8_idx"; +ALTER INDEX "photos_perceptual_hash_idx" RENAME TO "media_perceptual_hash_idx"; +ALTER INDEX "photos_s3_path_idx" RENAME TO "media_s3_path_idx"; +ALTER INDEX "photos_tweet_id_idx" RENAME TO "media_post_external_id_idx"; +ALTER INDEX "tweets_user_id_created_at_idx" RENAME TO "posts_user_id_created_at_idx"; +ALTER INDEX "tweets_username_idx" RENAME TO "posts_username_idx"; + +-- CreateIndex +CREATE UNIQUE INDEX "media_post_external_id_user_id_provider_position_key" ON "media"("post_external_id", "user_id", "provider", "position"); + +-- AddForeignKey +ALTER TABLE "provider_credentials" ADD CONSTRAINT "provider_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "media" ADD CONSTRAINT "media_post_external_id_user_id_provider_fkey" FOREIGN KEY ("post_external_id", "user_id", "provider") REFERENCES "posts"("external_id", "user_id", "provider") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/utils/prisma/schema.prisma b/packages/utils/prisma/schema.prisma index f4d7d4e8..12dab8f4 100644 --- a/packages/utils/prisma/schema.prisma +++ b/packages/utils/prisma/schema.prisma @@ -20,34 +20,50 @@ enum ChatMemoryScope { } model User { - id String @id @default(uuid(7)) @db.Uuid - telegramId BigInt @unique @map("telegram_id") - username String? @unique - firstName String @map("first_name") - lastName String? @map("last_name") - isBot Boolean @default(false) @map("is_bot") - isPublic Boolean @default(false) @map("is_public") - cookies String? @db.Text + id String @id @default(uuid(7)) @db.Uuid + telegramId BigInt @unique @map("telegram_id") + username String? @unique + firstName String @map("first_name") + lastName String? @map("last_name") + isBot Boolean @default(false) @map("is_bot") + isPublic Boolean @default(false) @map("is_public") + pixivIncludePrivate Boolean @default(false) @map("pixiv_include_private") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations - tweets Tweet[] - photos Photo[] - videos Video[] - chatMembers ChatMember[] + posts Post[] + media Media[] + videos Video[] + chatMembers ChatMember[] + providerCredentials ProviderCredential[] @@map("users") } +model ProviderCredential { + userId String @map("user_id") @db.Uuid + provider String + credentialType String @map("credential_type") + encryptedSecret String @map("encrypted_secret") @db.Text + externalUserId String? @map("external_user_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, provider]) + @@map("provider_credentials") +} + model Chat { // Telegram ID id BigInt @id title String? username String? - settings Json @default("{}") + settings Json @default("{}") photoThumbnail String? @map("photo_thumbnail") photoBig String? @map("photo_big") @@ -128,35 +144,43 @@ model ChatMember { @@map("chat_members") } -model Tweet { - id String - userId String @map("user_id") @db.Uuid +model Post { + id String @map("external_id") + userId String @map("user_id") @db.Uuid + provider String + sourceUrl String @map("source_url") + authorExternalId String? @map("author_external_id") + authorName String? @map("author_name") + authorUsername String? @map("author_username") + title String? + text String? @db.Text + tags String[] @default([]) - /// [TweetType] - tweetData Json @map("tweet_data") + providerPayload Json @map("provider_payload") - // Virtual columns, manually generated in SQL migration - tweetText String? @default(dbgenerated()) @map("tweet_text") @db.Text - username String? @default(dbgenerated()) @map("username") @db.Text + // Kept for compatibility with existing gallery reads. + username String? @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations - photos Photo[] - user User @relation(fields: [userId], references: [id], onDelete: Restrict) + media Media[] + user User @relation(fields: [userId], references: [id], onDelete: Restrict) - @@id(name: "tweetId", [id, userId]) + @@id(name: "postId", [id, userId, provider]) @@index([userId, createdAt(sort: Desc)]) @@index([username]) - @@map("tweets") + @@map("posts") } -model Photo { - // Twitter ID - id String - tweetId String @map("tweet_id") +model Media { + id String @map("external_id") + postId String @map("post_external_id") userId String @map("user_id") @db.Uuid + provider String + kind String @default("image") + position Int @default(0) s3Path String? @map("s3_path") originalUrl String @map("original_url") perceptualHash String? @map("perceptual_hash") @@ -180,11 +204,12 @@ model Photo { deletedAt DateTime? @map("deleted_at") // Relations - tweet Tweet @relation(fields: [tweetId, userId], references: [id, userId], onDelete: Restrict) - user User @relation(fields: [userId], references: [id], onDelete: Restrict) + post Post @relation(fields: [postId, userId, provider], references: [id, userId, provider], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) - @@id(name: "photoId", [id, userId]) - @@index([tweetId]) + @@id(name: "mediaId", [id, userId, provider]) + @@unique([postId, userId, provider, position]) + @@index([postId]) @@index([perceptualHash]) @@index([hashBucket4]) @@index([hashBucket8]) @@ -192,7 +217,7 @@ model Photo { @@index([s3Path]) @@index([deletedAt]) @@index([createdAt(sort: Desc)]) - @@map("photos") + @@map("media") } model Video { @@ -265,7 +290,7 @@ model Message { deletedAt DateTime? @map("deleted_at") // Relations - chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) attachments Attachment[] parts MessagePart[] diff --git a/packages/utils/src/db.ts b/packages/utils/src/db.ts index 7fe07167..46fee3bf 100644 --- a/packages/utils/src/db.ts +++ b/packages/utils/src/db.ts @@ -2,8 +2,7 @@ import { PrismaPg } from "@prisma/adapter-pg"; import Sqids from "sqids"; import { parse as uuidParse } from "uuid"; import env from "./config"; -import { PrismaClient } from "./generated/prisma/client"; -import type { Prisma as PrismaGenerated } from "./generated/prisma/client"; +import { PrismaClient, type Prisma as PrismaGenerated } from "./generated/prisma/client"; const sqids = new Sqids({ minLength: 12, @@ -22,28 +21,31 @@ const onlyNotDeletedMessages = < >( args: T, ): T => { - if (args.where?.deletedAt !== undefined) { + const where = args.where as Record | undefined; + if (where?.deletedAt !== undefined) { return args; } args.where = { ...args.where, deletedAt: null, - }; + } as PrismaGenerated.MessageWhereInput; return args; }; -const MESSAGE_READ_OPERATIONS = new Set([ - "findUnique", - "findUniqueOrThrow", - "findMany", - "findFirst", - "findFirstOrThrow", - "count", - "aggregate", - "groupBy", -]); +const isMessageReadOperation = (operation: string) => { + return ( + operation === "findUnique" || + operation === "findUniqueOrThrow" || + operation === "findMany" || + operation === "findFirst" || + operation === "findFirstOrThrow" || + operation === "count" || + operation === "aggregate" || + operation === "groupBy" + ); +}; export const prisma = new PrismaClient({ log: env.NODE_ENV === "production" ? ["warn", "error"] : ["info", "warn", "error"], @@ -52,7 +54,7 @@ export const prisma = new PrismaClient({ query: { message: { $allOperations({ operation, args, query }) { - if (MESSAGE_READ_OPERATIONS.has(operation)) { + if (isMessageReadOperation(operation)) { return query( onlyNotDeletedMessages( args as { @@ -75,22 +77,26 @@ export const prisma = new PrismaClient({ }, }, }, - photo: { + media: { externalId: { needs: { id: true, + provider: true, userId: true, }, - compute(data: { id: string; userId: string }) { + compute(data: { id: string; provider: string; userId: string }) { + if (data.provider !== "twitter") { + return `${data.provider}:${data.id}`; + } // Split Twitter ID into 3 parts to handle large numbers that exceed bigint - const { id } = data; + const id = data.id; const chunkSize = Math.ceil(id.length / 3); const parts = [ id.slice(0, chunkSize), id.slice(chunkSize, chunkSize * 2), id.slice(chunkSize * 2), - ].map((part) => Math.trunc(Number(part || "0"))); + ].map((part) => Number.parseInt(part || "0", 10)); const userId = uuidParse(data.userId); @@ -138,22 +144,22 @@ export const prisma = new PrismaClient({ }, }, model: { - photo: { - available: (): PrismaGenerated.PhotoWhereInput => ({ + media: { + available: () => ({ deletedAt: null, s3Path: { not: null }, }), - }, - tweet: { - available: (): PrismaGenerated.TweetWhereInput => ({ - photos: { + } satisfies Record PrismaGenerated.MediaWhereInput>, + post: { + available: () => ({ + media: { some: { deletedAt: null, s3Path: { not: null }, }, }, }), - }, + } satisfies Record PrismaGenerated.PostWhereInput>, message: { async hasNewerMessages(params: { chatId: bigint | number; diff --git a/tsconfig.json b/tsconfig.json index ffcbb947..4979fccb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "./tsconfig.base.json" + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + } }