Fixed 267U.pre2
This commit is contained in:
@@ -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).
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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("Να", "Νια");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user