Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ S3_ACCESS_KEY_ID=minio
S3_SECRET_ACCESS_KEY=minio-password
S3_AVATARS_BUCKET=seen-avatars
S3_PUBLIC_BASE_URL=http://localhost:3000
# Salt for hashing contact identifiers. MUST equal the client's
# EXPO_PUBLIC_CONTACT_HASH_SALT, or contact matching silently finds nothing.
CONTACT_HASH_SALT=seen-contact-v1
EXPO_PUBLIC_API_URL=http://localhost:3000
EXPO_PUBLIC_CONTACT_HASH_SALT=seen-contact-v1
5 changes: 5 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DEFAULT_CONTACT_HASH_SALT } from "@seen/shared";

function optionalEnv(name: string) {
const value = process.env[name];
return value && value.trim().length > 0 ? value : undefined;
Expand Down Expand Up @@ -36,4 +38,7 @@ export const env = {
s3SecretAccessKey: requiredEnv("S3_SECRET_ACCESS_KEY", "minio-password"),
s3AvatarsBucket: requiredEnv("S3_AVATARS_BUCKET", "seen-avatars"),
s3PublicBaseUrl: requiredEnv("S3_PUBLIC_BASE_URL", "http://localhost:3000"),
// Must match the client's EXPO_PUBLIC_CONTACT_HASH_SALT so device-computed
// contact hashes line up with server-stored ones.
contactHashSalt: requiredEnv("CONTACT_HASH_SALT", DEFAULT_CONTACT_HASH_SALT),
};
19 changes: 19 additions & 0 deletions apps/api/src/lib/contact-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createHash } from "node:crypto";

import {
buildContactHashPayload,
normalizeContactValue,
type ContactIdentifierKind,
} from "@seen/shared";

import { env } from "../env";

// Hash a contact identifier the same way the mobile client does (salted SHA-256
// hex over the normalized value). Returns null when the value can't be normalized.
export function hashContactValue(kind: ContactIdentifierKind, value: string): string | null {
const normalized = normalizeContactValue(kind, value);
if (!normalized) return null;
return createHash("sha256")
.update(buildContactHashPayload(env.contactHashSalt, kind, normalized))
.digest("hex");
}
52 changes: 52 additions & 0 deletions apps/api/src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { HttpError } from "./http-error";
import { redis } from "./redis";

type RateLimitOptions = {
key: string;
max: number;
windowSeconds: number;
message: string;
code: string;
};

type MemoryBucket = {
count: number;
resetAt: number;
};

const memoryBuckets = new Map<string, MemoryBucket>();

function assertMemoryRateLimit({ key, max, windowSeconds, message, code }: RateLimitOptions) {
const now = Date.now();
const existing = memoryBuckets.get(key);
const bucket =
existing && existing.resetAt > now
? existing
: { count: 0, resetAt: now + windowSeconds * 1000 };

bucket.count += 1;
memoryBuckets.set(key, bucket);

if (bucket.count > max) {
throw new HttpError(429, message, code);
}
}

export async function assertRateLimit(options: RateLimitOptions): Promise<void> {
if (!redis) {
assertMemoryRateLimit(options);
return;
}

const key = `rate:${options.key}`;
try {
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, options.windowSeconds);
if (count > options.max) {
throw new HttpError(429, options.message, options.code);
}
} catch (error) {
if (error instanceof HttpError) throw error;
assertMemoryRateLimit(options);
}
}
18 changes: 18 additions & 0 deletions apps/api/src/modules/profiles/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@ import { Elysia, t } from "elysia";

const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]);

const followPolicy = t.Union([t.Literal("open"), t.Literal("approval_required")]);
const profileVisibility = t.Union([t.Literal("public"), t.Literal("followers")]);
const watchlistVisibility = t.Union([
t.Literal("private"),
t.Literal("followers"),
t.Literal("public"),
]);

const profile = t.Object({
id: t.String(),
full_name: t.String(),
username: t.String(),
avatar_path: t.Nullable(t.String()),
follow_policy: followPolicy,
profile_visibility: profileVisibility,
default_watchlist_visibility: watchlistVisibility,
contact_discovery_enabled: t.Boolean(),
created_at: t.String(),
updated_at: t.String(),
});
Expand Down Expand Up @@ -35,6 +47,12 @@ export const ProfileModel = new Elysia({ name: "Profile.Model" }).model({
username: t.String({ minLength: 3, maxLength: 20, pattern: "^[a-z0-9_.]+$" }),
avatarPath: t.Optional(t.Nullable(t.String())),
}),
"profile.PrivacyBody": t.Object({
followPolicy: t.Optional(followPolicy),
profileVisibility: t.Optional(profileVisibility),
defaultWatchlistVisibility: t.Optional(watchlistVisibility),
contactDiscoveryEnabled: t.Optional(t.Boolean()),
}),
"profile.ActivityQuery": t.Object({
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 50 })),
offset: t.Optional(t.Numeric({ minimum: 0 })),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/modules/profiles/mutations/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./update-my-profile";
export * from "./update-my-privacy";
export * from "./upload-avatar";
export * from "./delete-avatar";
91 changes: 91 additions & 0 deletions apps/api/src/modules/profiles/mutations/update-my-privacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { db } from "@seen/db";
import { profileContactIdentifiers, profiles } from "@seen/db/schema";
import { and, eq, ne } from "@seen/db/orm";

import { hashContactValue } from "../../../lib/contact-hash";
import { HttpError } from "../../../lib/http-error";
import { toApiRow } from "../../../lib/rows";
import { getOrCreateMyProfile } from "../queries/get-or-create-my-profile";

type AuthUser = {
id: string;
email?: string | null;
emailVerified?: boolean | null;
name?: string | null;
image?: string | null;
userMetadata?: Record<string, unknown> | null;
};

type PrivacyInput = {
followPolicy?: "open" | "approval_required";
profileVisibility?: "public" | "followers";
defaultWatchlistVisibility?: "private" | "followers" | "public";
contactDiscoveryEnabled?: boolean;
};

// Reconcile the user's stored contact identifiers with their discovery setting.
// When discovery is on we store the salted hash of their verified email (and only
// that hash); when off we remove every identifier so they're no longer matchable.
async function syncContactIdentifiers(user: AuthUser, discoveryEnabled: boolean) {
if (!discoveryEnabled) {
await db.delete(profileContactIdentifiers).where(eq(profileContactIdentifiers.userId, user.id));
return;
}

const emailHash = user.emailVerified && user.email ? hashContactValue("email", user.email) : null;
if (!emailHash) {
// Nothing verifiable to store yet; clear any stale rows.
await db.delete(profileContactIdentifiers).where(eq(profileContactIdentifiers.userId, user.id));
return;
}

await db
.insert(profileContactIdentifiers)
.values({ userId: user.id, kind: "email", hash: emailHash })
.onConflictDoNothing({
target: [
profileContactIdentifiers.userId,
profileContactIdentifiers.kind,
profileContactIdentifiers.hash,
],
});
// Drop any previously stored email hash that no longer matches (e.g. email changed).
await db
.delete(profileContactIdentifiers)
.where(
and(
eq(profileContactIdentifiers.userId, user.id),
eq(profileContactIdentifiers.kind, "email"),
ne(profileContactIdentifiers.hash, emailHash),
),
);
}

export async function updateMyPrivacy(user: AuthUser, input: PrivacyInput) {
// Guarantee the profile row exists before patching it.
await getOrCreateMyProfile(user);

const patch: Partial<typeof profiles.$inferInsert> = {};
if (input.followPolicy !== undefined) patch.followPolicy = input.followPolicy;
if (input.profileVisibility !== undefined) patch.profileVisibility = input.profileVisibility;
if (input.defaultWatchlistVisibility !== undefined) {
patch.defaultWatchlistVisibility = input.defaultWatchlistVisibility;
}
if (input.contactDiscoveryEnabled !== undefined) {
patch.contactDiscoveryEnabled = input.contactDiscoveryEnabled;
}

if (Object.keys(patch).length > 0) {
patch.updatedAt = new Date();
await db.update(profiles).set(patch).where(eq(profiles.id, user.id));
}

const [row] = await db.select().from(profiles).where(eq(profiles.id, user.id)).limit(1);
if (!row) throw new HttpError(404, "Profile not found.", "profile-not-found");

if (input.contactDiscoveryEnabled !== undefined) {
await syncContactIdentifiers(user, row.contactDiscoveryEnabled);
}

return toApiRow(row);
}
9 changes: 8 additions & 1 deletion apps/api/src/modules/profiles/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Elysia } from "elysia";
import { authGuard } from "../../auth-plugin";
import { ProfileModel } from "./model";
import { getAvatar, getMyProfileActivity, getOrCreateMyProfile } from "./queries";
import { deleteAvatar, updateMyProfile, uploadAvatar } from "./mutations";
import { deleteAvatar, updateMyPrivacy, updateMyProfile, uploadAvatar } from "./mutations";

export const profileController = new Elysia({
name: "Profile.Controller",
Expand All @@ -24,6 +24,13 @@ export const profileController = new Elysia({
200: "profile.Profile",
},
})
.patch("/me/privacy", ({ user, body }) => updateMyPrivacy(user, body), {
auth: true,
body: "profile.PrivacyBody",
response: {
200: "profile.Profile",
},
})
.get(
"/me/activity",
({ user, query }) => getMyProfileActivity(user.id, query.limit, query.offset),
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/modules/recommendations/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const availableEntry = t.Composite([
t.Object({
providers: t.Array(providerRef),
isShort: t.Boolean(),
// How many followed profiles reviewed/rated/watchlisted this title, and a
// short human reason. 0 / null when no followed profile has engaged with it.
friendSignalCount: t.Number(),
friendReason: t.Nullable(t.String()),
}),
]);

Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/modules/recommendations/queries/available-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { normalizeSummary, trending } from "../../tmdb/client";
import { getMediaDetail } from "../../tmdb/queries/media-detail";
import type { MediaFilter, TmdbMovieSummary } from "../../tmdb";
import type { AvailableEntryDto } from "../model";
import { computeFriendSignals, getFolloweeIds } from "./friend-signal";

// Cap on how many cache-cold titles we warm per request, so a fresh feed
// populates itself over a few loads without fanning out to TMDB unbounded.
Expand Down Expand Up @@ -227,8 +228,28 @@ export async function getAvailableFeed(
...normalizeSummary(candidate.summary, candidate.summary.media_type),
providers: matching,
isShort: passesShortFilter(candidate),
friendSignalCount: 0,
friendReason: null,
});
}

return entries.sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0));
// Annotate each surviving entry with how many followed profiles engaged with it,
// then float social matches above equal non-social ones.
const followeeIds = await getFolloweeIds(userId);
const signals = await computeFriendSignals(
followeeIds,
entries.map((entry) => ({ id: entry.id, media_type: entry.media_type })),
);
for (const entry of entries) {
const signal = signals.get(`${entry.media_type}:${entry.id}`);
if (signal) {
entry.friendSignalCount = signal.count;
entry.friendReason = signal.reason;
}
}

return entries.sort(
(a, b) =>
b.friendSignalCount - a.friendSignalCount || (b.popularity ?? 0) - (a.popularity ?? 0),
);
}
95 changes: 95 additions & 0 deletions apps/api/src/modules/recommendations/queries/friend-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { db } from "@seen/db";
import { profiles, reviews, watchlist } from "@seen/db/schema";
import { and, inArray } from "@seen/db/orm";

import type { MediaType } from "../../tmdb";

// Reuse the social module's follow lookup rather than duplicating the query.
export { getFolloweeIds } from "../../social/activity";

type CandidateRef = { id: number; media_type: MediaType };
export type SignalAction = "review" | "watchlist";

export type FriendSignal = { count: number; reason: string | null };

export function buildReason(entries: { username: string; action: SignalAction }[]): string | null {
const primary = entries.find((entry) => entry.action === "review") ?? entries[0];
if (!primary) return null;
if (entries.length === 1) {
return primary.action === "review"
? `@${primary.username} reviewed this`
: `@${primary.username} added this to their watchlist`;
}
const others = entries.length - 1;
return `@${primary.username} and ${others} other${others > 1 ? "s" : ""}`;
}

// For each candidate, how many of the user's followees engaged with it (reviewed
// or kept a non-private watchlist entry) plus a short reason. As a follower the
// viewer can see followers/public content, so only `private` watchlist rows are
// excluded — never a leak of hidden activity.
export async function computeFriendSignals(
followeeIds: string[],
candidates: CandidateRef[],
): Promise<Map<string, FriendSignal>> {
const result = new Map<string, FriendSignal>();
if (followeeIds.length === 0 || candidates.length === 0) return result;

const tmdbIds = [...new Set(candidates.map((candidate) => candidate.id))];
const candidateKeys = new Set(
candidates.map((candidate) => `${candidate.media_type}:${candidate.id}`),
);

const [reviewRows, watchRows] = await Promise.all([
db
.select({ userId: reviews.userId, tmdbId: reviews.tmdbId, mediaType: reviews.mediaType })
.from(reviews)
.where(and(inArray(reviews.userId, followeeIds), inArray(reviews.tmdbId, tmdbIds))),
db
.select({
userId: watchlist.userId,
tmdbId: watchlist.tmdbId,
mediaType: watchlist.mediaType,
})
.from(watchlist)
.where(
and(
inArray(watchlist.userId, followeeIds),
inArray(watchlist.tmdbId, tmdbIds),
inArray(watchlist.visibility, ["followers", "public"]),
),
),
]);

// key -> (followeeId -> action). A review outweighs a watchlist entry.
const byKey = new Map<string, Map<string, SignalAction>>();
const record = (key: string, userId: string, action: SignalAction) => {
if (!candidateKeys.has(key)) return;
let users = byKey.get(key);
if (!users) {
users = new Map();
byKey.set(key, users);
}
if (users.get(userId) !== "review") users.set(userId, action);
};
for (const row of reviewRows) record(`${row.mediaType}:${row.tmdbId}`, row.userId, "review");
for (const row of watchRows) record(`${row.mediaType}:${row.tmdbId}`, row.userId, "watchlist");

const userIds = new Set<string>();
for (const users of byKey.values()) for (const id of users.keys()) userIds.add(id);
const nameRows = userIds.size
? await db
.select({ id: profiles.id, username: profiles.username })
.from(profiles)
.where(inArray(profiles.id, [...userIds]))
: [];
const usernames = new Map(nameRows.map((row) => [row.id, row.username]));

for (const [key, users] of byKey) {
const entries = [...users.entries()]
.map(([id, action]) => ({ username: usernames.get(id) ?? "someone", action }))
.sort((left, right) => left.username.localeCompare(right.username));
result.set(key, { count: entries.length, reason: buildReason(entries) });
}
return result;
}
Loading
Loading