Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -0,0 +1,35 @@
import { Blockings } from "../../../models/index.js";
import { Brackets } from "typeorm";
// ここでいうBlockedは被Blockedの意
export function generateBlockedUserQuery(q, me) {
const blockingQuery = Blockings.createQueryBuilder("blocking").select("blocking.blockerId").where("blocking.blockeeId = :blockeeId", {
blockeeId: me.id
}).andWhere("blocking.groupId IS NULL");
const groupBlockingQuery = Blockings.createQueryBuilder("blocking").select("blocking.groupId").where("blocking.blockeeId = :groupBlockeeId", {
groupBlockeeId: me.id
}).andWhere("blocking.groupId IS NOT NULL");
// 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない
q.andWhere(`note.userId NOT IN (${blockingQuery.getQuery()})`).andWhere(new Brackets((qb)=>{
qb.where("note.groupId IS NULL").orWhere(`note.groupId NOT IN (${groupBlockingQuery.getQuery()})`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.replyUserId IS NULL").orWhere(`note.replyUserId NOT IN (${blockingQuery.getQuery()})`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.renoteUserId IS NULL").orWhere(`note.renoteUserId NOT IN (${blockingQuery.getQuery()})`);
}));
q.setParameters(blockingQuery.getParameters());
q.setParameters(groupBlockingQuery.getParameters());
}
export function generateBlockQueryForUsers(q, me) {
const blockingQuery = Blockings.createQueryBuilder("blocking").select("blocking.blockeeId").where("blocking.blockerId = :blockerId", {
blockerId: me.id
});
const blockedQuery = Blockings.createQueryBuilder("blocking").select("blocking.blockerId").where("blocking.blockeeId = :blockeeId", {
blockeeId: me.id
});
q.andWhere(`user.id NOT IN (${blockingQuery.getQuery()})`);
q.setParameters(blockingQuery.getParameters());
q.andWhere(`user.id NOT IN (${blockedQuery.getQuery()})`);
q.setParameters(blockedQuery.getParameters());
}
@@ -0,0 +1,18 @@
import { ChannelFollowings } from "../../../models/index.js";
import { Brackets } from "typeorm";
export function generateChannelQuery(q, me) {
if (me == null) {
q.andWhere("note.channelId IS NULL");
} else {
q.leftJoinAndSelect("note.channel", "channel");
const channelFollowingQuery = ChannelFollowings.createQueryBuilder("channelFollowing").select("channelFollowing.followeeId").where("channelFollowing.followerId = :followerId", {
followerId: me.id
});
q.andWhere(new Brackets((qb)=>{
qb// チャンネルのノートではない
.where("note.channelId IS NULL")// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${channelFollowingQuery.getQuery()})`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
@@ -0,0 +1,3 @@
export function generateExcludeMemorietQuery(query) {
query.andWhere(`NOT EXISTS (SELECT 1 FROM "memoriet" "memoriet_exclude" WHERE "memoriet_exclude"."noteId" = note.id)`);
}
@@ -0,0 +1,40 @@
import { Brackets } from "typeorm";
import { Followings, Notes } from "../../../models/index.js";
import { Cache } from "../../../misc/cache.js";
import { apiLogger } from "../logger.js";
export const cache = new Cache("homeTlQueryData", 60 * 60 * 24);
const cutoff = 250; // 250 posts in the last 7 days, constant determined by comparing benchmarks for cutoff values between 100 and 2500
const logger = apiLogger.createSubLogger("heuristics");
export async function generateFollowingQuery(q, me) {
const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :meId");
const heuristic = await cache.fetch(me.id, async ()=>{
let curr = new Date();
let prev = new Date();
prev.setDate(prev.getDate() - 7);
return Notes.createQueryBuilder('note').where(`note.createdAt > :prev`, {
prev
}).andWhere(`note.createdAt < :curr`, {
curr
}).andWhere(new Brackets((qb)=>{
qb.where(`note.userId IN (${followingQuery.getQuery()})`);
qb.orWhere(`note.userId = :meId`, {
meId: me.id
});
})).getCount().then((res)=>{
logger.info(`Calculating heuristics for user ${me.id} took ${new Date().getTime() - curr.getTime()}ms`);
return res;
});
});
const shouldUseUnion = heuristic < cutoff;
q.andWhere(new Brackets((qb)=>{
if (shouldUseUnion) {
qb.where(`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`);
} else {
qb.where(`note.userId = :meId`);
qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`);
}
}));
q.setParameters({
meId: me.id
});
}
@@ -0,0 +1,234 @@
import { Brackets } from "typeorm";
import { sqlLikeEscape } from "../../../misc/sql-like-escape.js";
import { sqlRegexEscape } from "../../../misc/sql-regex-escape.js";
import { Followings, NoteFavorites, NoteReactions, Users } from "../../../models/index.js";
const filters = {
"from": fromFilter,
"-from": fromFilterInverse,
"mention": mentionFilter,
"-mention": mentionFilterInverse,
"reply": replyFilter,
"-reply": replyFilterInverse,
"to": replyFilter,
"-to": replyFilterInverse,
"before": beforeFilter,
"until": beforeFilter,
"after": afterFilter,
"since": afterFilter,
"instance": instanceFilter,
"-instance": instanceFilterInverse,
"domain": instanceFilter,
"-domain": instanceFilterInverse,
"host": instanceFilter,
"-host": instanceFilterInverse,
"filter": miscFilter,
"-filter": miscFilterInverse,
"in": inFilter,
"-in": inFilterInverse,
"has": attachmentFilter
};
export function generateFtsQuery(query, q) {
const components = q.trim().split(" ");
const terms = [];
let finalTerms = [];
let counter = 0;
let caseSensitive = false;
let matchWords = false;
for (const component of components){
const split = component.split(":");
if (split.length > 1 && filters[split[0]] !== undefined) filters[split[0]](query, split.slice(1).join(":"), counter++);
else if (split.length > 1 && (split[0] === "search" || split[0] === "match")) matchWords = split[1] === 'word' || split[1] === 'words';
else if (split.length > 1 && split[0] === "case") caseSensitive = split[1] === 'sensitive';
else terms.push(component);
}
let idx = 0;
let state = 'idle';
for(let i = 0; i < terms.length; i++){
if (state === 'idle') {
if (terms[i].startsWith('"') && terms[i].endsWith('"') || terms[i].startsWith('(') && terms[i].endsWith(')')) {
finalTerms.push(trimStartAndEnd(terms[i]));
} else if (terms[i].startsWith('"')) {
idx = i;
state = 'quote';
} else if (terms[i].startsWith('(')) {
idx = i;
state = 'parenthesis';
} else {
finalTerms.push(terms[i]);
}
} else if (state === 'quote' && terms[i].endsWith('"')) {
finalTerms.push(extractToken(terms, idx, i));
state = 'idle';
} else if (state === 'parenthesis' && terms[i].endsWith(')')) {
query.andWhere(new Brackets((qb)=>{
for (const term of extractToken(terms, idx, i).split(' OR ')){
const id = counter++;
appendSearchQuery(term, 'or', query, qb, id, term.startsWith('-'), matchWords, caseSensitive);
}
}));
state = 'idle';
}
}
if (state != "idle") {
finalTerms.push(...extractToken(terms, idx, terms.length - 1, false).substring(1).split(' '));
}
for (const term of finalTerms){
const id = counter++;
appendSearchQuery(term, 'and', query, query, id, term.startsWith('-'), matchWords, caseSensitive);
}
}
function fromFilter(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function fromFilterInverse(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function mentionFilter(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true
});
query.andWhere(`note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[]`);
query.setParameters(userQuery.getParameters());
}
function mentionFilterInverse(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true
});
query.andWhere(`NOT (note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[])`);
query.setParameters(userQuery.getParameters());
}
function replyFilter(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function replyFilterInverse(query, filter, id) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function beforeFilter(query, filter) {
query.andWhere('note.createdAt < :before', {
before: filter
});
}
function afterFilter(query, filter) {
query.andWhere('note.createdAt > :after', {
after: filter
});
}
function instanceFilter(query, filter, id) {
if (filter === 'local') {
query.andWhere(`note.userHost IS NULL`);
} else {
query.andWhere(`note.userHost = :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function instanceFilterInverse(query, filter, id) {
if (filter === 'local') {
query.andWhere(`note.userHost IS NOT NULL`);
} else {
query.andWhere(`note.userHost <> :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function miscFilter(query, filter) {
let subQuery = null;
if (filter === 'followers') {
subQuery = Followings.createQueryBuilder('following').select('following.followerId').where('following.followeeId = :meId');
} else if (filter === 'following') {
subQuery = Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId');
} else if (filter === 'replies' || filter === 'reply') {
query.andWhere('note.replyId IS NOT NULL');
} else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') {
query.andWhere('note.renoteId IS NOT NULL');
}
if (subQuery !== null) query.andWhere(`note.userId IN (${subQuery.getQuery()})`);
}
function miscFilterInverse(query, filter) {
let subQuery = null;
if (filter === 'followers') {
subQuery = Followings.createQueryBuilder('following').select('following.followerId').where('following.followeeId = :meId');
} else if (filter === 'following') {
subQuery = Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId');
} else if (filter === 'replies' || filter === 'reply') {
query.andWhere('note.replyId IS NULL');
} else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') {
query.andWhere('note.renoteId IS NULL');
}
if (subQuery !== null) query.andWhere(`note.userId NOT IN (${subQuery.getQuery()})`);
}
function inFilter(query, filter) {
let subQuery = null;
if (filter === 'bookmarks') {
subQuery = NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId');
} else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') {
subQuery = NoteReactions.createQueryBuilder('react').select('react.noteId').where('react.userId = :meId');
}
if (subQuery !== null) query.andWhere(`note.id IN (${subQuery.getQuery()})`);
}
function inFilterInverse(query, filter) {
let subQuery = null;
if (filter === 'bookmarks') {
subQuery = NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId');
} else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') {
subQuery = NoteReactions.createQueryBuilder('react').select('react.noteId').where('react.userId = :meId');
}
if (subQuery !== null) query.andWhere(`note.id NOT IN (${subQuery.getQuery()})`);
}
function attachmentFilter(query, filter) {
switch(filter){
case 'image':
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%image/%'`);
break;
case 'video':
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%video/%'`);
break;
case 'audio':
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%audio/%'`);
break;
case 'file':
query.andWhere(`note."attachedFileTypes" <> '{}'`);
query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%image/%')`);
query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%video/%')`);
query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%audio/%')`);
break;
default:
break;
}
}
function generateUserSubquery(filter, id) {
if (filter.startsWith('@')) filter = filter.substring(1);
const split = filter.split('@');
const query = Users.createQueryBuilder('user').select('user.id').where(`user.usernameLower = :user_${id}`).andWhere(`user.host ${split[1] !== undefined ? `= :host_${id}` : 'IS NULL'}`);
query.setParameter(`user_${id}`, split[0].toLowerCase());
if (split[1] !== undefined) query.setParameter(`host_${id}`, split[1].toLowerCase());
return query;
}
function extractToken(array, start, end, trim = true) {
const slice = array.slice(start, end + 1).join(" ");
return trim ? trimStartAndEnd(slice) : slice;
}
function trimStartAndEnd(str) {
return str.substring(1, str.length - 1);
}
function appendSearchQuery(term, mode, query, qb, id, negate, matchWords, caseSensitive) {
const sql = `note.text ${getSearchMatchOperator(negate, matchWords, caseSensitive)} :q_${id}`;
if (mode === 'and') qb.andWhere(sql);
else if (mode === 'or') qb.orWhere(sql);
query.setParameter(`q_${id}`, escapeSqlSearchParam(term.substring(negate ? 1 : 0), matchWords));
}
function getSearchMatchOperator(negate, matchWords, caseSensitive) {
const negatePrefix = matchWords ? '!' : 'NOT ';
return `${negate ? negatePrefix : ''}${matchWords ? caseSensitive ? '~' : '~*' : caseSensitive ? 'LIKE' : 'ILIKE'}`;
}
function escapeSqlSearchParam(param, matchWords) {
return matchWords ? `\\y${sqlRegexEscape(param)}\\y` : `%${sqlLikeEscape(param)}%`;
}
@@ -0,0 +1,13 @@
import { Brackets } from "typeorm";
import { UserListJoinings, UserLists } from "../../../models/index.js";
export function generateListQuery(q, me) {
const listQuery = UserLists.createQueryBuilder("list").select("list.id").where("list.hideFromHomeTl = TRUE").andWhere("list.userId = :meId");
const memberQuery = UserListJoinings.createQueryBuilder("member").select("member.userId").where(`member.userListId IN (${listQuery.getQuery()})`);
q.andWhere(new Brackets((qb)=>{
qb.where(`note.userId = :meId`);
qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`);
}));
q.setParameters({
meId: me.id
});
}
@@ -0,0 +1,18 @@
import { Brackets } from "typeorm";
export function shouldHideEUsersFor(viewer) {
return !!viewer && !viewer.isAdmin && !viewer.isModerator && (viewer.minorBadges ?? []).some((badge)=>badge === "K" || badge === "T");
}
export function generateMinorBadgeUserVisibilityQuery(q, viewer, alias = "user") {
if (!shouldHideEUsersFor(viewer)) return;
q.andWhere(new Brackets((qb)=>{
qb.where(`${alias}.id = :minorBadgeViewerId`).orWhere(`NOT ('E' = ANY(${alias}."minorBadges"))`);
}));
q.setParameter("minorBadgeViewerId", viewer.id);
}
export function generateMinorBadgeNoteVisibilityQuery(q, viewer, noteAlias = "note") {
if (!shouldHideEUsersFor(viewer)) return;
q.andWhere(new Brackets((qb)=>{
qb.where(`${noteAlias}."userId" = :minorBadgeViewerId`).orWhere(`${noteAlias}."userId" NOT IN (` + `SELECT "id" FROM "user" WHERE 'E' = ANY("minorBadges")` + `)`);
}));
q.setParameter("minorBadgeViewerId", viewer.id);
}
@@ -0,0 +1,12 @@
import { NoteThreadMutings } from "../../../models/index.js";
import { Brackets } from "typeorm";
export function generateMutedNoteThreadQuery(q, me) {
const mutedQuery = NoteThreadMutings.createQueryBuilder("threadMuted").select("threadMuted.threadId").where("threadMuted.userId = :userId", {
userId: me.id
});
q.andWhere(`note.id NOT IN (${mutedQuery.getQuery()})`);
q.andWhere(new Brackets((qb)=>{
qb.where("note.threadId IS NULL").orWhere(`note.threadId NOT IN (${mutedQuery.getQuery()})`);
}));
q.setParameters(mutedQuery.getParameters());
}
@@ -0,0 +1,39 @@
import { Brackets } from "typeorm";
import { Mutings, UserProfiles } from "../../../models/index.js";
export function generateMutedUserQuery(q, me, exclude) {
const mutingQuery = Mutings.createQueryBuilder("muting").select("muting.muteeId").where("muting.muterId = :muterId", {
muterId: me.id
});
if (exclude) {
mutingQuery.andWhere("muting.muteeId != :excludeId", {
excludeId: exclude.id
});
}
const mutingInstanceQuery = UserProfiles.createQueryBuilder("user_profile").select("user_profile.mutedInstances").where("user_profile.userId = :muterId", {
muterId: me.id
});
// 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
q.andWhere(`note.userId NOT IN (${mutingQuery.getQuery()})`).andWhere(new Brackets((qb)=>{
qb.where("note.replyUserId IS NULL").orWhere(`note.replyUserId NOT IN (${mutingQuery.getQuery()})`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.renoteUserId IS NULL").orWhere(`note.renoteUserId NOT IN (${mutingQuery.getQuery()})`);
}))// mute instances
.andWhere(new Brackets((qb)=>{
qb.andWhere("note.userHost IS NULL").orWhere(`NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.userHost)`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.replyUserHost IS NULL").orWhere(`NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.replyUserHost)`);
})).andWhere(new Brackets((qb)=>{
qb.where("note.renoteUserHost IS NULL").orWhere(`NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
}
export function generateMutedUserQueryForUsers(q, me) {
const mutingQuery = Mutings.createQueryBuilder("muting").select("muting.muteeId").where("muting.muterId = :muterId", {
muterId: me.id
});
q.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`);
q.setParameters(mutingQuery.getParameters());
}
@@ -0,0 +1,2 @@
import { secureRndstr } from "../../../misc/secure-rndstr.js";
export default (()=>secureRndstr(16));
@@ -0,0 +1,28 @@
import { Brackets } from "typeorm";
export function generateRepliesQuery(q, withReplies, me) {
if (me == null) {
q.andWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NULL") // 返信ではない
.orWhere(new Brackets((qb)=>{
qb.where(// 返信だけど投稿者自身への返信
"note.replyId IS NOT NULL").andWhere("note.replyUserId = note.userId");
}));
}));
} else if (!withReplies) {
q.andWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NULL") // 返信ではない
.orWhere("note.replyUserId = :meId", {
meId: me.id
}) // 返信だけど自分のノートへの返信
.orWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NOT NULL") // 返信だけど自分の行った返信
.andWhere("note.userId = :meId", {
meId: me.id
});
})).orWhere(new Brackets((qb)=>{
qb.where("note.replyId IS NOT NULL") // 返信だけど投稿者自身への返信
.andWhere("note.replyUserId = note.userId");
}));
}));
}
}
@@ -0,0 +1,43 @@
import { Followings } from "../../../models/index.js";
import { Brackets } from "typeorm";
import { generateMinorBadgeNoteVisibilityQuery } from "./generate-minor-badge-visibility-query.js";
export function generateVisibilityQuery(q, me, options) {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) {
q.andWhere(new Brackets((qb)=>{
qb.where(`note.visibility = 'public'`).orWhere(`note.visibility = 'home'`);
})).andWhere('note.localOnly = FALSE');
} else {
const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :meId");
q.andWhere(new Brackets((qb)=>{
qb// 公開投稿である
.where(new Brackets((qb)=>{
qb.where(`note.visibility = 'public'`).orWhere(`note.visibility = 'home'`);
}))// または 自分自身
.orWhere("note.userId = :meId")// または 自分宛て
.orWhere(":meId = ANY(note.visibleUserIds)").orWhere(":meId = ANY(note.mentions)").orWhere(new Brackets((qb)=>{
qb// または フォロワー宛ての投稿であり、
.where(`note.visibility = 'followers'`).andWhere(new Brackets((qb)=>{
qb// 自分がフォロワーである
.where(`note.userId IN (${followingQuery.getQuery()})`)// または 自分の投稿へのリプライ
.orWhere("note.replyUserId = :meId");
}));
}));
}));
q.andWhere(new Brackets((qb)=>{
qb.where(`note.visibility != 'hidden'`).orWhere(`note.userId = :meId`);
}));
q.setParameters({
meId: me.id
});
}
if (!options?.allowAdservice) {
q.andWhere(new Brackets((qb)=>{
qb.where(`NOT ('adservice' = ANY(note.tags))`);
if (me) {
qb.orWhere("note.userId = :meId");
}
}));
}
generateMinorBadgeNoteVisibilityQuery(q, me);
}
@@ -0,0 +1,15 @@
import { Brackets } from "typeorm";
import { RenoteMutings } from "../../../models/index.js";
export function generateMutedUserRenotesQueryForNotes(q, me) {
const mutingQuery = RenoteMutings.createQueryBuilder("renote_muting").select("renote_muting.muteeId").where("renote_muting.muterId = :muterId", {
muterId: me.id
});
q.andWhere(new Brackets((qb)=>{
qb.where(new Brackets((qb)=>{
qb.where("note.renoteId IS NOT NULL");
qb.andWhere("note.text IS NULL");
qb.andWhere(`note.userId NOT IN (${mutingQuery.getQuery()})`);
})).orWhere("note.renoteId IS NULL").orWhere("note.text IS NOT NULL");
}));
q.setParameters(mutingQuery.getParameters());
}
@@ -0,0 +1,14 @@
import { UserGroupJoinings, UserGroups } from "../../../models/index.js";
export async function getGroupActor(groupId, user) {
if (groupId == null) return null;
const group = await UserGroups.findOneBy({
id: groupId
});
if (group == null) return null;
if (group.userId === user.id) return group;
const joining = await UserGroupJoinings.findOneBy({
userId: user.id,
userGroupId: group.id
});
return joining == null ? null : group;
}
@@ -0,0 +1,45 @@
import { IdentifiableError } from "../../../misc/identifiable-error.js";
import { Notes, Users } from "../../../models/index.js";
import { generateVisibilityQuery } from "./generate-visibility-query.js";
/**
* Get note for API processing, taking into account visibility.
*/ export async function getNote(noteId, me, options) {
const query = Notes.createQueryBuilder("note").where("note.id = :id", {
id: noteId
});
generateVisibilityQuery(query, me, options);
const note = await query.getOne();
if (note == null || me == null && note.localOnly) {
throw new IdentifiableError("9725d0ce-ba28-4dde-95a7-2cbb2c15de24", "No such note.");
}
return note;
}
/**
* Get user for API processing
*/ export async function getUser(userId) {
const user = await Users.findOneBy({
id: userId
});
if (user == null) {
throw new IdentifiableError("15348ddd-432d-49c2-8a5a-8069753becff", "No such user.");
}
return user;
}
/**
* Get remote user for API processing
*/ export async function getRemoteUser(userId) {
const user = await getUser(userId);
if (!Users.isRemoteUser(user)) {
throw new Error("user is not a remote user");
}
return user;
}
/**
* Get local user for API processing
*/ export async function getLocalUser(userId) {
const user = await getUser(userId);
if (!Users.isLocalUser(user)) {
throw new Error("user is not a local user");
}
return user;
}
@@ -0,0 +1,37 @@
import rndstr from "rndstr";
import { Notes, UserProfiles, NoteReactions } from "../../../models/index.js";
import { generateMutedUserQuery } from "./generate-muted-user-query.js";
import { generateBlockedUserQuery } from "./generate-block-query.js";
// TODO: リアクション、Renote、返信などをしたノートは除外する
export async function injectFeatured(timeline, user) {
if (timeline.length < 5) return;
if (user) {
const profile = await UserProfiles.findOneByOrFail({
userId: user.id
});
if (!profile.injectFeaturedNote) return;
}
const max = 30;
const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで
const query = Notes.createQueryBuilder("note").addSelect("note.score").where("note.userHost IS NULL").andWhere("note.score > 0").andWhere("note.createdAt > :date", {
date: new Date(Date.now() - day)
}).andWhere(`note.visibility = 'public'`).innerJoinAndSelect("note.user", "user");
if (user) {
query.andWhere("note.userId != :userId", {
userId: user.id
});
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
const reactionQuery = NoteReactions.createQueryBuilder("reaction").select("reaction.noteId").where("reaction.userId = :userId", {
userId: user.id
});
query.andWhere(`note.id NOT IN (${reactionQuery.getQuery()})`);
}
const notes = await query.orderBy("note.score", "DESC").take(max).getMany();
if (notes.length === 0) return;
// Pick random one
const featured = notes[Math.floor(Math.random() * notes.length)];
featured._featuredId_ = rndstr("a-z0-9", 8);
// Inject featured
timeline.splice(3, 0, featured);
}
@@ -0,0 +1,53 @@
import rndstr from "rndstr";
import { PromoReads, PromoNotes, Notes, Users } from "../../../models/index.js";
import { shouldHideEUsersFor } from "./generate-minor-badge-visibility-query.js";
import { readPromo } from "./read-promo.js";
const systemTags = new Set([
"adservice",
"videoservice",
"audioservice",
"imageservice",
"karaokeservice",
"lua4frozen"
]);
function isExplicitAd(note) {
return note.tags.includes("explicit") || note.user?.minorBadges?.includes("E") === true;
}
export async function injectPromo(timeline, user, preferredTag) {
// TODO: readやexpireフィルタはクエリ側でやる
const readDay = new Date().toISOString().slice(0, 10);
const reads = user ? await PromoReads.findBy({
userId: user.id,
readDay
}) : [];
let promos = await PromoNotes.find();
promos = promos.filter((n)=>n.expiresAt.getTime() > Date.now());
promos = promos.filter((n)=>n.remainingCredits > 0);
promos = promos.filter((n)=>!reads.map((r)=>r.noteId).includes(n.noteId));
if (promos.length === 0) return;
const candidates = [];
for (const promo of promos){
const note = await Notes.findOneBy({
id: promo.noteId
});
if (!note?.tags.includes("adservice")) continue;
note.user = await Users.findOneByOrFail({
id: note.userId
});
if (user && shouldHideEUsersFor(user) && isExplicitAd(note)) continue;
candidates.push(note);
}
if (candidates.length === 0) return;
const normalizedPreferredTag = preferredTag?.trim().toLowerCase().replace(/^#/, "");
const priority = normalizedPreferredTag && !systemTags.has(normalizedPreferredTag) ? candidates.filter((note)=>note.tags.includes(normalizedPreferredTag)) : [];
const pool = priority.length > 0 ? priority : candidates;
// Pick random promo
const note = pool[Math.floor(Math.random() * pool.length)];
const promo = promos.find((promo)=>promo.noteId === note.id);
note._prId_ = rndstr("a-z0-9", 8);
if (user && promo) {
await readPromo(note, promo, user);
}
// Inject promo
timeline.splice(Math.min(3, timeline.length), 0, note);
}
@@ -0,0 +1 @@
export default ((token)=>token.length === 16);
@@ -0,0 +1,42 @@
export function makePaginationQuery(q, sinceId, untilId, sinceDate, untilDate) {
if (sinceId && untilId) {
q.andWhere(`${q.alias}.id > :sinceId`, {
sinceId: sinceId
});
q.andWhere(`${q.alias}.id < :untilId`, {
untilId: untilId
});
q.orderBy(`${q.alias}.id`, "DESC");
} else if (sinceId) {
q.andWhere(`${q.alias}.id > :sinceId`, {
sinceId: sinceId
});
q.orderBy(`${q.alias}.id`, "ASC");
} else if (untilId) {
q.andWhere(`${q.alias}.id < :untilId`, {
untilId: untilId
});
q.orderBy(`${q.alias}.id`, "DESC");
} else if (sinceDate && untilDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, {
sinceDate: new Date(sinceDate)
});
q.andWhere(`${q.alias}.createdAt < :untilDate`, {
untilDate: new Date(untilDate)
});
q.orderBy(`${q.alias}.createdAt`, "DESC");
} else if (sinceDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, {
sinceDate: new Date(sinceDate)
});
q.orderBy(`${q.alias}.createdAt`, "ASC");
} else if (untilDate) {
q.andWhere(`${q.alias}.createdAt < :untilDate`, {
untilDate: new Date(untilDate)
});
q.orderBy(`${q.alias}.createdAt`, "DESC");
} else {
q.orderBy(`${q.alias}.id`, "DESC");
}
return q;
}
@@ -0,0 +1,125 @@
import { publishMainStream, publishGroupMessagingStream } from "../../../services/stream.js";
import { publishMessagingStream } from "../../../services/stream.js";
import { publishMessagingIndexStream } from "../../../services/stream.js";
import { pushNotification } from "../../../services/push-notification.js";
import { MessagingMessages, UserGroupJoinings, Users } from "../../../models/index.js";
import { In } from "typeorm";
import { IdentifiableError } from "../../../misc/identifiable-error.js";
import { toArray } from "../../../prelude/array.js";
import { renderReadActivity } from "../../../remote/activitypub/renderer/read.js";
import { renderActivity } from "../../../remote/activitypub/renderer/index.js";
import { deliver } from "../../../queue/index.js";
import orderedCollection from "../../../remote/activitypub/renderer/ordered-collection.js";
/**
* Mark messages as read
*/ export async function readUserMessagingMessage(userId, otherpartyId, messageIds) {
if (messageIds.length === 0) return;
const messages = await MessagingMessages.findBy({
id: In(messageIds)
});
for (const message of messages){
if (message.recipientId !== userId) {
throw new IdentifiableError("e140a4bf-49ce-4fb6-b67c-b78dadf6b52f", "Access denied (user).");
}
}
// Update documents
await MessagingMessages.update({
id: In(messageIds),
userId: otherpartyId,
recipientId: userId,
isRead: false
}, {
isRead: true
});
// Publish event
publishMessagingStream(otherpartyId, userId, "read", messageIds);
publishMessagingIndexStream(userId, "read", messageIds);
if (!await Users.getHasUnreadMessagingMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
publishMainStream(userId, "readAllMessagingMessages");
pushNotification(userId, "readAllMessagingMessages", undefined);
} else {
// そのユーザーとのメッセージで未読がなければイベント発行
const count = await MessagingMessages.count({
where: {
userId: otherpartyId,
recipientId: userId,
isRead: false
},
take: 1
});
if (!count) {
pushNotification(userId, "readAllMessagingMessagesOfARoom", {
userId: otherpartyId
});
}
}
}
/**
* Mark messages as read
*/ export async function readGroupMessagingMessage(userId, groupId, messageIds) {
if (messageIds.length === 0) return;
// check joined
const joining = await UserGroupJoinings.findOneBy({
userId: userId,
userGroupId: groupId
});
if (joining == null) {
throw new IdentifiableError("930a270c-714a-46b2-b776-ad27276dc569", "Access denied (group).");
}
const messages = await MessagingMessages.findBy({
id: In(messageIds)
});
const reads = [];
for (const message of messages){
if (message.userId === userId) continue;
if (message.reads.includes(userId)) continue;
// Update document
await MessagingMessages.createQueryBuilder().update().set({
reads: ()=>`array_append("reads", '${joining.userId}')`
}).where("id = :id", {
id: message.id
}).execute();
reads.push(message.id);
}
// Publish event
publishGroupMessagingStream(groupId, "read", {
ids: reads,
userId: userId
});
publishMessagingIndexStream(userId, "read", reads);
if (!await Users.getHasUnreadMessagingMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
publishMainStream(userId, "readAllMessagingMessages");
pushNotification(userId, "readAllMessagingMessages", undefined);
} else {
// そのグループにおいて未読がなければイベント発行
const unreadExist = await MessagingMessages.createQueryBuilder("message").where("message.groupId = :groupId", {
groupId: groupId
}).andWhere("message.userId != :userId", {
userId: userId
}).andWhere("NOT (:userId = ANY(message.reads))", {
userId: userId
}).andWhere("message.createdAt > :joinedAt", {
joinedAt: joining.createdAt
}) // 自分が加入する前の会話については、未読扱いしない
.getOne().then((x)=>x != null);
if (!unreadExist) {
pushNotification(userId, "readAllMessagingMessagesOfARoom", {
groupId
});
}
}
}
export async function deliverReadActivity(user, recipient, messages) {
messages = toArray(messages).filter((x)=>x.uri);
const contents = messages.map((x)=>renderReadActivity(user, x));
if (contents.length > 1) {
const collection = orderedCollection(null, contents.length, undefined, undefined, contents);
deliver(user, renderActivity(collection), recipient.inbox);
} else {
for (const content of contents){
deliver(user, renderActivity(content), recipient.inbox);
}
}
}
@@ -0,0 +1,36 @@
import { In } from "typeorm";
import { publishMainStream } from "../../../services/stream.js";
import { pushNotification } from "../../../services/push-notification.js";
import { Notifications, Users } from "../../../models/index.js";
export async function readNotification(userId, notificationIds) {
if (notificationIds.length === 0) return;
// Update documents
const result = await Notifications.update({
notifieeId: userId,
id: In(notificationIds),
isRead: false
}, {
isRead: true
});
if (result.affected === 0) return;
if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId);
else return postReadNotifications(userId, notificationIds);
}
export async function readNotificationByQuery(userId, query) {
const notificationIds = await Notifications.findBy({
...query,
notifieeId: userId,
isRead: false
}).then((notifications)=>notifications.map((notification)=>notification.id));
return readNotification(userId, notificationIds);
}
function postReadAllNotifications(userId) {
publishMainStream(userId, "readAllNotifications");
return pushNotification(userId, "readAllNotifications", undefined);
}
function postReadNotifications(userId, notificationIds) {
publishMainStream(userId, "readNotifications", notificationIds);
return pushNotification(userId, "readNotifications", {
notificationIds
});
}
@@ -0,0 +1,23 @@
import { PromoNotes, PromoReads } from "../../../models/index.js";
import { genId } from "../../../misc/gen-id.js";
export async function readPromo(note, promo, user) {
if (promo.expiresAt.getTime() <= Date.now() || promo.remainingCredits <= 0 || promo.userId === user.id || note.userId === user.id || user.isAdmin || user.isModerator || user.isBot) {
return;
}
const readDay = new Date().toISOString().slice(0, 10);
const result = await PromoReads.createQueryBuilder().insert().values({
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
readDay
}).orIgnore().returning("id").execute();
if (result.raw.length === 0) {
return;
}
await PromoNotes.createQueryBuilder().update().set({
remainingCredits: ()=>`"remainingCredits" - 1`
}).where(`"noteId" = :noteId`, {
noteId: note.id
}).andWhere(`"remainingCredits" > 0`).execute();
}
@@ -0,0 +1,37 @@
import config from "../../../config/index.js";
import { Signins } from "../../../models/index.js";
import { genId } from "../../../misc/gen-id.js";
import { publishMainStream } from "../../../services/stream.js";
export default function(ctx, user, redirect = false) {
if (redirect) {
//#region Cookie
ctx.cookies.set("igi", user.token, {
path: "/",
// SEE: https://github.com/koajs/koa/issues/974
// When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header
secure: config.url.startsWith("https"),
httpOnly: false
});
//#endregion
ctx.redirect(config.url);
} else {
ctx.body = {
id: user.id,
i: user.token
};
ctx.status = 200;
}
(async ()=>{
// Append signin history
const record = await Signins.insert({
id: genId(),
createdAt: new Date(),
userId: user.id,
ip: ctx.ip,
headers: ctx.headers,
success: true
}).then((x)=>Signins.findOneByOrFail(x.identifiers[0]));
// Publish signin event
publishMainStream(user.id, "signin", await Signins.pack(record));
})();
}
@@ -0,0 +1,128 @@
import { generateKeyPair } from "node:crypto";
import generateUserToken from "./generate-native-user-token.js";
import { User } from "../../../models/entities/user.js";
import { Users, UsedUsernames } from "../../../models/index.js";
import { UserProfile } from "../../../models/entities/user-profile.js";
import { IsNull } from "typeorm";
import { genId } from "../../../misc/gen-id.js";
import { toPunyNullable } from "../../../misc/convert-host.js";
import { UserKeypair } from "../../../models/entities/user-keypair.js";
import { usersChart } from "../../../services/chart/index.js";
import { UsedUsername } from "../../../models/entities/used-username.js";
import { db } from "../../../db/postgre.js";
import config from "../../../config/index.js";
import { hashPassword } from "../../../misc/password.js";
import { fetchMeta } from "../../../misc/fetch-meta.js";
import follow from "../../../services/following/create.js";
export async function signup(opts) {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
const userCount = await Users.countBy({
host: IsNull()
});
if (config.maxUserSignups != null && userCount > config.maxUserSignups) {
throw new Error("MAX_USERS_REACHED");
}
// Validate username
if (!Users.validateLocalUsername(username)) {
throw new Error("INVALID_USERNAME");
}
if (password != null && passwordHash == null) {
// Validate password
if (!Users.validatePassword(password)) {
throw new Error("INVALID_PASSWORD");
}
// Generate hash of password
hash = await hashPassword(password);
}
// Generate secret
const secret = generateUserToken();
// Check username duplication
if (await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull()
})) {
throw new Error("DUPLICATED_USERNAME");
}
// Check deleted username duplication
if (await UsedUsernames.findOneBy({
username: username.toLowerCase()
})) {
throw new Error("USED_USERNAME");
}
const keyPair = await new Promise((res, rej)=>generateKeyPair("rsa", {
modulusLength: 4096,
publicKeyEncoding: {
type: "spki",
format: "pem"
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
cipher: undefined,
passphrase: undefined
}
}, (err, publicKey, privateKey)=>err ? rej(err) : res([
publicKey,
privateKey
])));
const exist = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull()
});
if (exist) throw new Error("The username is already in use");
// Prepare objects
const user = new User({
id: genId(),
createdAt: new Date(),
username: username,
usernameLower: username.toLowerCase(),
host: toPunyNullable(host),
token: secret,
isAdmin: await Users.countBy({
host: IsNull(),
isAdmin: true
}) === 0
});
const userKeypair = new UserKeypair({
publicKey: keyPair[0],
privateKey: keyPair[1],
userId: user.id
});
const userProfile = new UserProfile({
userId: user.id,
autoAcceptFollowed: true,
allowCalls: false,
password: hash
});
const usedUsername = new UsedUsername({
createdAt: new Date(),
username: username.toLowerCase()
});
// Save the objects atomically using a db transaction, note that we should never run any code in a transaction block directly
await db.transaction(async (transactionalEntityManager)=>{
await transactionalEntityManager.save(user);
await transactionalEntityManager.save(userKeypair);
await transactionalEntityManager.save(userProfile);
await transactionalEntityManager.save(usedUsername);
});
const account = await Users.findOneByOrFail({
id: user.id
});
const meta = await fetchMeta();
// If an autofollow account exists, follow it
if (meta.autofollowedAccount) {
const autofollowedAccount = await Users.findOneByOrFail({
usernameLower: meta.autofollowedAccount.toLowerCase(),
host: IsNull()
});
if (autofollowedAccount) {
await follow(account, autofollowedAccount);
}
}
usersChart.update(account, true);
return {
account,
secret
};
}