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
23 changes: 23 additions & 0 deletions packages/backend/src/interfaces/repositories/INoteRepository.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import type { Note } from "shared";

/**
* Viewer-aware visibility filter for note list queries.
*
* When supplied, repositories only return notes the viewer is allowed to see
* (see `lib/noteVisibility.ts` for the rules):
* - `public` / `home`: always
* - `followers`: author, followers of the author (accepted follow),
* mentioned users, or the author of the replied-to note
* - `specified`: author or recipients (stored in `mentions`)
*
* A `viewerId` of `null` represents an unauthenticated viewer and restricts
* results to `public` / `home` notes.
*/
export interface NoteVisibilityFilter {
/** Viewing user ID, or null for unauthenticated viewers */
viewerId: string | null;
}

export interface TimelineOptions {
limit?: number;
sinceId?: string;
untilId?: string;
userIds?: string[];
/**
* Restrict results to notes visible to this viewer.
* When omitted, no visibility filtering is applied (trusted/internal use only).
*/
visibleTo?: NoteVisibilityFilter;
}

/**
Expand Down
142 changes: 142 additions & 0 deletions packages/backend/src/lib/noteVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Note Visibility Rules
*
* Central implementation of Misskey-compatible note visibility semantics.
* Every code path that returns note content to a viewer (API, ActivityPub,
* oEmbed, streaming) must go through these helpers (or the equivalent
* repository-level `visibleTo` filter) so that followers-only and
* specified (direct) notes are never exposed to unauthorized viewers.
*
* Rules:
* - `public` / `home`: visible to anyone, including unauthenticated viewers
* - `followers`: visible to the author, users who follow the author,
* users mentioned in the note, and the author of the note being replied to
* - `specified`: visible to the author and the recipients
* (stored in the note's `mentions` array, which holds `visibleUserIds`)
*
* @module lib/noteVisibility
*/

import type { IFollowRepository } from "../interfaces/repositories/IFollowRepository.js";
import type { INoteRepository } from "../interfaces/repositories/INoteRepository.js";

/**
* Minimal note shape required to evaluate visibility.
*/
export interface NoteVisibilityFields {
/** Author user ID */
userId: string;
/** Visibility level (public, home, followers, specified) */
visibility: string;
/** Mentioned user IDs (for `specified` notes, the recipient user IDs) */
mentions?: string[] | null;
/** ID of the note this note replies to */
replyId?: string | null;
}

/**
* Additional relationship facts between the viewer and the note,
* used to evaluate `followers` visibility.
*/
export interface NoteViewerRelation {
/** Whether the viewer follows the note author */
isFollowing: boolean;
/** Whether the viewer is the author of the note being replied to */
isReplyTargetAuthor?: boolean;
}

/**
* Check whether a note is visible to everyone (including unauthenticated viewers).
*
* @param note - Note to check
* @returns True for `public` and `home` notes
*/
export function isPubliclyVisible(note: Pick<NoteVisibilityFields, "visibility">): boolean {
return note.visibility === "public" || note.visibility === "home";
}

/**
* Pure visibility check for a note and a viewer.
*
* @param note - Note to check
* @param viewerId - Viewing user ID, or null for unauthenticated viewers
* @param relation - Whether the viewer follows the author (boolean shorthand),
* or a full relation object
* @returns True if the viewer may see the note
*
* @example
* ```typescript
* const isFollowing = await followRepository.exists(viewerId, note.userId);
* if (!canViewNote(note, viewerId, isFollowing)) {
* return c.json({ error: "Note not found" }, 404);
* }
* ```
*/
export function canViewNote(
note: NoteVisibilityFields,
viewerId: string | null | undefined,
relation: boolean | NoteViewerRelation = false,
): boolean {
if (isPubliclyVisible(note)) return true;
if (!viewerId) return false;
if (note.userId === viewerId) return true;

const rel: NoteViewerRelation =
typeof relation === "boolean" ? { isFollowing: relation } : relation;
const isMentioned = (note.mentions ?? []).includes(viewerId);

if (note.visibility === "specified") {
return isMentioned;
}

if (note.visibility === "followers") {
return rel.isFollowing || isMentioned || rel.isReplyTargetAuthor === true;
}

// Unknown visibility values are treated as private
return false;
}

/**
* Dependencies required by {@link isNoteVisibleTo}.
*/
export interface NoteVisibilityDeps {
/** Follow repository used to check whether the viewer follows the author */
followRepository: Pick<IFollowRepository, "exists">;
/** Optional note repository used to resolve the reply target author */
noteRepository?: Pick<INoteRepository, "findById">;
}

/**
* Asynchronous visibility check that resolves relationship facts on demand.
*
* Only performs database lookups when the result cannot be determined
* from the note itself (i.e. `followers` notes viewed by a non-author
* who is not mentioned).
*
* @param note - Note to check
* @param viewerId - Viewing user ID, or null for unauthenticated viewers
* @param deps - Repositories used to resolve relationships
* @returns True if the viewer may see the note
*/
export async function isNoteVisibleTo(
note: NoteVisibilityFields,
viewerId: string | null | undefined,
deps: NoteVisibilityDeps,
): Promise<boolean> {
if (canViewNote(note, viewerId, false)) return true;
if (!viewerId || note.visibility !== "followers") return false;

if (await deps.followRepository.exists(viewerId, note.userId)) {
return true;
}

if (note.replyId && deps.noteRepository) {
const parent = await deps.noteRepository.findById(note.replyId);
if (parent && parent.userId === viewerId) {
return true;
}
}

return false;
}
72 changes: 70 additions & 2 deletions packages/backend/src/repositories/mysql/MysqlNoteRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
* @module repositories/mysql/MysqlNoteRepository
*/

import { eq, and, or, isNull, inArray, desc, gt, lt, sql } from "drizzle-orm";
import { eq, and, or, isNull, inArray, desc, gt, lt, sql, exists, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/mysql-core";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { notes, users } from "../../db/schema/mysql.js";
import { follows, notes, users } from "../../db/schema/mysql.js";
import type * as mysqlSchema from "../../db/schema/mysql.js";
import type {
INoteRepository,
NoteCreateInput,
NoteVisibilityFilter,
TimelineOptions,
} from "../../interfaces/repositories/INoteRepository.js";
import type { Note } from "shared";
Expand All @@ -22,6 +24,52 @@ type MysqlDatabase = MySql2Database<typeof mysqlSchema>;
export class MysqlNoteRepository implements INoteRepository {
constructor(private db: MysqlDatabase) {}

/**
* Build a viewer-aware visibility condition (Misskey semantics).
*
* - public / home: visible to anyone
* - followers: author, followers of the author, mentioned users,
* or the author of the replied-to note
* - specified: author or recipients (stored in `mentions`)
*
* @param filter - Viewer filter; `viewerId: null` means unauthenticated
* @returns SQL condition restricting notes to those visible to the viewer
*/
private buildVisibilityCondition(filter: NoteVisibilityFilter): SQL {
const publicCondition = inArray(notes.visibility, ["public", "home"]);
const viewerId = filter.viewerId;
if (!viewerId) {
return publicCondition;
}

const isMentioned = sql`JSON_CONTAINS(${notes.mentions}, ${JSON.stringify(viewerId)})`;
const replyParent = alias(notes, "visibility_reply_parent");

return or(
publicCondition,
eq(notes.userId, viewerId),
and(
eq(notes.visibility, "followers"),
or(
exists(
this.db
.select({ one: sql`1` })
.from(follows)
.where(and(eq(follows.followerId, viewerId), eq(follows.followeeId, notes.userId))),
),
isMentioned,
exists(
this.db
.select({ one: sql`1` })
.from(replyParent)
.where(and(eq(replyParent.id, notes.replyId), eq(replyParent.userId, viewerId))),
),
),
),
and(eq(notes.visibility, "specified"), isMentioned),
)!;
}

async create(note: NoteCreateInput): Promise<Note> {
const now = new Date();
await this.db.insert(notes).values({
Expand Down Expand Up @@ -142,6 +190,10 @@ export class MysqlNoteRepository implements INoteRepository {

const conditions = [inArray(notes.userId, userIds), eq(notes.isDeleted, false)];

if (options.visibleTo) {
conditions.push(this.buildVisibilityCondition(options.visibleTo));
}

if (sinceId) {
conditions.push(gt(notes.id, sinceId));
}
Expand Down Expand Up @@ -192,6 +244,10 @@ export class MysqlNoteRepository implements INoteRepository {
conditions.push(...localConditions);
}

if (options.visibleTo) {
conditions.push(this.buildVisibilityCondition(options.visibleTo));
}

if (sinceId) {
conditions.push(gt(notes.id, sinceId));
}
Expand Down Expand Up @@ -272,6 +328,10 @@ export class MysqlNoteRepository implements INoteRepository {

const conditions = [eq(notes.userId, userId), eq(notes.isDeleted, false)];

if (options.visibleTo) {
conditions.push(this.buildVisibilityCondition(options.visibleTo));
}

if (sinceId) {
conditions.push(gt(notes.id, sinceId));
}
Expand Down Expand Up @@ -310,6 +370,10 @@ export class MysqlNoteRepository implements INoteRepository {

const conditions = [eq(notes.replyId, noteId), eq(notes.isDeleted, false)];

if (options.visibleTo) {
conditions.push(this.buildVisibilityCondition(options.visibleTo));
}

if (sinceId) {
conditions.push(gt(notes.id, sinceId));
}
Expand Down Expand Up @@ -348,6 +412,10 @@ export class MysqlNoteRepository implements INoteRepository {

const conditions = [eq(notes.renoteId, noteId), eq(notes.isDeleted, false)];

if (options.visibleTo) {
conditions.push(this.buildVisibilityCondition(options.visibleTo));
}

if (sinceId) {
conditions.push(gt(notes.id, sinceId));
}
Expand Down
Loading
Loading