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
+11
View File
@@ -0,0 +1,11 @@
export function parse(acct) {
if (acct.startsWith("@")) acct = acct.slice(1);
const split = acct.split("@", 2);
return {
username: split[0],
host: split[1] || null
};
}
export function toString(acct) {
return acct.host == null ? acct.username : `${acct.username}@${acct.host}`;
}
@@ -0,0 +1,30 @@
import { Antennas } from "../models/index.js";
import { subscriber } from "../db/redis.js";
let antennasFetched = false;
let antennas = [];
export async function getAntennas() {
if (!antennasFetched) {
antennas = await Antennas.find();
antennasFetched = true;
}
return antennas;
}
subscriber.on("message", async (_, data)=>{
const obj = JSON.parse(data);
if (obj.channel === "internal") {
const { type, body } = obj.message;
switch(type){
case "antennaCreated":
antennas.push(body);
break;
case "antennaUpdated":
antennas[antennas.findIndex((a)=>a.id === body.id)] = body;
break;
case "antennaDeleted":
antennas = antennas.filter((a)=>a.id !== body.id);
break;
default:
break;
}
}
});
@@ -0,0 +1,34 @@
export const kinds = [
"read:account",
"write:account",
"read:blocks",
"write:blocks",
"read:drive",
"write:drive",
"read:favorites",
"write:favorites",
"read:following",
"write:following",
"read:messaging",
"write:messaging",
"read:mutes",
"write:mutes",
"write:notes",
"read:notifications",
"write:notifications",
"read:reactions",
"write:reactions",
"write:votes",
"read:pages",
"write:pages",
"write:page-likes",
"read:page-likes",
"read:user-groups",
"write:user-groups",
"read:channels",
"write:channels",
"read:gallery",
"write:gallery",
"read:gallery-likes",
"write:gallery-likes"
]; // IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions).
+21
View File
@@ -0,0 +1,21 @@
import { redisClient } from "../db/redis.js";
import { promisify } from "node:util";
import redisLock from "redis-lock";
/**
* Retry delay (ms) for lock acquisition
*/ const retryDelay = 100;
const lock = redisClient ? promisify(redisLock(redisClient, retryDelay)) : async ()=>()=>{};
/**
* Get AP Object lock
* @param uri AP object ID
* @param timeout Lock timeout (ms), The timeout releases previous lock.
* @returns Unlock function
*/ export function getApLock(uri, timeout = 30 * 1000) {
return lock(`ap-object:${uri}`, timeout);
}
export function getFetchInstanceMetadataLock(host, timeout = 30 * 1000) {
return lock(`instance:${host}`, timeout);
}
export function getChartInsertLock(lockKey, timeout = 30 * 1000) {
return lock(`chart-insert:${lockKey}`, timeout);
}
@@ -0,0 +1,75 @@
// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58
"use strict";
/**
* @callback BeforeShutdownListener
* @param {string} [signalOrEvent] The exit signal or event name received on the process.
*/ /**
* System signals the app will listen to initiate shutdown.
* @const {string[]}
*/ const SHUTDOWN_SIGNALS = [
"SIGINT",
"SIGTERM"
];
/**
* Time in milliseconds to wait before forcing shutdown.
* @const {number}
*/ const SHUTDOWN_TIMEOUT = 15000;
/**
* A queue of listener callbacks to execute before shutting
* down the process.
* @type {BeforeShutdownListener[]}
*/ const shutdownListeners = [];
/**
* Listen for signals and execute given `fn` function once.
* @param {string[]} signals System signals to listen to.
* @param {function(string)} fn Function to execute on shutdown.
*/ const processOnce = (signals, fn)=>{
for (const sig of signals){
process.once(sig, fn);
}
};
/**
* Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds.
* @param {number} timeout Time to wait before forcing shutdown (milliseconds)
*/ const forceExitAfter = (timeout)=>()=>{
setTimeout(()=>{
// Force shutdown after timeout
console.warn(`Could not close resources gracefully after ${timeout}ms: forcing shutdown`);
return process.exit(1);
}, timeout).unref();
};
/**
* Main process shutdown handler. Will invoke every previously registered async shutdown listener
* in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will
* be logged out as a warning, but won't prevent other callbacks from executing.
* @param {string} signalOrEvent The exit signal or event name received on the process.
*/ async function shutdownHandler(signalOrEvent) {
if (process.env.NODE_ENV === "test") return process.exit(0);
console.warn(`Shutting down: received [${signalOrEvent}] signal`);
for (const listener of shutdownListeners){
try {
await listener(signalOrEvent);
} catch (err) {
if (err instanceof Error) {
console.warn(`A shutdown handler failed before completing with: ${err.message || err}`);
}
}
}
return process.exit(0);
}
/**
* Registers a new shutdown listener to be invoked before exiting
* the main process. Listener handlers are guaranteed to be called in the order
* they were registered.
* @param {BeforeShutdownListener} listener The shutdown listener to register.
* @returns {BeforeShutdownListener} Echoes back the supplied `listener`.
*/ export function beforeShutdown(listener) {
shutdownListeners.push(listener);
return listener;
}
// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds
// This prevents custom shutdown handlers from hanging the process indefinitely
processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT));
// Register process shutdown callback
// Will listen to incoming signal events and execute all registered handlers in the stack
processOnce(SHUTDOWN_SIGNALS, shutdownHandler);
+101
View File
@@ -0,0 +1,101 @@
import { redisClient } from "../db/redis.js";
import { encode, decode } from "msgpackr";
import config from "../config/index.js";
export class Cache {
ttl;
prefix;
constructor(name, ttlSeconds){
this.ttl = ttlSeconds;
this.prefix = `cache:${name}`;
}
prefixedKey(key) {
return key ? `${this.prefix}:${key}` : this.prefix;
}
async set(key, value, transaction) {
const _key = this.prefixedKey(key);
const _value = Buffer.from(encode(value));
const commander = transaction ?? redisClient;
await commander.set(_key, _value, "EX", this.ttl);
}
async get(key, renew = false) {
const _key = this.prefixedKey(key);
const cached = await redisClient.getBuffer(_key);
if (cached === null) return undefined;
if (renew) await redisClient.expire(_key, this.ttl);
return decode(cached);
}
async getAll(renew = false) {
const finalPrefix = `${config.redis.prefix}:${this.prefix}:`;
const keys = (await redisClient.keys(`${finalPrefix}*`)).map((p)=>p.substring(finalPrefix.length));
const prefixedKeys = keys.map((p)=>this.prefixedKey(p));
const map = new Map();
if (keys.length === 0) {
return map;
}
const values = await redisClient.mgetBuffer(prefixedKeys);
for (const [i, key] of keys.entries()){
const val = values[i];
if (val !== null) {
map.set(key, decode(val));
}
}
if (renew) {
const trans = redisClient.multi();
for (const key of map.keys()){
trans.expire(this.prefixedKey(key), this.ttl);
}
await trans.exec();
}
return map;
}
async delete(...keys) {
if (keys.length > 0) {
const _keys = keys.map((p)=>this.prefixedKey(p));
await redisClient.del(_keys);
}
}
/**
* Returns if cached value exists. Otherwise, calls fetcher and caches.
* Overwrites cached value if invalidated by the optional validator.
*/ async fetch(key, fetcher, renew = false, validator) {
const cachedValue = await this.get(key, renew);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
await this.set(key, value);
return value;
}
/**
* Returns if cached value exists. Otherwise, calls fetcher and caches if the fetcher returns a value.
* Overwrites cached value if invalidated by the optional validator.
*/ async fetchMaybe(key, fetcher, renew = false, validator) {
const cachedValue = await this.get(key, renew);
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
await this.set(key, value);
}
return value;
}
}
+44
View File
@@ -0,0 +1,44 @@
import fetch from "node-fetch";
import { URLSearchParams } from "node:url";
import { getAgentByUrl } from "./fetch.js";
import config from "../config/index.js";
export async function verifyRecaptcha(secret, response) {
const result = await getCaptchaResponse("https://www.recaptcha.net/recaptcha/api/siteverify", secret, response).catch((e)=>{
throw new Error(`recaptcha-request-failed: ${e.message}`);
});
if (result.success !== true) {
const errorCodes = result["error-codes"] ? result["error-codes"]?.join(", ") : "";
throw new Error(`recaptcha-failed: ${errorCodes}`);
}
}
export async function verifyHcaptcha(secret, response) {
const result = await getCaptchaResponse("https://hcaptcha.com/siteverify", secret, response).catch((e)=>{
throw new Error(`hcaptcha-request-failed: ${e.message}`);
});
if (result.success !== true) {
const errorCodes = result["error-codes"] ? result["error-codes"]?.join(", ") : "";
throw new Error(`hcaptcha-failed: ${errorCodes}`);
}
}
async function getCaptchaResponse(url, secret, response) {
const params = new URLSearchParams({
secret,
response
});
const res = await fetch(url, {
method: "POST",
body: params,
headers: {
"User-Agent": config.userAgent
},
// TODO
//timeout: 10 * 1000,
agent: getAgentByUrl
}).catch((e)=>{
throw new Error(`${e.message || e}`);
});
if (!res.ok) {
throw new Error(`${res.status}`);
}
return await res.json();
}
@@ -0,0 +1,69 @@
import { UserListJoinings, UserGroupJoinings, Blockings } from "../models/index.js";
import { getFullApAccount } from "./convert-host.js";
import * as Acct from "./acct.js";
import { Cache } from "./cache.js";
const blockingCache = new Cache("blocking", 60 * 5);
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
/**
* noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい
*/ export async function checkHitAntenna(antenna, note, noteUser, noteUserFollowers, antennaUserFollowing) {
if (note.visibility === "specified") return false;
if (note.visibility === "home") return false;
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await blockingCache.fetch(noteUser.id, ()=>Blockings.findBy({
blockerId: noteUser.id
}).then((res)=>res.map((x)=>x.blockeeId)));
if (blockings.some((blocking)=>blocking === antenna.userId)) return false;
if (note.visibility === "followers") {
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
}
if (!antenna.withReplies && note.replyId != null) return false;
if (antenna.src === "home") {
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
} else if (antenna.src === "list") {
const listUsers = (await UserListJoinings.findBy({
userListId: antenna.userListId
})).map((x)=>x.userId);
if (!listUsers.includes(note.userId)) return false;
} else if (antenna.src === "group") {
const joining = await UserGroupJoinings.findOneByOrFail({
id: antenna.userGroupJoiningId
});
const groupUsers = (await UserGroupJoinings.findBy({
userGroupId: joining.userGroupId
})).map((x)=>x.userId);
if (!groupUsers.includes(note.userId)) return false;
} else if (antenna.src === "users") {
const accts = antenna.users.map((x)=>{
const { username, host } = Acct.parse(x);
return getFullApAccount(username, host).toLowerCase();
});
if (!accts.includes(getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
} else if (antenna.src === "instances") {
const instances = antenna.instances.filter((x)=>x !== "").map((host)=>{
return host.toLowerCase();
});
if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false;
}
const keywords = antenna.keywords// Clean up
.map((xs)=>xs.filter((x)=>x !== "")).filter((xs)=>xs.length > 0);
if (keywords.length > 0) {
if (note.text == null) return false;
const matched = keywords.some((and)=>and.every((keyword)=>antenna.caseSensitive ? note.text.includes(keyword) || note.cw?.includes(keyword) : note.text.toLowerCase().includes(keyword.toLowerCase()) || note.cw?.toLowerCase().includes(keyword.toLowerCase())));
if (!matched) return false;
}
const excludeKeywords = antenna.excludeKeywords// Clean up
.map((xs)=>xs.filter((x)=>x !== "")).filter((xs)=>xs.length > 0);
if (excludeKeywords.length > 0) {
if (note.text == null) return false;
const matched = excludeKeywords.some((and)=>and.every((keyword)=>antenna.caseSensitive ? note.text.includes(keyword) || note.cw?.includes(keyword) : note.text.toLowerCase().includes(keyword.toLowerCase()) || note.cw?.toLowerCase().includes(keyword.toLowerCase())));
if (matched) return false;
}
if (antenna.withFile) {
if (note.fileIds && note.fileIds.length === 0) return false;
}
// TODO: eval expression
return true;
}
@@ -0,0 +1,36 @@
import RE2 from "re2";
function checkWordMute(note, mutedWords) {
if (note == null) return false;
let text = `${note.cw ?? ""} ${note.text ?? ""}`;
if (note.files != null) text += ` ${note.files.map((f)=>f.comment ?? "").join(" ")}`;
text = text.trim().toLowerCase();
if (text === "") return false;
for (const mutePattern of mutedWords){
if (Array.isArray(mutePattern)) {
// Clean up
const keywords = mutePattern.filter((keyword)=>keyword !== "");
if (keywords.length > 0 && keywords.every((keyword)=>text.includes(keyword.toLowerCase()))) return true;
} else {
// represents RegExp
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) {
console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
continue;
}
try {
if (new RE2(regexp[1], regexp[2]).test(text)) return true;
} catch (err) {
// This should never happen due to input sanitisation.
}
}
}
return false;
}
export async function getWordHardMute(note, me, mutedWords) {
// 自分自身
if (me && note.userId === me.id) return false;
if (mutedWords.length <= 0) return false;
if (note.isFiltered) return true;
return checkWordMute(note, mutedWords) || checkWordMute(note.reply, mutedWords) || checkWordMute(note.renote, mutedWords);
}
@@ -0,0 +1,72 @@
import * as http from "node:http";
import * as https from "node:https";
import net from "node:net";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import config from "../config/index.js";
import IPCIDR from "ip-cidr";
import PrivateIp from "private-ip";
function isPrivateIp(ip) {
for (const net of config.allowedPrivateNetworks || []){
const cidr = new IPCIDR(net);
if (cidr.contains(ip)) {
return false;
}
}
return PrivateIp(ip);
}
function checkConnection(socket) {
if (socket instanceof net.Socket) {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && IPCIDR.isValidAddress(address) && isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
} else {
throw "Tried to check connection for type that isn't net.Socket";
}
}
export class CheckedHttpAgent extends http.Agent {
createConnection(options, callback) {
const socket = super.createConnection(options, callback ? (err, stream)=>{
if (stream) checkConnection(stream);
callback(err, stream);
} : undefined)?.on('connect', ()=>{
socket && checkConnection(socket);
});
return socket;
}
}
export class CheckedHttpsAgent extends https.Agent {
createConnection(options, callback) {
const socket = super.createConnection(options, callback ? (err, stream)=>{
if (stream) checkConnection(stream);
callback(err, stream);
} : undefined)?.on('connect', ()=>{
socket && checkConnection(socket);
});
return socket;
}
}
export class CheckedHttpProxyAgent extends HttpProxyAgent {
createConnection(options, callback) {
const socket = super.createConnection(options, callback ? (err, stream)=>{
if (stream) checkConnection(stream);
callback(err, stream);
} : undefined)?.on('connect', ()=>{
socket && checkConnection(socket);
});
return socket;
}
}
export class CheckedHttpsProxyAgent extends HttpsProxyAgent {
createConnection(options, callback) {
const socket = super.createConnection(options, callback ? (err, stream)=>{
if (stream) checkConnection(stream);
callback(err, stream);
} : undefined)?.on('connect', ()=>{
socket && checkConnection(socket);
});
return socket;
}
}
+15
View File
@@ -0,0 +1,15 @@
// structredCloneが遅いため
// SEE: http://var.blog.jp/archives/86038606.html
export function deepClone(x) {
if (typeof x === "object") {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone);
const obj = {};
for (const [k, v] of Object.entries(x)){
obj[k] = deepClone(v);
}
return obj;
} else {
return x;
}
}
@@ -0,0 +1,8 @@
import cd from "content-disposition";
export function contentDisposition(type, filename) {
const fallback = filename.replace(/[^\w.-]/g, "_");
return cd(filename, {
type,
fallback
});
}
@@ -0,0 +1,21 @@
import { URL } from "node:url";
import config from "../config/index.js";
import punycode from "punycode/";
export function getFullApAccount(username, host) {
return host ? `${username}@${toPuny(host)}` : `${username}@${toPuny(config.domain)}`;
}
export function isSelfHost(host) {
if (host == null) return true;
return toPuny(config.domain) === toPuny(host) || toPuny(config.host) === toPuny(host);
}
export function extractDbHost(uri) {
const url = new URL(uri);
return toPuny(url.hostname);
}
export function toPuny(host) {
return punycode.toASCII(host.toLowerCase());
}
export function toPunyNullable(host) {
if (host == null) return null;
return punycode.toASCII(host.toLowerCase());
}
@@ -0,0 +1,15 @@
export function convertMilliseconds(ms) {
let seconds = Math.round(ms / 1000);
let minutes = Math.round(seconds / 60);
let hours = Math.round(minutes / 60);
const days = Math.round(hours / 24);
seconds %= 60;
minutes %= 60;
hours %= 24;
const result = [];
if (days > 0) result.push(`${days} day(s)`);
if (hours > 0) result.push(`${hours} hour(s)`);
if (minutes > 0) result.push(`${minutes} minute(s)`);
if (seconds > 0) result.push(`${seconds} second(s)`);
return result.join(", ");
}
@@ -0,0 +1,23 @@
import { Notes } from "../models/index.js";
export async function countSameRenotes(userId, renoteId, excludeNoteId, groupId) {
// 指定したユーザーの指定したノートのリノートがいくつあるか数える
const query = Notes.createQueryBuilder("note").where("note.userId = :userId", {
userId
}).andWhere("note.renoteId = :renoteId", {
renoteId
});
if (groupId) {
query.andWhere("note.groupId = :groupId", {
groupId
});
} else {
query.andWhere("note.groupId IS NULL");
}
// 指定した投稿を除く
if (excludeNoteId) {
query.andWhere("note.id != :excludeNoteId", {
excludeNoteId
});
}
return await query.getCount();
}
@@ -0,0 +1,25 @@
import * as tmp from "tmp";
export function createTemp() {
return new Promise((res, rej)=>{
tmp.file((e, path, fd, cleanup)=>{
if (e) return rej(e);
res([
path,
cleanup
]);
});
});
}
export function createTempDir() {
return new Promise((res, rej)=>{
tmp.dir({
unsafeCleanup: true
}, (e, path, cleanup)=>{
if (e) return rej(e);
res([
path,
cleanup
]);
});
});
}
@@ -0,0 +1,13 @@
import { createTemp } from "./create-temp.js";
import { downloadUrl } from "./download-url.js";
import { detectType } from "./get-file-info.js";
export async function detectUrlMime(url) {
const [path, cleanup] = await createTemp();
try {
await downloadUrl(url, path);
const { mime } = await detectType(path);
return mime;
} finally{
cleanup();
}
}
@@ -0,0 +1,19 @@
import * as fs from "node:fs";
import * as util from "node:util";
import Logger from "../services/logger.js";
import { createTemp } from "./create-temp.js";
import { downloadUrl } from "./download-url.js";
const logger = new Logger("download-text-file");
export async function downloadTextFile(url) {
// Create temp file
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
try {
// write content at URL to temp file
await downloadUrl(url, path);
const text = await util.promisify(fs.readFile)(path, "utf8");
return text;
} finally{
cleanup();
}
}
@@ -0,0 +1,63 @@
import * as fs from "node:fs";
import * as stream from "node:stream";
import * as util from "node:util";
import got, * as Got from "got";
import { httpAgent, httpsAgent, StatusError } from "./fetch.js";
import config from "../config/index.js";
import chalk from "chalk";
import Logger from "../services/logger.js";
const pipeline = util.promisify(stream.pipeline);
export async function downloadUrl(url, path) {
const logger = new Logger("download");
logger.info(`Downloading ${chalk.cyan(url)} ...`);
const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
const maxSize = config.maxFileSize || 262144000;
const req = got.stream(url, {
headers: {
"User-Agent": config.userAgent,
Host: new URL(url).hostname
},
timeout: {
lookup: timeout,
connect: timeout,
secureConnect: timeout,
socket: timeout,
response: timeout,
send: timeout,
request: operationTimeout
},
agent: {
http: httpAgent,
https: httpsAgent
},
http2: false,
retry: {
limit: 0
}
}).on("response", (res)=>{
const contentLength = res.headers["content-length"];
if (contentLength != null) {
const size = Number(contentLength);
if (size > maxSize) {
logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
req.destroy();
}
}
}).on("downloadProgress", (progress)=>{
if (progress.transferred > maxSize) {
logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
req.destroy();
}
});
try {
await pipeline(req, fs.createWriteStream(path));
} catch (e) {
if (e instanceof Got.HTTPError) {
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
} else {
throw e;
}
}
logger.succ(`Download finished: ${chalk.cyan(url)}`);
}
+49
View File
@@ -0,0 +1,49 @@
import probeImageSize from "probe-image-size";
import { Mutex } from "redis-semaphore";
import { FILE_TYPE_BROWSERSAFE } from "../const.js";
import Logger from "../services/logger.js";
import { Cache } from "./cache.js";
import { redisClient } from "../db/redis.js";
const cache = new Cache("emojiMeta", 60 * 10); // once every 10 minutes for the same url
const logger = new Logger("emoji");
export async function getEmojiSize(url) {
let attempted = true;
const lock = new Mutex(redisClient, "getEmojiSize");
await lock.acquire();
try {
attempted = await cache.get(url) === true;
if (!attempted) {
await cache.set(url, true);
}
} finally{
await lock.release();
}
if (attempted) {
logger.warn(`Attempt limit exceeded: ${url}`);
throw new Error("Too many attempts");
}
try {
logger.debug(`Retrieving emoji size from ${url}`);
const { width, height, mime } = await probeImageSize(url, {
timeout: 5000
});
if (!(mime.startsWith("image/") && FILE_TYPE_BROWSERSAFE.includes(mime))) {
throw new Error("Unsupported image type");
}
return {
width,
height
};
} catch (e) {
throw new Error(`Unable to retrieve metadata: ${e}`);
}
}
export function getNormalSize({ width, height }, orientation) {
return (orientation || 0) >= 5 ? {
width: height,
height: width
} : {
width,
height
};
}
@@ -0,0 +1,4 @@
import twemoji from "@twemoji/parser/dist/lib/regex.js";
const twemojiRegex = twemoji.default;
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`);
@@ -0,0 +1,24 @@
import * as mfm from "mfm-js";
import { unique } from "../prelude/array.js";
const wrappedEmojiRegex = /([:;])([^:;\s]{1,100})\1/g;
const glyphEmojiRegex = /;([^:;\s]{1,100});/g;
export function extractCustomEmojisFromMfm(nodes) {
const emojiNodes = mfm.extract(nodes, (node)=>{
return node.type === "emojiCode" && node.props.name.length <= 100;
});
const glyphNames = mfm.extract(nodes, (node)=>node.type === "text").flatMap((node)=>Array.from(node.props.text.matchAll(glyphEmojiRegex), (match)=>match[1]));
return unique([
...emojiNodes.map((x)=>x.props.name),
...glyphNames
]);
}
export function extractCustomEmojiNamesFromText(texts) {
const emojis = [];
for (const text of texts){
if (!text) continue;
for (const match of text.matchAll(wrappedEmojiRegex)){
emojis.push(match[2]);
}
}
return unique(emojis);
}
@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as mfm from "mfm-js";
import { extractCustomEmojiNamesFromText, extractCustomEmojisFromMfm } from "./extract-custom-emojis-from-mfm.js";
test("extractCustomEmojiNamesFromText includes glyph syntax", ()=>{
assert.deepEqual(extractCustomEmojiNamesFromText([
"plain :blobcat: and ;glyph; and ;glyph@example.com;"
]), [
"blobcat",
"glyph",
"glyph@example.com"
]);
});
test("extractCustomEmojisFromMfm includes glyph syntax inside text nodes", ()=>{
assert.deepEqual(extractCustomEmojisFromMfm(mfm.parse("hello :blobcat: ;glyph;")), [
"blobcat",
"glyph"
]);
});
@@ -0,0 +1,42 @@
import { UserGroupJoinings, UserGroups, Users } from "../models/index.js";
import { In } from "typeorm";
const GROUP_MENTION = /(^|[^\w@])@@([a-zA-Z0-9_]{1,64})\b/g;
const WRAPPED_ASSET = /[:;][^:;\s]{1,100}[:;]/g;
export function extractGroupMentionNames(texts) {
const names = new Set();
for (const text of texts){
if (!text) continue;
const sanitized = text.replace(WRAPPED_ASSET, (match)=>" ".repeat(match.length));
for (const match of sanitized.matchAll(GROUP_MENTION)){
names.add(match[2].toLowerCase());
}
}
return [
...names
];
}
export async function extractGroupMentionedUsers(texts) {
const names = extractGroupMentionNames(texts);
if (names.length === 0) return [];
const groups = await UserGroups.find({
where: names.map((username)=>({
username
}))
});
const mentionedUserIds = new Set();
for (const group of groups){
mentionedUserIds.add(group.userId);
const joinings = await UserGroupJoinings.findBy({
userGroupId: group.id
});
for (const joining of joinings){
mentionedUserIds.add(joining.userId);
}
}
if (mentionedUserIds.size === 0) return [];
return await Users.findBy({
id: In([
...mentionedUserIds
])
});
}
@@ -0,0 +1,7 @@
import * as mfm from "mfm-js";
import { unique } from "../prelude/array.js";
export function extractHashtags(nodes) {
const hashtagNodes = mfm.extract(nodes, (node)=>node.type === "hashtag");
const hashtags = unique(hashtagNodes.map((x)=>x.props.hashtag));
return hashtags;
}
@@ -0,0 +1,22 @@
// test is located in test/extract-mentions
export function extractMentions(nodes) {
const mentions = [];
collectMentions(nodes, mentions);
return mentions;
}
function collectMentions(nodes, mentions) {
for(let i = 0; i < nodes.length; i++){
const node = nodes[i];
if (node.type === "mention" && !isWrappedUserEmojiMention(nodes, i)) {
mentions.push(node.props);
}
if ("children" in node && Array.isArray(node.children)) {
collectMentions(node.children, mentions);
}
}
}
function isWrappedUserEmojiMention(nodes, index) {
const previous = nodes[index - 1];
const next = nodes[index + 1];
return previous?.type === "text" && next?.type === "text" && previous.props.text.endsWith(":") && next.props.text.startsWith(":");
}
@@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as mfm from "mfm-js";
import { extractMentions } from "./extract-mentions.js";
test("extractMentions keeps regular mentions", ()=>{
const mentions = extractMentions(mfm.parse("hello @alice and @bob@example.com"));
assert.deepEqual(mentions, [
{
username: "alice",
host: null,
acct: "@alice"
},
{
username: "bob",
host: "example.com",
acct: "@bob@example.com"
}
]);
});
test("extractMentions ignores user icon syntax", ()=>{
const mentions = extractMentions(mfm.parse("regular @alice icon :@alice: remote :@bob@example.com:"));
assert.deepEqual(mentions, [
{
username: "alice",
host: null,
acct: "@alice"
}
]);
});
+62
View File
@@ -0,0 +1,62 @@
import push from "web-push";
import { Metas } from "../models/index.js";
let cache;
export function metaToPugArgs(meta) {
let motd = [
"Loading..."
];
if (meta.customMOTD.length > 0) {
motd = meta.customMOTD;
}
let splashIconUrl = meta.iconUrl;
if (meta.customSplashIcons.length > 0) {
splashIconUrl = meta.customSplashIcons[Math.floor(Math.random() * meta.customSplashIcons.length)];
}
return {
img: meta.bannerUrl,
title: meta.name || "FrozenFriendsYume",
instanceName: meta.name || "FrozenFriendsYume",
desc: meta.description,
icon: meta.iconUrl,
splashIcon: splashIconUrl,
themeColor: meta.themeColor,
randomMOTD: motd[Math.floor(Math.random() * motd.length)],
privateMode: meta.privateMode
};
}
export function fetchMetaSync() {
return cache;
}
export async function fetchMeta(noCache = false) {
if (!noCache && cache) return cache;
// New IDs are prioritized because multiple records may have been created due to past bugs.
const meta = await Metas.findOne({
where: {},
order: {
id: "DESC"
}
});
if (meta) {
cache = meta;
return meta;
}
const { publicKey, privateKey } = push.generateVAPIDKeys();
const data = {
id: "x",
swPublicKey: publicKey,
swPrivateKey: privateKey
};
// If fetchMeta is called at the same time when meta is empty, this part may be called at the same time, so use fail-safe upsert.
await Metas.upsert(data, [
"id"
]);
cache = await Metas.findOneByOrFail({
id: data.id
});
return cache;
}
setInterval(()=>{
fetchMeta(true).then((meta)=>{
cache = meta;
});
}, 1000 * 10);
+141
View File
@@ -0,0 +1,141 @@
import CacheableLookup from "cacheable-lookup";
import fetch from "node-fetch";
import config from "../config/index.js";
import { CheckedHttpAgent, CheckedHttpProxyAgent, CheckedHttpsAgent, CheckedHttpsProxyAgent } from "./checked-fetch.js";
export async function getJson(url, accept = "application/json, */*", timeout = 10000, headers) {
const res = await getResponse({
url,
method: "GET",
headers: Object.assign({
"User-Agent": config.userAgent,
Accept: accept
}, headers || {}),
timeout
});
return await res.json();
}
export async function getJsonActivity(url, accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", timeout = 10000, headers) {
const res = await getResponse({
url,
method: "GET",
headers: Object.assign({
"User-Agent": config.userAgent,
Accept: accept
}, headers || {}),
timeout
});
const contentType = res.headers.get('content-type');
if (contentType == null || contentType !== 'application/activity+json' && !contentType.startsWith('application/activity+json;') && (!contentType.startsWith('application/ld+json;') || !contentType.includes('profile="https://www.w3.org/ns/activitystreams"'))) {
throw new Error(`getJsonActivity response had unexpected content-type: ${contentType}`);
}
return {
finalUrl: res.url,
content: await res.json()
};
}
export async function getHtml(url, accept = "text/html, */*", timeout = 10000, headers) {
const res = await getResponse({
url,
method: "GET",
headers: Object.assign({
"User-Agent": config.userAgent,
Accept: accept
}, headers || {}),
timeout
});
return await res.text();
}
export async function getResponse(args) {
const timeout = args.timeout || 10 * 1000;
const controller = new AbortController();
setTimeout(()=>{
controller.abort();
}, timeout * 6);
const res = await fetch(args.url, {
method: args.method,
headers: args.headers,
body: args.body,
timeout,
size: 10 * 1024 * 1024,
agent: getAgentByUrl,
signal: controller.signal,
redirect: args.redirect
});
if (args.redirect === "manual" && [
301,
302,
307,
308
].includes(res.status)) {
return res;
}
if (!res.ok) {
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
}
return res;
}
const cache = new CacheableLookup({
maxTtl: 3600,
errorTtl: 30,
lookup: false
});
/**
* Get http non-proxy agent
*/ const _http = new CheckedHttpAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup
});
/**
* Get https non-proxy agent
*/ const _https = new CheckedHttpsAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup
});
const maxSockets = Math.max(256, config.deliverJobConcurrency || 128);
/**
* Get http proxy or non-proxy agent
*/ export const httpAgent = config.proxy ? new CheckedHttpProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets,
maxFreeSockets: 256,
scheduling: "lifo",
proxy: config.proxy
}) : _http;
/**
* Get https proxy or non-proxy agent
*/ export const httpsAgent = config.proxy ? new CheckedHttpsProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets,
maxFreeSockets: 256,
scheduling: "lifo",
proxy: config.proxy
}) : _https;
/**
* Get agent by URL
* @param url URL
* @param bypassProxy Allways bypass proxy
*/ export function getAgentByUrl(url, bypassProxy = false) {
if (bypassProxy || (config.proxyBypassHosts || []).includes(url.hostname)) {
return url.protocol === "http:" ? _http : _https;
} else {
return url.protocol === "http:" ? httpAgent : httpsAgent;
}
}
export class StatusError extends Error {
statusCode;
statusMessage;
isClientError;
isRetryable;
constructor(message, statusCode, statusMessage){
super(message);
this.name = "StatusError";
this.statusCode = statusCode;
this.statusMessage = statusMessage;
this.isClientError = typeof this.statusCode === "number" && this.statusCode >= 400 && this.statusCode < 500;
this.isRetryable = this.isClientError && this.statusCode != 429;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { init, createId } from "@paralleldrive/cuid2";
import config from "../config/index.js";
const TIME2000 = 946684800000;
const TIMESTAMP_LENGTH = 8;
const length = Math.min(Math.max(config.cuid?.length ?? 16, 16), 24) - TIMESTAMP_LENGTH;
const fingerprint = `${config.cuid?.fingerprint ?? ""}${createId()}`;
const genCuid2 = init({
length,
fingerprint
});
/**
* The generated ID results in the form of `[8 chars timestamp] + [cuid2]`.
* The minimum and maximum lengths are 16 and 24, respectively.
* With the length of 16, namely 8 for cuid2, roughly 1427399 IDs are needed
* in the same millisecond to reach 50% chance of collision.
*
* Ref: https://github.com/paralleldrive/cuid2#parameterized-length
*/ export function genId(date) {
const now = (date ?? new Date()).getTime();
const time = Math.max(now - TIME2000, 0);
const timestamp = time.toString(36).padStart(TIMESTAMP_LENGTH, "0");
return `${timestamp}${genCuid2()}`;
}
@@ -0,0 +1,199 @@
/**
* Identicon generator
* https://en.wikipedia.org/wiki/Identicon
*/ import * as p from "pureimage";
import gen from "random-seed";
const size = 128; // px
const n = 5; // resolution
const margin = size / 4;
const colors = [
[
"#eb6f92",
"#b4637a"
],
[
"#f6c177",
"#ea9d34"
],
[
"#ebbcba",
"#d7827e"
],
[
"#9ccfd8",
"#56949f"
],
[
"#c4a7e7",
"#907aa9"
],
[
"#eb6f92",
"#f6c177"
],
[
"#eb6f92",
"#ebbcba"
],
[
"#eb6f92",
"#31748f"
],
[
"#eb6f92",
"#9ccfd8"
],
[
"#eb6f92",
"#c4a7e7"
],
[
"#f6c177",
"#eb6f92"
],
[
"#f6c177",
"#ebbcba"
],
[
"#f6c177",
"#31748f"
],
[
"#f6c177",
"#9ccfd8"
],
[
"#f6c177",
"#c4a7e7"
],
[
"#ebbcba",
"#eb6f92"
],
[
"#ebbcba",
"#f6c177"
],
[
"#ebbcba",
"#31748f"
],
[
"#ebbcba",
"#9ccfd8"
],
[
"#ebbcba",
"#c4a7e7"
],
[
"#31748f",
"#eb6f92"
],
[
"#31748f",
"#f6c177"
],
[
"#31748f",
"#ebbcba"
],
[
"#31748f",
"#9ccfd8"
],
[
"#31748f",
"#c4a7e7"
],
[
"#9ccfd8",
"#eb6f92"
],
[
"#9ccfd8",
"#f6c177"
],
[
"#9ccfd8",
"#ebbcba"
],
[
"#9ccfd8",
"#31748f"
],
[
"#9ccfd8",
"#c4a7e7"
],
[
"#c4a7e7",
"#eb6f92"
],
[
"#c4a7e7",
"#f6c177"
],
[
"#c4a7e7",
"#ebbcba"
],
[
"#c4a7e7",
"#31748f"
],
[
"#c4a7e7",
"#9ccfd8"
]
];
const actualSize = size - margin * 2;
const cellSize = actualSize / n;
const sideN = Math.floor(n / 2);
/**
* Generate buffer of an identicon by seed
*/ export function genIdenticon(seed, stream) {
const rand = gen.create(seed);
const canvas = p.make(size, size, undefined);
const ctx = canvas.getContext("2d");
const bgColors = colors[rand(colors.length)];
const bg = ctx.createLinearGradient(0, 0, size, size);
bg.addColorStop(0, bgColors[0]);
bg.addColorStop(1, bgColors[1]);
ctx.fillStyle = bg;
ctx.beginPath();
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#ffffff";
// side bitmap (filled by false)
const side = new Array(sideN);
for(let i = 0; i < side.length; i++){
side[i] = new Array(n).fill(false);
}
// 1*n (filled by false)
const center = new Array(n).fill(false);
for(let x = 0; x < side.length; x++){
for(let y = 0; y < side[x].length; y++){
side[x][y] = rand(3) === 0;
}
}
for(let i = 0; i < center.length; i++){
center[i] = rand(3) === 0;
}
// Draw
for(let x = 0; x < n; x++){
for(let y = 0; y < n; y++){
const isXCenter = x === (n - 1) / 2;
if (isXCenter && !center[y]) continue;
const isLeftSide = x < (n - 1) / 2;
if (isLeftSide && !side[x][y]) continue;
const isRightSide = x > (n - 1) / 2;
if (isRightSide && !side[sideN - (x - sideN)][y]) continue;
const actualX = margin + cellSize * x;
const actualY = margin + cellSize * y;
ctx.beginPath();
ctx.fillRect(actualX, actualY, cellSize, cellSize);
}
}
return p.encodePNGToStream(canvas, stream);
}
@@ -0,0 +1,33 @@
import * as crypto from "node:crypto";
import * as util from "node:util";
const generateKeyPair = util.promisify(crypto.generateKeyPair);
export async function genRsaKeyPair(modulusLength = 2048) {
return await generateKeyPair("rsa", {
modulusLength,
publicKeyEncoding: {
type: "spki",
format: "pem"
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
cipher: undefined,
passphrase: undefined
}
});
}
export async function genEcKeyPair(namedCurve = "prime256v1") {
return await generateKeyPair("ec", {
namedCurve,
publicKeyEncoding: {
type: "spki",
format: "pem"
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
cipher: undefined,
passphrase: undefined
}
});
}
@@ -0,0 +1,168 @@
import * as fs from "node:fs";
import * as crypto from "node:crypto";
import * as stream from "node:stream";
import * as util from "node:util";
import { fileTypeFromFile } from "file-type";
import probeImageSize from "probe-image-size";
import isSvg from "is-svg";
import sharp from "sharp";
import { encode } from "blurhash";
const pipeline = util.promisify(stream.pipeline);
const TYPE_OCTET_STREAM = {
mime: "application/octet-stream",
ext: null
};
const TYPE_SVG = {
mime: "image/svg+xml",
ext: "svg"
};
/**
* Get file information
*/ export async function getFileInfo(path) {
const warnings = [];
const size = await getFileSize(path);
const md5 = await calcHash(path);
let type = await detectType(path);
// image dimensions
let width;
let height;
let orientation;
if ([
"image/jpeg",
"image/gif",
"image/png",
"image/apng",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
"image/vnd.adobe.photoshop",
"image/avif"
].includes(type.mime)) {
const imageSize = await detectImageSize(path).catch((e)=>{
warnings.push(`detectImageSize failed: ${e}`);
return undefined;
});
// うまく判定できない画像は octet-stream にする
if (!imageSize) {
warnings.push("cannot detect image dimensions");
type = TYPE_OCTET_STREAM;
} else if (imageSize.wUnits === "px") {
width = imageSize.width;
height = imageSize.height;
orientation = imageSize.orientation;
// 制限を超えている画像は octet-stream にする
if (imageSize.width > 16383 || imageSize.height > 16383) {
warnings.push("image dimensions exceeds limits");
type = TYPE_OCTET_STREAM;
}
} else {
warnings.push(`unsupported unit type: ${imageSize.wUnits}`);
}
}
let blurhash;
if ([
"image/jpeg",
"image/gif",
"image/png",
"image/apng",
"image/webp",
"image/svg+xml",
"image/avif"
].includes(type.mime)) {
blurhash = await getBlurhash(path).catch((e)=>{
warnings.push(`getBlurhash failed: ${e}`);
return undefined;
});
}
return {
size,
md5,
type,
width,
height,
orientation,
blurhash,
warnings
};
}
function exists(path) {
return fs.promises.access(path).then(()=>true, ()=>false);
}
/**
* Detect MIME Type and extension
*/ export async function detectType(path) {
// Check 0 byte
const fileSize = await getFileSize(path);
if (fileSize === 0) {
return TYPE_OCTET_STREAM;
}
const type = await fileTypeFromFile(path);
if (type) {
// XMLはSVGかもしれない
if (type.mime === "application/xml" && await checkSvg(path)) {
return TYPE_SVG;
}
return {
mime: type.mime,
ext: type.ext
};
}
// 種類が不明でもSVGかもしれない
if (await checkSvg(path)) {
return TYPE_SVG;
}
// それでも種類が不明なら application/octet-stream にする
return TYPE_OCTET_STREAM;
}
/**
* Check the file is SVG or not
*/ export async function checkSvg(path) {
try {
const size = await getFileSize(path);
if (size > 1 * 1024 * 1024) return false;
return isSvg(fs.readFileSync(path));
} catch {
return false;
}
}
/**
* Get file size
*/ export async function getFileSize(path) {
const getStat = util.promisify(fs.stat);
return (await getStat(path)).size;
}
/**
* Calculate MD5 hash
*/ async function calcHash(path) {
const hash = crypto.createHash("md5").setEncoding("hex");
await pipeline(fs.createReadStream(path), hash);
return hash.read();
}
/**
* Detect dimensions of image
*/ async function detectImageSize(path) {
const readable = fs.createReadStream(path);
const imageSize = await probeImageSize(readable);
readable.destroy();
return imageSize;
}
/**
* Calculate average color of image
*/ function getBlurhash(path) {
return new Promise((resolve, reject)=>{
sharp(path).raw().ensureAlpha().resize(64, 64, {
fit: "inside"
}).toBuffer((err, buffer, info)=>{
if (err) return reject(err);
let { width, height } = info;
let hash;
try {
hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7);
} catch (e) {
return reject(e);
}
resolve(hash);
});
});
}
@@ -0,0 +1,22 @@
import IPCIDR from "ip-cidr";
import net from "node:net";
function normalizeIp(ip) {
let normalized = ip.split(",")[0]?.trim();
if (!normalized) return null;
if (normalized.startsWith("[") && normalized.includes("]")) {
normalized = normalized.slice(1, normalized.indexOf("]"));
}
if (net.isIP(normalized)) return normalized;
const ipv4WithPort = normalized.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
if (ipv4WithPort && net.isIPv4(ipv4WithPort[1])) return ipv4WithPort[1];
return null;
}
export function getIpHash(ip) {
const normalized = normalizeIp(ip);
if (!normalized) return "ip-invalid";
// because a single person may control many IPv6 addresses,
// only a /64 subnet prefix of any IP will be taken into account.
// (this means for IPv4 the entire address is used)
const prefix = IPCIDR.createAddress(normalized).mask(64);
return `ip-${BigInt(`0b${prefix}`).toString(36)}`;
}
@@ -0,0 +1,43 @@
/**
* 投稿を表す文字列を取得します。
* @param {*} note (packされた)投稿
*/ export const getNoteSummary = (note)=>{
if (note.deletedAt) {
return "❌";
}
let summary = "";
// 本文
if (note.cw != null) {
summary += note.cw;
} else {
summary += note.text ? note.text : "";
}
// ファイルが添付されているとき
if ((note.files || []).length !== 0) {
const len = note.files?.length;
summary += ` 📎${len !== 1 ? ` (${len})` : ""}`;
}
// 投票が添付されているとき
if (note.poll) {
summary += " 📊";
}
/*
// 返信のとき
if (note.replyId) {
if (note.reply) {
summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
} else {
summary += '\n\nRE: ...';
}
}
// Renoteのとき
if (note.renoteId) {
if (note.renote) {
summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
} else {
summary += '\n\nRN: ...';
}
}
*/ return summary.trim();
};
@@ -0,0 +1,28 @@
export default function(reaction) {
switch(reaction){
case "like":
return "👍";
case "love":
return "❤️";
case "laugh":
return "😆";
case "hmm":
return "🤔";
case "surprise":
return "😮";
case "congrats":
return "🎉";
case "angry":
return "💢";
case "confused":
return "😥";
case "rip":
return "😇";
case "pudding":
return "🍮";
case "star":
return "⭐";
default:
return reaction;
}
}
@@ -0,0 +1,14 @@
// If you change DB_* values, you must also change the DB schema.
/**
* Maximum note text length that can be stored in DB.
* Surrogate pairs count as one
*
* NOTE: this can hypothetically be pushed further
* (up to 250000000), but will likely cause truncations
* and incompatibilities with other servers,
* as well as potential performance issues.
*/ export const DB_MAX_NOTE_TEXT_LENGTH = 100000;
/**
* Maximum image description length that can be stored in DB.
* Surrogate pairs count as one
*/ export const DB_MAX_IMAGE_COMMENT_LENGTH = 8192;
+25
View File
@@ -0,0 +1,25 @@
export class I18n {
locale;
constructor(locale){
this.locale = locale;
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
t(key, args) {
try {
let str = key.split(".").reduce((o, i)=>o[i], this.locale);
if (args) {
for (const [k, v] of Object.entries(args)){
str = str.replace(`{${k}}`, v);
}
}
return str;
} catch (e) {
console.warn(`missing localization '${key}'`);
return key;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
// AID
// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列]
import * as crypto from "node:crypto";
const TIME2000 = 946684800000;
let counter = crypto.randomBytes(2).readUInt16LE(0);
function getTime(time) {
time = time - TIME2000;
if (time < 0) time = 0;
return time.toString(36).padStart(8, "0");
}
function getNoise() {
return counter.toString(36).padStart(2, "0").slice(-2);
}
export function genAid(date) {
const t = date.getTime();
if (isNaN(t)) throw "Failed to create AID: Invalid Date";
counter++;
return getTime(t) + getNoise();
}
+19
View File
@@ -0,0 +1,19 @@
const CHARS = "0123456789abcdef";
function getTime(time) {
if (time < 0) time = 0;
if (time === 0) {
return CHARS[0];
}
time += 0x800000000000;
return time.toString(16).padStart(12, CHARS[0]);
}
function getRandom() {
let str = "";
for(let i = 0; i < 12; i++){
str += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return str;
}
export function genMeid(date) {
return getTime(date.getTime()) + getRandom();
}
+21
View File
@@ -0,0 +1,21 @@
const CHARS = "0123456789abcdef";
// 4bit Fixed hex value 'g'
// 44bit UNIX Time ms in Hex
// 48bit Random value in Hex
function getTime(time) {
if (time < 0) time = 0;
if (time === 0) {
return CHARS[0];
}
return time.toString(16).padStart(11, CHARS[0]);
}
function getRandom() {
let str = "";
for(let i = 0; i < 12; i++){
str += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return str;
}
export function genMeidg(date) {
return `g${getTime(date.getTime())}${getRandom()}`;
}
@@ -0,0 +1,19 @@
const CHARS = "0123456789abcdef";
function getTime(time) {
if (time < 0) time = 0;
if (time === 0) {
return CHARS[0];
}
time = Math.floor(time / 1000);
return time.toString(16).padStart(8, CHARS[0]);
}
function getRandom() {
let str = "";
for(let i = 0; i < 16; i++){
str += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return str;
}
export function genObjectId(date) {
return getTime(date.getTime()) + getRandom();
}
@@ -0,0 +1,11 @@
/**
* ID付きエラー
*/ export class IdentifiableError extends Error {
message;
id;
constructor(id, message){
super(message);
this.message = message || "";
this.id = id;
}
}
@@ -0,0 +1,4 @@
export function isDuplicateKeyValueError(e) {
const nodeError = e;
return nodeError.code === "23505";
}
@@ -0,0 +1,19 @@
import { getWordHardMute } from "./check-word-mute.js";
import { Cache } from "./cache.js";
import { unique } from "../prelude/array.js";
import config from "../config/index.js";
import { UserProfiles } from "../models/index.js";
const filteredNoteCache = new Cache("filteredNote", config.wordMuteCache?.ttlSeconds ?? 60 * 60 * 24);
const mutedWordsCache = new Cache("mutedWords", 60 * 5);
export async function isFiltered(note, user, profile) {
if (!user) return false;
if (profile === undefined) profile = {
mutedWords: await mutedWordsCache.fetch(user.id, async ()=>UserProfiles.findOneBy({
userId: user.id
}).then((p)=>p?.mutedWords ?? []))
};
if (!profile || profile.mutedWords.length < 1) return false;
const ts = note.updatedAt ?? note.createdAt;
const identifier = (typeof ts === "string" ? new Date(ts) : ts)?.getTime() ?? '0';
return filteredNoteCache.fetch(`${note.id}:${identifier}:${user.id}`, ()=>getWordHardMute(note, user, unique(profile.mutedWords)));
}
@@ -0,0 +1,10 @@
export function isInstanceMuted(note, mutedInstances) {
if (mutedInstances.has(note?.user?.host ?? "")) return true;
if (mutedInstances.has(note?.reply?.user?.host ?? "")) return true;
if (mutedInstances.has(note?.renote?.user?.host ?? "")) return true;
return false;
}
export function isUserFromMutedInstance(notif, mutedInstances) {
if (mutedInstances.has(notif?.user?.host ?? "")) return true;
return false;
}
@@ -0,0 +1,15 @@
import { FILE_TYPE_BROWSERSAFE } from "../const.js";
const dictionary = {
"safe-file": FILE_TYPE_BROWSERSAFE,
"sharp-convertible-image": [
"image/jpeg",
"image/png",
"image/gif",
"image/apng",
"image/vnd.mozilla.apng",
"image/webp",
"image/svg+xml",
"image/avif"
]
};
export const isMimeImage = (mime, type)=>dictionary[type].includes(mime);
+3
View File
@@ -0,0 +1,3 @@
export default function(note) {
return note.renoteId != null && (note.text != null || note.hasPoll || note.fileIds != null && note.fileIds.length > 0);
}
@@ -0,0 +1,7 @@
export function isUserRelated(note, ids) {
if (ids.has(note.userId)) return true; // note author is muted
if (note.mentions?.some((user)=>ids.has(user))) return true; // any of mentioned users are muted
if (note.reply && isUserRelated(note.reply, ids)) return true; // also check reply target
if (note.renote && isUserRelated(note.renote, ids)) return true; // also check renote target
return false;
}
@@ -0,0 +1,8 @@
import { UserKeypairs } from "../models/index.js";
import { Cache } from "./cache.js";
const cache = new Cache("keypairStore", 60 * 30);
export async function getUserKeypair(userId) {
return await cache.fetch(userId, ()=>UserKeypairs.findOneByOrFail({
userId: userId
}), true);
}
+666
View File
@@ -0,0 +1,666 @@
// TODO: sharedに置いてフロントエンドのと統合したい
export const langmap = {
ach: {
nativeName: "Lwo"
},
ady: {
nativeName: "Адыгэбзэ"
},
af: {
nativeName: "Afrikaans"
},
"af-NA": {
nativeName: "Afrikaans (Namibia)"
},
"af-ZA": {
nativeName: "Afrikaans (South Africa)"
},
ak: {
nativeName: "Tɕɥi"
},
ar: {
nativeName: "العربية"
},
"ar-AR": {
nativeName: "العربية"
},
"ar-MA": {
nativeName: "العربية"
},
"ar-SA": {
nativeName: "العربية (السعودية)"
},
"ay-BO": {
nativeName: "Aymar aru"
},
az: {
nativeName: "Azərbaycan dili"
},
"az-AZ": {
nativeName: "Azərbaycan dili"
},
"be-BY": {
nativeName: "Беларуская"
},
bg: {
nativeName: "Български"
},
"bg-BG": {
nativeName: "Български"
},
bn: {
nativeName: "বাংলা"
},
"bn-IN": {
nativeName: "বাংলা (ভারত)"
},
"bn-BD": {
nativeName: "বাংলা(বাংলাদেশ)"
},
br: {
nativeName: "Brezhoneg"
},
"bs-BA": {
nativeName: "Bosanski"
},
ca: {
nativeName: "Català"
},
"ca-ES": {
nativeName: "Català"
},
cak: {
nativeName: "Maya Kaqchikel"
},
"ck-US": {
nativeName: "ᏣᎳᎩ (tsalagi)"
},
cs: {
nativeName: "Čeština"
},
"cs-CZ": {
nativeName: "Čeština"
},
cy: {
nativeName: "Cymraeg"
},
"cy-GB": {
nativeName: "Cymraeg"
},
da: {
nativeName: "Dansk"
},
"da-DK": {
nativeName: "Dansk"
},
de: {
nativeName: "Deutsch"
},
"de-AT": {
nativeName: "Deutsch (Österreich)"
},
"de-DE": {
nativeName: "Deutsch (Deutschland)"
},
"de-CH": {
nativeName: "Deutsch (Schweiz)"
},
dsb: {
nativeName: "Dolnoserbšćina"
},
el: {
nativeName: "Ελληνικά"
},
"el-GR": {
nativeName: "Ελληνικά"
},
en: {
nativeName: "English"
},
"en-GB": {
nativeName: "English (UK)"
},
"en-AU": {
nativeName: "English (Australia)"
},
"en-CA": {
nativeName: "English (Canada)"
},
"en-IE": {
nativeName: "English (Ireland)"
},
"en-IN": {
nativeName: "English (India)"
},
"en-PI": {
nativeName: "English (Pirate)"
},
"en-SG": {
nativeName: "English (Singapore)"
},
"en-UD": {
nativeName: "English (Upside Down)"
},
"en-US": {
nativeName: "English (US)"
},
"en-ZA": {
nativeName: "English (South Africa)"
},
"en@pirate": {
nativeName: "English (Pirate)"
},
eo: {
nativeName: "Esperanto"
},
"eo-EO": {
nativeName: "Esperanto"
},
es: {
nativeName: "Español"
},
"es-AR": {
nativeName: "Español (Argentine)"
},
"es-419": {
nativeName: "Español (Latinoamérica)"
},
"es-CL": {
nativeName: "Español (Chile)"
},
"es-CO": {
nativeName: "Español (Colombia)"
},
"es-EC": {
nativeName: "Español (Ecuador)"
},
"es-ES": {
nativeName: "Español (España)"
},
"es-LA": {
nativeName: "Español (Latinoamérica)"
},
"es-NI": {
nativeName: "Español (Nicaragua)"
},
"es-MX": {
nativeName: "Español (México)"
},
"es-US": {
nativeName: "Español (Estados Unidos)"
},
"es-VE": {
nativeName: "Español (Venezuela)"
},
et: {
nativeName: "eesti keel"
},
"et-EE": {
nativeName: "Eesti (Estonia)"
},
eu: {
nativeName: "Euskara"
},
"eu-ES": {
nativeName: "Euskara"
},
fa: {
nativeName: "فارسی"
},
"fa-IR": {
nativeName: "فارسی"
},
"fb-LT": {
nativeName: "Leet Speak"
},
ff: {
nativeName: "Fulah"
},
fi: {
nativeName: "Suomi"
},
"fi-FI": {
nativeName: "Suomi"
},
fo: {
nativeName: "Føroyskt"
},
"fo-FO": {
nativeName: "Føroyskt (Færeyjar)"
},
fr: {
nativeName: "Français"
},
"fr-CA": {
nativeName: "Français (Canada)"
},
"fr-FR": {
nativeName: "Français (France)"
},
"fr-BE": {
nativeName: "Français (Belgique)"
},
"fr-CH": {
nativeName: "Français (Suisse)"
},
"fy-NL": {
nativeName: "Frysk"
},
ga: {
nativeName: "Gaeilge"
},
"ga-IE": {
nativeName: "Gaeilge"
},
gd: {
nativeName: "Gàidhlig"
},
gl: {
nativeName: "Galego"
},
"gl-ES": {
nativeName: "Galego"
},
"gn-PY": {
nativeName: "Avañe'ẽ"
},
"gu-IN": {
nativeName: "ગુજરાતી"
},
gv: {
nativeName: "Gaelg"
},
"gx-GR": {
nativeName: "Ἑλληνική ἀρχαία"
},
he: {
nativeName: "עברית‏"
},
"he-IL": {
nativeName: "עברית‏"
},
hi: {
nativeName: "हिन्दी"
},
"hi-IN": {
nativeName: "हिन्दी"
},
hr: {
nativeName: "Hrvatski"
},
"hr-HR": {
nativeName: "Hrvatski"
},
hsb: {
nativeName: "Hornjoserbšćina"
},
ht: {
nativeName: "Kreyòl"
},
hu: {
nativeName: "Magyar"
},
"hu-HU": {
nativeName: "Magyar"
},
hy: {
nativeName: "Հայերեն"
},
"hy-AM": {
nativeName: "Հայերեն (Հայաստան)"
},
id: {
nativeName: "Bahasa Indonesia"
},
"id-ID": {
nativeName: "Bahasa Indonesia"
},
is: {
nativeName: "Íslenska"
},
"is-IS": {
nativeName: "Íslenska (Iceland)"
},
it: {
nativeName: "Italiano"
},
"it-IT": {
nativeName: "Italiano"
},
ja: {
nativeName: "日本語"
},
"ja-JP": {
nativeName: "日本語 (日本)"
},
"jv-ID": {
nativeName: "Basa Jawa"
},
"ka-GE": {
nativeName: "ქართული"
},
"kk-KZ": {
nativeName: "Қазақша"
},
km: {
nativeName: "ភាសាខ្មែរ"
},
kl: {
nativeName: "kalaallisut"
},
"km-KH": {
nativeName: "ភាសាខ្មែរ"
},
kab: {
nativeName: "Taqbaylit"
},
kn: {
nativeName: "ಕನ್ನಡ"
},
"kn-IN": {
nativeName: "ಕನ್ನಡ (India)"
},
ko: {
nativeName: "한국어"
},
"ko-KR": {
nativeName: "한국어 (한국)"
},
"ku-TR": {
nativeName: "Kurdî"
},
kw: {
nativeName: "Kernewek"
},
la: {
nativeName: "Latin"
},
"la-VA": {
nativeName: "Latin"
},
lb: {
nativeName: "Lëtzebuergesch"
},
"li-NL": {
nativeName: "Lèmbörgs"
},
lt: {
nativeName: "Lietuvių"
},
"lt-LT": {
nativeName: "Lietuvių"
},
lv: {
nativeName: "Latviešu"
},
"lv-LV": {
nativeName: "Latviešu"
},
mai: {
nativeName: "मैथिली, মৈথিলী"
},
"mg-MG": {
nativeName: "Malagasy"
},
mk: {
nativeName: "Македонски"
},
"mk-MK": {
nativeName: "Македонски (Македонски)"
},
ml: {
nativeName: "മലയാളം"
},
"ml-IN": {
nativeName: "മലയാളം"
},
"mn-MN": {
nativeName: "Монгол"
},
mr: {
nativeName: "मराठी"
},
"mr-IN": {
nativeName: "मराठी"
},
ms: {
nativeName: "Bahasa Melayu"
},
"ms-MY": {
nativeName: "Bahasa Melayu"
},
mt: {
nativeName: "Malti"
},
"mt-MT": {
nativeName: "Malti"
},
my: {
nativeName: "ဗမာစကာ"
},
no: {
nativeName: "Norsk"
},
nb: {
nativeName: "Norsk (bokmål)"
},
"nb-NO": {
nativeName: "Norsk (bokmål)"
},
ne: {
nativeName: "नेपाली"
},
"ne-NP": {
nativeName: "नेपाली"
},
nl: {
nativeName: "Nederlands"
},
"nl-BE": {
nativeName: "Nederlands (België)"
},
"nl-NL": {
nativeName: "Nederlands (Nederland)"
},
"nn-NO": {
nativeName: "Norsk (nynorsk)"
},
oc: {
nativeName: "Occitan"
},
"or-IN": {
nativeName: "ଓଡ଼ିଆ"
},
pa: {
nativeName: "ਪੰਜਾਬੀ"
},
"pa-IN": {
nativeName: "ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)"
},
pl: {
nativeName: "Polski"
},
"pl-PL": {
nativeName: "Polski"
},
"ps-AF": {
nativeName: "پښتو"
},
pt: {
nativeName: "Português"
},
"pt-BR": {
nativeName: "Português (Brasil)"
},
"pt-PT": {
nativeName: "Português (Portugal)"
},
"qu-PE": {
nativeName: "Qhichwa"
},
"rm-CH": {
nativeName: "Rumantsch"
},
ro: {
nativeName: "Română"
},
"ro-RO": {
nativeName: "Română"
},
ru: {
nativeName: "Русский"
},
"ru-RU": {
nativeName: "Русский"
},
"sa-IN": {
nativeName: "संस्कृतम्"
},
"se-NO": {
nativeName: "Davvisámegiella"
},
sh: {
nativeName: "српскохрватски"
},
"si-LK": {
nativeName: "සිංහල"
},
sk: {
nativeName: "Slovenčina"
},
"sk-SK": {
nativeName: "Slovenčina (Slovakia)"
},
sl: {
nativeName: "Slovenščina"
},
"sl-SI": {
nativeName: "Slovenščina"
},
"so-SO": {
nativeName: "Soomaaliga"
},
sq: {
nativeName: "Shqip"
},
"sq-AL": {
nativeName: "Shqip"
},
sr: {
nativeName: "Српски"
},
"sr-RS": {
nativeName: "Српски (Serbia)"
},
su: {
nativeName: "Basa Sunda"
},
sv: {
nativeName: "Svenska"
},
"sv-SE": {
nativeName: "Svenska"
},
sw: {
nativeName: "Kiswahili"
},
"sw-KE": {
nativeName: "Kiswahili"
},
ta: {
nativeName: "தமிழ்"
},
"ta-IN": {
nativeName: "தமிழ்"
},
te: {
nativeName: "తెలుగు"
},
"te-IN": {
nativeName: "తెలుగు"
},
tg: {
nativeName: "забо́ни тоҷикӣ́"
},
"tg-TJ": {
nativeName: "тоҷикӣ"
},
th: {
nativeName: "ภาษาไทย"
},
"th-TH": {
nativeName: "ภาษาไทย (ประเทศไทย)"
},
fil: {
nativeName: "Filipino"
},
tlh: {
nativeName: "tlhIngan-Hol"
},
tr: {
nativeName: "Türkçe"
},
"tr-TR": {
nativeName: "Türkçe"
},
"tt-RU": {
nativeName: "татарча"
},
uk: {
nativeName: "Українська"
},
"uk-UA": {
nativeName: "Українська"
},
ur: {
nativeName: "اردو"
},
"ur-PK": {
nativeName: "اردو"
},
uz: {
nativeName: "O'zbek"
},
"uz-UZ": {
nativeName: "O'zbek"
},
vi: {
nativeName: "Tiếng Việt"
},
"vi-VN": {
nativeName: "Tiếng Việt"
},
"xh-ZA": {
nativeName: "isiXhosa"
},
yi: {
nativeName: "ייִדיש"
},
"yi-DE": {
nativeName: "ייִדיש (German)"
},
zh: {
nativeName: "中文"
},
"zh-Hans": {
nativeName: "中文简体"
},
"zh-Hant": {
nativeName: "中文繁體"
},
"zh-CN": {
nativeName: "中文(中国大陆)"
},
"zh-HK": {
nativeName: "中文(香港)"
},
"zh-SG": {
nativeName: "中文(新加坡)"
},
"zh-TW": {
nativeName: "中文(台灣)"
},
"zu-ZA": {
nativeName: "isiZulu"
}
};
@@ -0,0 +1,6 @@
export function normalizeForSearch(tag) {
// ref.
// - https://analytics-note.xyz/programming/unicode-normalization-forms/
// - https://maku77.github.io/js/string/normalize.html
return tag.normalize("NFKC").toLowerCase();
}
+7
View File
@@ -0,0 +1,7 @@
export function nyaize(text) {
return text// ja-JP
.replaceAll("な", "にゃ").replaceAll("ナ", "ニャ").replaceAll("ナ", "ニャ")// en-US
.replace(/(?<=n)a/gi, (x)=>x === "A" ? "YA" : "ya").replace(/(?<=morn)ing/gi, (x)=>x === "ING" ? "YAN" : "yan").replace(/(?<=every)one/gi, (x)=>x === "ONE" ? "NYAN" : "nyan").replace(/non(?=[bcdfghjklmnpqrstvwxyz])/gi, (x)=>x === "NON" ? "NYAN" : "nyan")// ko-KR
.replace(/[나-낳]/g, (match)=>String.fromCharCode(match.charCodeAt(0) + "냐".charCodeAt(0) - "나".charCodeAt(0))).replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, "다냥").replace(/(야(?=\?))|(야$)|(야(?= ))/gm, "냥")// el-GR
.replaceAll("να", "νια").replaceAll("ΝΑ", "ΝΙΑ").replaceAll("Να", "Νια");
}
+13
View File
@@ -0,0 +1,13 @@
import bcrypt from "bcryptjs";
import * as argon2 from "argon2";
export async function hashPassword(password) {
return argon2.hash(password);
}
export async function comparePassword(password, hash) {
if (isOldAlgorithm(hash)) return bcrypt.compare(password, hash);
return argon2.verify(hash, password);
}
export function isOldAlgorithm(hash) {
// bcrypt hashes start with $2[ab]$
return hash.startsWith("$2");
}
@@ -0,0 +1,285 @@
import { In, IsNull } from "typeorm";
import { DriveFiles, Emojis, UserEmojis, UserGroups, UserProfiles, Users } from "../models/index.js";
import { Cache } from "./cache.js";
import { isSelfHost, toPunyNullable } from "./convert-host.js";
import { decodeReaction } from "./reaction-lib.js";
import config from "../config/index.js";
import { query } from "../prelude/url.js";
import { redisClient } from "../db/redis.js";
import { resolveUser } from "../remote/resolve-user.js";
const cache = new Cache("populateEmojis", 60 * 60 * 12);
const userEmojiCache = new Cache("populateUserEmojis", 60 * 60 * 12);
function normalizeHost(src, noteUserHost) {
// クエリに使うホスト
let host = src === "." ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
: isSelfHost(src) ? null // 自ホスト指定
: src || noteUserHost; // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
host = toPunyNullable(host);
return host;
}
function parseEmojiStr(emojiName, noteUserHost) {
// emojiName may be of the form `emoji@host`, turn it into a suitable form
const match = emojiName.split("@");
const name = match[0];
const host = toPunyNullable(normalizeHost(match[1], noteUserHost));
return {
name,
host
};
}
function proxiedUrl(url, host) {
if (host == null) return url;
return `${config.url}/proxy/${encodeURIComponent(new URL(url).pathname)}?${query({
url
})}`;
}
function proxiedGlyphUrl(url, host) {
if (host == null) return url;
return `${config.url}/proxy/${encodeURIComponent(new URL(url).pathname)}?${query({
url,
glyph: "1"
})}`;
}
function parseUserIconEmoji(emojiName) {
const match = emojiName.match(/^@([^@:\s]+)(?:@([^@:\s]+))?$/);
if (!match) return null;
return {
username: match[1],
host: toPunyNullable(normalizeHost(match[2], null))
};
}
function parseGroupSymbolEmoji(emojiName) {
const match = emojiName.match(/^@@([a-zA-Z0-9_]{1,64})$/);
if (!match) return null;
return {
username: match[1].toLowerCase()
};
}
function parseGroupEmoji(emojiName) {
const match = emojiName.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/);
if (!match) return null;
return {
name: match[1],
username: match[2].toLowerCase()
};
}
function parseUserEmoji(emojiName) {
const parts = emojiName.split("@");
if (parts.length !== 2 && parts.length !== 3) return null;
if (!parts[0] || !parts[1]) return null;
return {
name: parts[0],
username: parts[1],
host: toPunyNullable(normalizeHost(parts[2], null))
};
}
async function findUserByAcct(username, host) {
const user = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull()
});
if (user) return user;
if (host == null) return null;
return resolveUser(username, host).catch(()=>null);
}
async function populateUserIconEmoji(emojiName) {
const parsed = parseUserIconEmoji(emojiName);
if (!parsed) return null;
const user = await findUserByAcct(parsed.username, parsed.host);
if (!user) return null;
const profile = await UserProfiles.findOneBy({
userId: user.id
});
if (profile?.symbolFileId) {
const symbol = await DriveFiles.findOneBy({
id: profile.symbolFileId
});
if (symbol) {
const symbolUrl = symbol.webpublicUrl ?? symbol.url;
return {
name: emojiName,
url: proxiedUrl(symbolUrl, user.host),
glyph: true,
glyphUrl: proxiedGlyphUrl(symbol.url, user.host),
width: null,
height: null
};
}
}
const avatarUrl = user.avatarUrl ?? await Users.getAvatarUrl(user);
return {
name: emojiName,
url: proxiedUrl(avatarUrl, user.host),
glyph: false,
glyphUrl: null,
width: null,
height: null
};
}
async function populateUserEmoji(emojiName) {
const parsed = parseUserEmoji(emojiName);
if (!parsed) return null;
const user = await findUserByAcct(parsed.username, parsed.host);
if (!user) return null;
const userEmoji = await UserEmojis.findOneBy({
name: parsed.name,
userId: user.id
});
if (!userEmoji) return null;
const emojiUrl = userEmoji.publicUrl || userEmoji.originalUrl;
return {
name: emojiName,
url: proxiedUrl(emojiUrl, user.host),
glyph: userEmoji.glyph,
glyphUrl: userEmoji.glyph ? proxiedGlyphUrl(userEmoji.originalUrl, user.host) : null,
width: userEmoji.width,
height: userEmoji.height
};
}
async function populateGroupSymbolEmoji(emojiName) {
const parsed = parseGroupSymbolEmoji(emojiName);
if (!parsed) return null;
const group = await UserGroups.findOneBy({
username: parsed.username
});
if (!group?.symbolFileId && !group?.iconFileId) return null;
const file = await DriveFiles.findOneBy({
id: group.symbolFileId ?? group.iconFileId
});
if (!file) return null;
const symbolUrl = file.webpublicUrl ?? file.url;
return {
name: emojiName,
url: proxiedUrl(symbolUrl, null),
glyph: true,
glyphUrl: proxiedGlyphUrl(file.url, null),
width: null,
height: null
};
}
async function populateGroupEmoji(emojiName) {
const parsed = parseGroupEmoji(emojiName);
if (!parsed) return null;
const group = await UserGroups.findOneBy({
username: parsed.username
});
if (!group) return null;
const groupEmoji = await UserEmojis.findOneBy({
name: parsed.name,
userGroupId: group.id
});
if (!groupEmoji) return null;
const emojiUrl = groupEmoji.publicUrl || groupEmoji.originalUrl;
return {
name: emojiName,
url: proxiedUrl(emojiUrl, null),
glyph: groupEmoji.glyph,
glyphUrl: groupEmoji.glyph ? proxiedGlyphUrl(groupEmoji.originalUrl, null) : null,
width: groupEmoji.width,
height: groupEmoji.height
};
}
/**
* 添付用絵文字情報を解決する
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
* @returns 絵文字情報, nullは未マッチを意味する
*/ export async function populateEmoji(emojiName, noteUserHost) {
const { name, host } = parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null;
const queryOrNull = async ()=>await Emojis.findOneBy({
name,
host: host ?? IsNull()
}) || null;
const cacheKey = `${name} ${host}`;
let emoji = await cache.fetch(cacheKey, queryOrNull);
if (emoji && !(emoji.width && emoji.height)) {
emoji = await queryOrNull();
await cache.set(cacheKey, emoji);
}
if (emoji == null) return null;
const isLocal = emoji.host == null;
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため
const url = proxiedUrl(emojiUrl, isLocal ? null : emoji.host);
return {
name: emojiName,
url,
glyph: emoji.glyph,
glyphUrl: emoji.glyph ? proxiedGlyphUrl(emoji.originalUrl, isLocal ? null : emoji.host) : null,
width: emoji.width,
height: emoji.height
};
}
export async function populateEmojiOrUserEmoji(emojiName, noteUserHost) {
const emoji = await populateEmoji(emojiName, noteUserHost);
if (emoji) return emoji;
const userEmoji = await userEmojiCache.fetchMaybe(`user ${emojiName}`, async ()=>await populateUserIconEmoji(emojiName) ?? await populateUserEmoji(emojiName) ?? await populateGroupSymbolEmoji(emojiName) ?? await populateGroupEmoji(emojiName) ?? undefined, false, (cached)=>cached != null);
return userEmoji ?? null;
}
export async function clearUserEmojiCache(name, username, host) {
const keys = new Set([
`user ${name}@${username}`
]);
if (host) {
keys.add(`user ${name}@${username}@${host}`);
} else {
keys.add(`user ${name}@${username}@${config.host}`);
}
await userEmojiCache.delete(...keys);
}
export async function clearGroupEmojiCache(name, groupUsername) {
if (!groupUsername) return;
await userEmojiCache.delete(`user ${name}@@${groupUsername}`, `user @@${groupUsername}`);
}
/**
* 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される)
*/ export async function populateEmojis(emojiNames, noteUserHost) {
const emojis = await Promise.all(emojiNames.map((x)=>populateEmojiOrUserEmoji(x, noteUserHost)));
return emojis.filter((x)=>x != null);
}
export function aggregateNoteEmojis(notes) {
let emojis = [];
for (const note of notes){
emojis = emojis.concat(note.emojis.map((e)=>parseEmojiStr(e, note.userHost)));
if (note.renote) {
emojis = emojis.concat(note.renote.emojis.map((e)=>parseEmojiStr(e, note.renote.userHost)));
if (note.renote.user) {
emojis = emojis.concat(note.renote.user.emojis.map((e)=>parseEmojiStr(e, note.renote.userHost)));
}
}
const customReactions = Object.keys(note.reactions).map((x)=>decodeReaction(x)).filter((x)=>x.name != null);
emojis = emojis.concat(customReactions);
if (note.user) {
emojis = emojis.concat(note.user.emojis.map((e)=>parseEmojiStr(e, note.userHost)));
}
}
return emojis.filter((x)=>x.name != null);
}
/**
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
*/ export async function prefetchEmojis(emojis) {
const notCachedEmojis = emojis.filter(async (emoji)=>!await cache.get(`${emoji.name} ${emoji.host}`));
const emojisQuery = [];
const hosts = new Set(notCachedEmojis.map((e)=>e.host));
for (const host of hosts){
emojisQuery.push({
name: In(notCachedEmojis.filter((e)=>e.host === host).map((e)=>e.name)),
host: host ?? IsNull()
});
}
const _emojis = emojisQuery.length > 0 ? await Emojis.find({
where: emojisQuery,
select: [
"name",
"host",
"originalUrl",
"publicUrl"
]
}) : [];
const trans = redisClient.multi();
for (const emoji of _emojis){
cache.set(`${emoji.name} ${emoji.host}`, emoji, trans);
}
await trans.exec();
}
+15
View File
@@ -0,0 +1,15 @@
export function parse(acct) {
return {
text: acct.text,
cw: acct.cw,
localOnly: acct.localOnly,
createdAt: new Date(acct.createdAt)
};
}
export function toJson(acct) {
return ({
text: acct.text,
cw: acct.cw,
localOnly: acct.localOnly
}).toString();
}
@@ -0,0 +1,122 @@
import * as fs from "node:fs";
import Logger from "../services/logger.js";
import { createTemp, createTempDir } from "./create-temp.js";
import { downloadUrl } from "./download-url.js";
import { addFile } from "../services/drive/add-file.js";
import { Users } from "../models/index.js";
import * as tar from "tar-stream";
import gunzip from "gunzip-maybe";
import decompress from "decompress";
import * as Path from "node:path";
const logger = new Logger("process-masto-notes");
export async function processMastoNotes(fn, url, uid) {
// Create temp file
const [path, cleanup] = await createTemp();
const [unzipPath, unzipCleanup] = await createTempDir();
logger.info(`Temp file is ${path}`);
try {
// write content at URL to temp file
await downloadUrl(url, path);
return await processMastoFile(fn, path, unzipPath, uid);
} finally{
cleanup();
//unzipCleanup();
}
}
function processMastoFile(fn, path, dir, uid) {
return new Promise(async (resolve, reject)=>{
const user = await Users.findOneBy({
id: uid
});
try {
logger.info(`Start unzip ${path}`);
fn.endsWith("tar.gz") ? await unzipTarGz(path, dir) : await unzipZip(path, dir);
logger.info(`Unzip to ${dir}`);
const outbox = JSON.parse(fs.readFileSync(`${dir}/outbox.json`));
for (const note of outbox.orderedItems){
// Skip if attachment is undefined or not iterable
if (note.object.attachment == null || !note.object.attachment[Symbol.iterator]) {
continue;
}
for (const attachment of note.object.attachment){
const url = attachment.url.replaceAll("..", "");
if (url.indexOf("\0") !== -1) {
logger.error(`Found Poison Null Bytes Attack: ${url}`);
reject();
return;
}
try {
const fpath = Path.resolve(`${dir}${url}`);
if (!fpath.startsWith(dir)) {
logger.error(`Found Path Attack: ${url}`);
reject();
return;
}
logger.info(fpath);
const driveFile = await addFile({
user: user,
path: fpath
});
attachment.driveFile = driveFile;
} catch (e) {
logger.error(`Skipped adding file to drive: ${url}`);
}
}
}
resolve(outbox);
} catch (e) {
logger.error(`Error on extract masto note package: ${fn}`);
reject(e);
}
});
}
function createFileDir(fn) {
if (!fs.existsSync(fn)) {
fs.mkdirSync(fn, {
recursive: true
});
fs.rmdirSync(fn);
}
}
function unzipZip(fn, dir) {
return new Promise(async (resolve, reject)=>{
try {
decompress(fn, dir).then((files)=>{
resolve(files);
});
} catch (e) {
reject();
}
});
}
function unzipTarGz(fn, dir) {
return new Promise(async (resolve, reject)=>{
const onErr = (err)=>{
logger.error(`pipe broken: ${err}`);
reject();
};
try {
const extract = tar.extract().on("error", onErr);
dir = dir.endsWith("/") ? dir : dir + "/";
const ls = [];
extract.on("entry", function(header, stream, next) {
try {
ls.push(dir + header.name);
createFileDir(dir + header.name);
stream.on("error", onErr).pipe(fs.createWriteStream(dir + header.name)).on("error", onErr);
next();
} catch (e) {
logger.error(`create dir error:${e}`);
reject();
}
});
extract.on("finish", function() {
resolve(ls);
});
fs.createReadStream(fn).on("error", onErr).pipe(gunzip()).on("error", onErr).pipe(extract).on("error", onErr);
} catch (e) {
logger.error(`unzipTarGz error: ${e}`);
reject();
}
});
}
+184
View File
@@ -0,0 +1,184 @@
import { emojiRegex } from "./emoji-regex.js";
import { fetchMeta } from "./fetch-meta.js";
import { Emojis, UserEmojis, UserGroups, Users } from "../models/index.js";
import { toPunyNullable } from "./convert-host.js";
import { IsNull } from "typeorm";
import { resolveUser } from "../remote/resolve-user.js";
const legacies = new Map([
[
"like",
"👍"
],
[
"love",
"❤️"
],
[
"laugh",
"😆"
],
[
"hmm",
"🤔"
],
[
"surprise",
"😮"
],
[
"congrats",
"🎉"
],
[
"angry",
"💢"
],
[
"confused",
"😥"
],
[
"rip",
"😇"
],
[
"pudding",
"🍮"
],
[
"star",
"⭐"
]
]);
export async function getFallbackReaction() {
const meta = await fetchMeta();
return meta.defaultReaction;
}
export function convertLegacyReactions(reactions) {
const _reactions = new Map();
const decodedReactions = new Map();
for(const reaction in reactions){
if (reactions[reaction] <= 0) continue;
let decodedReaction;
if (decodedReactions.has(reaction)) {
decodedReaction = decodedReactions.get(reaction);
} else {
decodedReaction = decodeReaction(reaction);
decodedReactions.set(reaction, decodedReaction);
}
let emoji = legacies.get(decodedReaction.reaction);
if (emoji) {
_reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]);
} else {
_reactions.set(reaction, (_reactions.get(reaction) || 0) + reactions[reaction]);
}
}
const _reactions2 = new Map();
for (const [reaction, count] of _reactions){
const decodedReaction = decodedReactions.get(reaction);
_reactions2.set(decodedReaction.reaction, count);
}
return Object.fromEntries(_reactions2);
}
export async function toDbReaction(reaction, reacterHost, recurse = true) {
if (!reaction) return await getFallbackReaction();
reacterHost = toPunyNullable(reacterHost);
// Convert string-type reactions to unicode
const emoji = legacies.get(reaction) || (reaction === "♥️" ? "❤️" : null);
if (emoji) return emoji;
// Allow unicode reactions
const match = emojiRegex.exec(reaction);
if (match) {
const unicode = match[0];
return unicode;
}
const custom = reaction.match(/^:([^:\s]+):$/);
if (custom) {
const decoded = decodeReaction(reaction);
if (decoded.name) {
const groupCustom = decoded.name.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/);
if (groupCustom) {
const group = await UserGroups.findOneBy({
username: groupCustom[2].toLowerCase()
});
const groupEmoji = group ? await UserEmojis.findOneBy({
name: groupCustom[1],
userGroupId: group.id,
glyph: false
}) : null;
if (groupEmoji) return `:${groupCustom[1]}@@${group.username}:`;
}
const emoji = await Emojis.findOneBy({
host: decoded.host ?? reacterHost ?? IsNull(),
name: decoded.name
});
if (emoji) return emoji.host ? `:${emoji.name}@${emoji.host}:` : `:${emoji.name}:`;
}
if (await isUserReaction(reaction)) return reaction;
}
return recurse && reacterHost == null && reaction !== null ? await toDbReaction(`:${reaction}:`, reacterHost, false) : await getFallbackReaction();
}
export function decodeReaction(str) {
const custom = str.match(/^:([^:\s]+):$/);
if (custom) {
const body = custom[1];
const parts = body.split("@");
const name = parts[0];
const host = parts.length === 2 ? parts[1] || null : null;
return {
reaction: host ? `:${name}@${host}:` : str,
name,
host
};
}
return {
reaction: str,
name: undefined,
host: undefined
};
}
async function isUserReaction(reaction) {
const body = reaction.slice(1, -1);
const icon = body.match(/^@([^@:\s]+)(?:@([^@:\s]+))?$/);
if (icon) {
return await findUser(icon[1], icon[2] ?? null) != null;
}
if (/^@@[a-zA-Z0-9_]{1,64}$/.test(body)) return false;
const groupCustom = body.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/);
if (groupCustom) {
const group = await UserGroups.findOneBy({
username: groupCustom[2].toLowerCase()
});
if (!group) return false;
return await UserEmojis.findOneBy({
name: groupCustom[1],
userGroupId: group.id,
glyph: false
}) != null;
}
const parts = body.split("@");
if (parts.length !== 2 && parts.length !== 3) return false;
const [name, username, host] = parts;
if (!name || !username) return false;
const user = await findUser(username, host ?? null);
if (!user) return false;
return await UserEmojis.findOneBy({
name,
userId: user.id
}) != null;
}
async function findUser(username, host) {
const normalizedHost = toPunyNullable(host);
const user = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: normalizedHost ?? IsNull()
});
if (user) return user;
if (normalizedHost == null) return null;
return resolveUser(username, normalizedHost).catch(()=>null);
}
export function convertLegacyReaction(reaction) {
const decoded = decodeReaction(reaction).reaction;
if (legacies.has(decoded)) return legacies.get(decoded);
return decoded;
}
@@ -0,0 +1,3 @@
export function safeForSql(text) {
return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
}
+60
View File
@@ -0,0 +1,60 @@
import { packedUserLiteSchema, packedUserDetailedNotMeOnlySchema, packedMeDetailedOnlySchema, packedUserDetailedNotMeSchema, packedMeDetailedSchema, packedUserDetailedSchema, packedUserSchema } from "../models/schema/user.js";
import { packedNoteSchema } from "../models/schema/note.js";
import { packedUserListSchema } from "../models/schema/user-list.js";
import { packedAppSchema } from "../models/schema/app.js";
import { packedMessagingMessageSchema } from "../models/schema/messaging-message.js";
import { packedNotificationSchema } from "../models/schema/notification.js";
import { packedDriveFileSchema } from "../models/schema/drive-file.js";
import { packedDriveFolderSchema } from "../models/schema/drive-folder.js";
import { packedFollowingSchema } from "../models/schema/following.js";
import { packedMutingSchema } from "../models/schema/muting.js";
import { packedRenoteMutingSchema } from "../models/schema/renote-muting.js";
import { packedBlockingSchema } from "../models/schema/blocking.js";
import { packedNoteReactionSchema } from "../models/schema/note-reaction.js";
import { packedHashtagSchema } from "../models/schema/hashtag.js";
import { packedPageSchema } from "../models/schema/page.js";
import { packedUserGroupSchema } from "../models/schema/user-group.js";
import { packedNoteFavoriteSchema } from "../models/schema/note-favorite.js";
import { packedChannelSchema } from "../models/schema/channel.js";
import { packedAntennaSchema } from "../models/schema/antenna.js";
import { packedClipSchema } from "../models/schema/clip.js";
import { packedFederationInstanceSchema } from "../models/schema/federation-instance.js";
import { packedQueueCountSchema } from "../models/schema/queue.js";
import { packedGalleryPostSchema } from "../models/schema/gallery-post.js";
import { packedEmojiSchema } from "../models/schema/emoji.js";
import { packedNoteEdit } from "../models/schema/note-edit.js";
import { packedBiteSchema } from "../models/schema/bite.js";
export const refs = {
UserLite: packedUserLiteSchema,
UserDetailedNotMeOnly: packedUserDetailedNotMeOnlySchema,
MeDetailedOnly: packedMeDetailedOnlySchema,
UserDetailedNotMe: packedUserDetailedNotMeSchema,
MeDetailed: packedMeDetailedSchema,
UserDetailed: packedUserDetailedSchema,
User: packedUserSchema,
UserList: packedUserListSchema,
UserGroup: packedUserGroupSchema,
App: packedAppSchema,
MessagingMessage: packedMessagingMessageSchema,
Note: packedNoteSchema,
NoteEdit: packedNoteEdit,
NoteReaction: packedNoteReactionSchema,
NoteFavorite: packedNoteFavoriteSchema,
Notification: packedNotificationSchema,
DriveFile: packedDriveFileSchema,
DriveFolder: packedDriveFolderSchema,
Following: packedFollowingSchema,
Muting: packedMutingSchema,
RenoteMuting: packedRenoteMutingSchema,
Blocking: packedBlockingSchema,
Hashtag: packedHashtagSchema,
Page: packedPageSchema,
Channel: packedChannelSchema,
QueueCount: packedQueueCountSchema,
Antenna: packedAntennaSchema,
Clip: packedClipSchema,
FederationInstance: packedFederationInstanceSchema,
GalleryPost: packedGalleryPostSchema,
Emoji: packedEmojiSchema,
Bite: packedBiteSchema
};
@@ -0,0 +1,13 @@
import * as crypto from "node:crypto";
const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
export function secureRndstr(length = 32) {
let str = "";
for(let i = 0; i < length; i++){
let rand = Math.floor(crypto.randomBytes(1).readUInt8(0) / 0xff * charset.length);
if (rand === charset.length) {
rand = charset.length - 1;
}
str += charset.charAt(rand);
}
return str;
}
@@ -0,0 +1,21 @@
import { fetchMeta } from "./fetch-meta.js";
/**
* Returns whether a specific host (punycoded) should be blocked.
*
* @param host punycoded instance host
* @param meta a resolved Meta table
* @returns whether the given host should be blocked
*/ export async function shouldBlockInstance(host, meta) {
const { blockedHosts } = meta ?? await fetchMeta();
return blockedHosts.some((blockedHost)=>host === blockedHost || host.endsWith(`.${blockedHost}`));
}
/**
* Returns whether a specific host (punycoded) should be limited.
*
* @param host punycoded instance host
* @param meta a resolved Meta table
* @returns whether the given host should be limited
*/ export async function shouldSilenceInstance(host, meta) {
const { silencedHosts } = meta ?? await fetchMeta();
return silencedHosts.some((silencedHost)=>host === silencedHost || host.endsWith(`.${silencedHost}`));
}
@@ -0,0 +1,11 @@
import * as os from "node:os";
import sysUtils from "systeminformation";
export async function showMachineInfo(parentLogger) {
const logger = parentLogger.createSubLogger("machine");
logger.debug(`Hostname: ${os.hostname()}`);
logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`);
const mem = await sysUtils.mem();
const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1);
const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1);
logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`);
}
@@ -0,0 +1,46 @@
import { Brackets } from "typeorm";
import { fetchMeta } from "./fetch-meta.js";
import { Instances } from "../models/index.js";
import { DAY } from "../const.js";
import { shouldBlockInstance } from "./should-block-instance.js";
// Threshold from last contact after which an instance will be considered
// "dead" and should no longer get activities delivered to it.
const deadThreshold = 7 * DAY;
/**
* Returns the subset of hosts which should be skipped.
*
* @param hosts array of punycoded instance hosts
* @returns array of punycoed instance hosts that should be skipped (subset of hosts parameter)
*/ export async function skippedInstances(hosts) {
// first check for blocked instances since that info may already be in memory
const meta = await fetchMeta();
const shouldSkip = await Promise.all(hosts.map((host)=>shouldBlockInstance(host, meta)));
const skipped = hosts.filter((_, i)=>shouldSkip[i]);
// if possible return early and skip accessing the database
if (skipped.length === hosts.length) return hosts;
const deadTime = new Date(Date.now() - deadThreshold);
return skipped.concat(await Instances.createQueryBuilder("instance").where("instance.host in (:...hosts)", {
// don't check hosts again that we already know are suspended
// also avoids adding duplicates to the list
hosts: hosts.filter((host)=>!skipped.includes(host))
}).andWhere(new Brackets((qb)=>{
qb.where("instance.isSuspended").orWhere(new Brackets((qb)=>{
qb.where("instance.isNotResponding").andWhere("instance.lastCommunicatedAt < :deadTime", {
deadTime
});
}));
})).select("host").getRawMany());
}
/**
* Returns whether a specific host (punycoded) should be skipped.
* Convenience wrapper around skippedInstances which should only be used if there is a single host to check.
* If you have multiple hosts, consider using skippedInstances instead to do a bulk check.
*
* @param host punycoded instance host
* @returns whether the given host should be skipped
*/ export async function shouldSkipInstance(host) {
const skipped = await skippedInstances([
host
]);
return skipped.length > 0;
}
@@ -0,0 +1,3 @@
export function sqlLikeEscape(s) {
return s.replace(/([%_\\])/g, "\\$1");
}
@@ -0,0 +1,3 @@
export function sqlRegexEscape(s) {
return s.replace(/([!$()*+.:<=>?[\\\]^{|}-])/g, "\\$1");
}
+8
View File
@@ -0,0 +1,8 @@
import { substring } from "stringz";
export function truncate(input, size) {
if (!input) {
return input;
} else {
return substring(input, 0, size);
}
}
@@ -0,0 +1,43 @@
import { Webhooks } from "../models/index.js";
import { subscriber } from "../db/redis.js";
let webhooksFetched = false;
let webhooks = [];
export async function getActiveWebhooks() {
if (!webhooksFetched) {
webhooks = await Webhooks.findBy({
active: true
});
webhooksFetched = true;
}
return webhooks;
}
subscriber.on("message", async (_, data)=>{
const obj = JSON.parse(data);
if (obj.channel === "internal") {
const { type, body } = obj.message;
switch(type){
case "webhookCreated":
if (body.active) {
webhooks.push(body);
}
break;
case "webhookUpdated":
if (body.active) {
const i = webhooks.findIndex((a)=>a.id === body.id);
if (i > -1) {
webhooks[i] = body;
} else {
webhooks.push(body);
}
} else {
webhooks = webhooks.filter((a)=>a.id !== body.id);
}
break;
case "webhookDeleted":
webhooks = webhooks.filter((a)=>a.id !== body.id);
break;
default:
break;
}
}
});