Merge branch 'develop' into refactor/push-notification

This commit is contained in:
naskya 2024-04-24 13:30:50 +09:00
commit 854030db3b
No known key found for this signature in database
GPG Key ID: 712D413B3A9FED5C
24 changed files with 181 additions and 167 deletions

1
Cargo.lock generated
View File

@ -167,7 +167,6 @@ dependencies = [
"argon2",
"basen",
"bcrypt",
"cfg-if",
"chrono",
"cuid2",
"emojis",

View File

@ -12,7 +12,6 @@ napi-build = "2.1.3"
argon2 = "0.5.3"
basen = "0.1.0"
bcrypt = "0.15.1"
cfg-if = "1.0.0"
chrono = "0.4.37"
convert_case = "0.6.0"
cuid2 = "0.1.2"

View File

@ -20,7 +20,6 @@ napi-derive = { workspace = true, optional = true }
argon2 = { workspace = true, features = ["std"] }
basen = { workspace = true }
bcrypt = { workspace = true }
cfg-if = { workspace = true }
chrono = { workspace = true }
cuid2 = { workspace = true }
emojis = { workspace = true }

View File

@ -264,6 +264,8 @@ export interface DecodedReaction {
export function decodeReaction(reaction: string): DecodedReaction
export function countReactions(reactions: Record<string, number>): Record<string, number>
export function toDbReaction(reaction?: string | undefined | null, host?: string | undefined | null): Promise<string>
/** Delete all entries in the "attestation_challenge" table created at more than 5 minutes ago */
export function removeOldAttestationChallenges(): Promise<void>
export interface AbuseUserReport {
id: string
createdAt: Date
@ -1120,6 +1122,8 @@ export interface Webhook {
latestSentAt: Date | null
latestStatus: number | null
}
export function watchNote(watcherId: string, noteAuthorId: string, noteId: string): Promise<void>
export function unwatchNote(watcherId: string, noteId: string): Promise<void>
export enum PushNotificationKind {
Generic = 'generic',
Chat = 'chat',
@ -1136,8 +1140,6 @@ export enum ChatEvent {
Typing = 'typing'
}
export function publishToChatStream(senderUserId: string, receiverUserId: string, kind: ChatEvent, object: any): void
/** Initializes Cuid2 generator. Must be called before any [create_id]. */
export function initIdGenerator(length: number, fingerprint: string): void
export function getTimestamp(id: string): number
/**
* The generated ID results in the form of `[8 chars timestamp] + [cuid2]`.
@ -1147,5 +1149,7 @@ export function getTimestamp(id: string): number
*
* Ref: https://github.com/paralleldrive/cuid2#parameterized-length
*/
export function genId(date?: Date | undefined | null): string
export function genId(): string
/** Generate an ID using a specific datetime */
export function genIdAt(date: Date): string
export function secureRndstr(length?: number | undefined | null): string

View File

@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, PushNotificationKind, sendPushNotification, ChatEvent, publishToChatStream, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding
const { loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, ChatEvent, publishToChatStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
module.exports.loadEnv = loadEnv
module.exports.loadConfig = loadConfig
@ -342,6 +342,7 @@ module.exports.isOldPasswordAlgorithm = isOldPasswordAlgorithm
module.exports.decodeReaction = decodeReaction
module.exports.countReactions = countReactions
module.exports.toDbReaction = toDbReaction
module.exports.removeOldAttestationChallenges = removeOldAttestationChallenges
module.exports.AntennaSrcEnum = AntennaSrcEnum
module.exports.DriveFileUsageHintEnum = DriveFileUsageHintEnum
module.exports.MutedNoteReasonEnum = MutedNoteReasonEnum
@ -353,11 +354,13 @@ module.exports.RelayStatusEnum = RelayStatusEnum
module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum
module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum
module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum
module.exports.watchNote = watchNote
module.exports.unwatchNote = unwatchNote
module.exports.PushNotificationKind = PushNotificationKind
module.exports.sendPushNotification = sendPushNotification
module.exports.ChatEvent = ChatEvent
module.exports.publishToChatStream = publishToChatStream
module.exports.initIdGenerator = initIdGenerator
module.exports.getTimestamp = getTimestamp
module.exports.genId = genId
module.exports.genIdAt = genIdAt
module.exports.secureRndstr = secureRndstr

View File

@ -1,21 +1,31 @@
use crate::database::{redis_conn, redis_key};
use crate::model::entity::note;
use crate::service::stream;
use crate::util::id::get_timestamp;
use redis::{streams::StreamMaxlen, Commands};
use crate::util::id::{get_timestamp, InvalidIdErr};
use redis::{streams::StreamMaxlen, Commands, RedisError};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Redis error: {0}")]
RedisErr(#[from] RedisError),
#[error("Invalid ID: {0}")]
InvalidIdErr(#[from] InvalidIdErr),
#[error("Stream error: {0}")]
StreamErr(#[from] stream::Error),
}
type Note = note::Model;
#[crate::export]
pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), stream::Error> {
pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error> {
// for timeline API
redis_conn()?.xadd_maxlen(
redis_key(format!("antennaTimeline:{}", antenna_id)),
StreamMaxlen::Approx(200),
format!("{}-*", get_timestamp(&note.id)),
format!("{}-*", get_timestamp(&note.id)?),
&[("note", &note.id)],
)?;
// for streaming API
stream::antenna::publish(antenna_id, note)
Ok(stream::antenna::publish(antenna_id, note)?)
}

View File

@ -13,3 +13,4 @@ pub mod nyaify;
pub mod password;
pub mod reaction;
pub mod redis_cache;
pub mod remove_old_attestation_challenges;

View File

@ -0,0 +1,17 @@
// TODO: We want to get rid of this
use crate::database::db_conn;
use crate::model::entity::attestation_challenge;
use chrono::{Duration, Utc};
use sea_orm::{ColumnTrait, DbErr, EntityTrait, QueryFilter};
/// Delete all entries in the "attestation_challenge" table created at more than 5 minutes ago
#[crate::export]
pub async fn remove_old_attestation_challenges() -> Result<(), DbErr> {
attestation_challenge::Entity::delete_many()
.filter(attestation_challenge::Column::CreatedAt.lt(Utc::now() - Duration::minutes(5)))
.exec(db_conn().await?)
.await?;
Ok(())
}

View File

@ -1,2 +1,3 @@
pub mod note;
pub mod push_notification;
pub mod stream;

View File

@ -0,0 +1 @@
pub mod watch;

View File

@ -0,0 +1,42 @@
use crate::database::db_conn;
use crate::model::entity::note_watching;
use crate::util::id::gen_id;
use sea_orm::{ActiveValue, ColumnTrait, DbErr, EntityTrait, ModelTrait, QueryFilter};
#[crate::export]
pub async fn watch_note(
watcher_id: &str,
note_author_id: &str,
note_id: &str,
) -> Result<(), DbErr> {
if watcher_id != note_author_id {
note_watching::Entity::insert(note_watching::ActiveModel {
id: ActiveValue::set(gen_id()),
created_at: ActiveValue::set(chrono::Local::now().naive_local()),
user_id: ActiveValue::Set(watcher_id.to_string()),
note_user_id: ActiveValue::Set(note_author_id.to_string()),
note_id: ActiveValue::Set(note_id.to_string()),
})
.exec(db_conn().await?)
.await?;
}
Ok(())
}
#[crate::export]
pub async fn unwatch_note(watcher_id: &str, note_id: &str) -> Result<(), DbErr> {
let db = db_conn().await?;
let entry = note_watching::Entity::find()
.filter(note_watching::Column::UserId.eq(watcher_id))
.filter(note_watching::Column::NoteId.eq(note_id))
.one(db)
.await?;
if let Some(entry) = entry {
entry.delete(db).await?;
}
Ok(())
}

View File

@ -1,95 +1,109 @@
//! ID generation utility based on [cuid2]
use crate::config::CONFIG;
use basen::BASE36;
use cfg_if::cfg_if;
use chrono::NaiveDateTime;
use chrono::{DateTime, NaiveDateTime, Utc};
use once_cell::sync::OnceCell;
use std::cmp;
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
#[error("ID generator has not been initialized yet")]
pub struct ErrorUninitialized;
static FINGERPRINT: OnceCell<String> = OnceCell::new();
static GENERATOR: OnceCell<cuid2::CuidConstructor> = OnceCell::new();
const TIME_2000: i64 = 946_684_800_000;
const TIMESTAMP_LENGTH: u16 = 8;
const TIMESTAMP_LENGTH: u8 = 8;
/// Initializes Cuid2 generator. Must be called before any [create_id].
#[crate::export]
pub fn init_id_generator(length: u16, fingerprint: &str) {
/// Initializes Cuid2 generator.
fn init_id_generator(length: u8, fingerprint: &str) {
FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint, cuid2::create_id()));
GENERATOR.get_or_init(move || {
cuid2::CuidConstructor::new()
// length to pass shoule be greater than or equal to 8.
.with_length(cmp::max(length - TIMESTAMP_LENGTH, 8))
.with_length(cmp::max(length - TIMESTAMP_LENGTH, 8).into())
.with_fingerprinter(|| FINGERPRINT.get().unwrap().clone())
});
}
/// Returns Cuid2 with the length specified by [init_id]. Must be called after
/// [init_id], otherwise returns [ErrorUninitialized].
pub fn create_id(datetime: &NaiveDateTime) -> Result<String, ErrorUninitialized> {
match GENERATOR.get() {
None => Err(ErrorUninitialized),
Some(gen) => {
let date_num = cmp::max(0, datetime.and_utc().timestamp_millis() - TIME_2000) as u64;
Ok(format!(
"{:0>8}{}",
BASE36.encode_var_len(&date_num),
gen.create_id()
))
}
/// Returns Cuid2 with the length specified by [init_id_generator].
/// It automatically calls [init_id_generator], if the generator has not been initialized.
fn create_id(datetime: &NaiveDateTime) -> String {
if GENERATOR.get().is_none() {
let length = match &CONFIG.cuid {
Some(cuid) => cmp::min(cmp::max(cuid.length.unwrap_or(16), 16), 24),
None => 16,
};
let fingerprint = match &CONFIG.cuid {
Some(cuid) => cuid.fingerprint.as_deref().unwrap_or_default(),
None => "",
};
init_id_generator(length, fingerprint);
}
let date_num = cmp::max(0, datetime.and_utc().timestamp_millis() - TIME_2000) as u64;
format!(
"{:0>8}{}",
BASE36.encode_var_len(&date_num),
GENERATOR.get().unwrap().create_id()
)
}
#[derive(thiserror::Error, Debug)]
#[error("Invalid ID: {id}")]
pub struct InvalidIdErr {
id: String,
}
#[crate::export]
pub fn get_timestamp(id: &str) -> i64 {
pub fn get_timestamp(id: &str) -> Result<i64, InvalidIdErr> {
let n: Option<u64> = BASE36.decode_var_len(&id[0..8]);
match n {
None => -1,
Some(n) => n as i64 + TIME_2000,
if let Some(n) = n {
Ok(n as i64 + TIME_2000)
} else {
Err(InvalidIdErr { id: id.to_string() })
}
}
cfg_if! {
if #[cfg(feature = "napi")] {
use chrono::{DateTime, Utc};
/// 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
#[crate::export]
pub fn gen_id() -> String {
create_id(&Utc::now().naive_utc())
}
/// 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
#[napi_derive::napi]
pub fn gen_id(date: Option<DateTime<Utc>>) -> String {
create_id(&date.unwrap_or_else(Utc::now).naive_utc()).unwrap()
}
}
/// Generate an ID using a specific datetime
#[crate::export]
pub fn gen_id_at(date: DateTime<Utc>) -> String {
create_id(&date.naive_utc())
}
#[cfg(test)]
mod unit_test {
use crate::util::id;
use chrono::Utc;
use super::{gen_id, gen_id_at, get_timestamp};
use chrono::{Duration, Utc};
use pretty_assertions::{assert_eq, assert_ne};
use std::thread;
#[test]
fn can_create_and_decode_id() {
let now = Utc::now().naive_utc();
assert_eq!(id::create_id(&now), Err(id::ErrorUninitialized));
id::init_id_generator(16, "");
assert_eq!(id::create_id(&now).unwrap().len(), 16);
assert_ne!(id::create_id(&now).unwrap(), id::create_id(&now).unwrap());
let id1 = thread::spawn(move || id::create_id(&now).unwrap());
let id2 = thread::spawn(move || id::create_id(&now).unwrap());
let now = Utc::now();
assert_eq!(gen_id().len(), 16);
assert_ne!(gen_id_at(now), gen_id_at(now));
assert_ne!(gen_id(), gen_id());
let id1 = thread::spawn(move || gen_id_at(now));
let id2 = thread::spawn(move || gen_id_at(now));
assert_ne!(id1.join().unwrap(), id2.join().unwrap());
let test_id = id::create_id(&now).unwrap();
let timestamp = id::get_timestamp(&test_id);
assert_eq!(now.and_utc().timestamp_millis(), timestamp);
let test_id = gen_id_at(now);
let timestamp = get_timestamp(&test_id).unwrap();
assert_eq!(now.timestamp_millis(), timestamp);
let now_id = gen_id_at(now);
let old_id = gen_id_at(now - Duration::milliseconds(1));
let future_id = gen_id_at(now + Duration::milliseconds(1));
assert!(old_id < now_id);
assert!(now_id < future_id);
}
}

View File

@ -9,7 +9,7 @@ import semver from "semver";
import Logger from "@/services/logger.js";
import type { Config } from "backend-rs";
import { fetchMeta } from "backend-rs";
import { fetchMeta, removeOldAttestationChallenges } from "backend-rs";
import { config, envOption } from "@/config.js";
import { showMachineInfo } from "@/misc/show-machine-info.js";
import { db, initDb } from "@/db/postgre.js";
@ -115,18 +115,14 @@ export async function masterMain() {
true,
);
if (
!envOption.noDaemons &&
config.clusterLimits?.web &&
config.clusterLimits?.web >= 1
) {
if (!envOption.noDaemons) {
import("../daemons/server-stats.js").then((x) => x.default());
import("../daemons/queue-stats.js").then((x) => x.default());
import("../daemons/janitor.js").then((x) => x.default());
// Update meta cache every 5 minitues
setInterval(() => fetchMeta(false), 1000 * 60 * 5);
// Remove old attestation challenges
setInterval(() => removeOldAttestationChallenges(), 1000 * 60 * 30);
}
// Update meta cache every 5 minitues
setInterval(() => fetchMeta(false), 1000 * 60 * 5);
}
function showEnvironment(): void {

View File

@ -1,17 +1,11 @@
import cluster from "node:cluster";
import { config } from "@/config.js";
import { initDb } from "@/db/postgre.js";
import { initIdGenerator } from "backend-rs";
import os from "node:os";
/**
* Init worker process
*/
export async function workerMain() {
const length = Math.min(Math.max(config.cuid?.length ?? 16, 16), 24);
const fingerprint = config.cuid?.fingerprint ?? "";
initIdGenerator(length, fingerprint);
await initDb();
if (!process.env.mode || process.env.mode === "web") {

View File

@ -1,20 +0,0 @@
// TODO: 消したい
const interval = 30 * 60 * 1000;
import { AttestationChallenges } from "@/models/index.js";
import { LessThan } from "typeorm";
/**
* Clean up database occasionally
*/
export default function () {
async function tick() {
await AttestationChallenges.delete({
createdAt: LessThan(new Date(Date.now() - 5 * 60 * 1000)),
});
}
tick();
setInterval(tick, interval);
}

View File

@ -1,5 +1,4 @@
import * as fs from "node:fs";
import * as util from "node:util";
import * as fs from "node:fs/promises";
import Logger from "@/services/logger.js";
import { createTemp } from "./create-temp.js";
import { downloadUrl } from "./download-url.js";
@ -16,7 +15,7 @@ export async function downloadTextFile(url: string): Promise<string> {
// write content at URL to temp file
await downloadUrl(url, path);
const text = await util.promisify(fs.readFile)(path, "utf8");
const text = await fs.readFile(path, "utf-8");
return text;
} finally {

View File

@ -1,6 +1,5 @@
import * as fs from "node:fs";
import * as stream from "node:stream";
import * as util from "node:util";
import * as stream from "node:stream/promises";
import got, * as Got from "got";
import { config } from "@/config.js";
import { getAgentByHostname, StatusError } from "./fetch.js";
@ -10,8 +9,6 @@ import IPCIDR from "ip-cidr";
import PrivateIp from "private-ip";
import { isValidUrl } from "./is-valid-url.js";
const pipeline = util.promisify(stream.pipeline);
export async function downloadUrl(url: string, path: string): Promise<void> {
if (!isValidUrl(url)) {
throw new StatusError("Invalid URL", 400);
@ -84,7 +81,7 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
});
try {
await pipeline(req, fs.createWriteStream(path));
await stream.pipeline(req, fs.createWriteStream(path));
} catch (e) {
if (e instanceof Got.HTTPError) {
throw new StatusError(

View File

@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as fs from "node:fs/promises";
import { createReadStream } from "node:fs";
import * as crypto from "node:crypto";
import * as stream from "node:stream";
import * as util from "node:util";
import * as stream from "node:stream/promises";
import { fileTypeFromFile } from "file-type";
import probeImageSize from "probe-image-size";
import isSvg from "is-svg";
@ -9,8 +9,6 @@ import sharp from "sharp";
import { encode } from "blurhash";
import { inspect } from "node:util";
const pipeline = util.promisify(stream.pipeline);
export type FileInfo = {
size: number;
md5: string;
@ -163,7 +161,7 @@ export async function checkSvg(path: string) {
try {
const size = await getFileSize(path);
if (size > 1 * 1024 * 1024) return false;
return isSvg(fs.readFileSync(path));
return isSvg(await fs.readFile(path, "utf-8"));
} catch {
return false;
}
@ -173,8 +171,7 @@ export async function checkSvg(path: string) {
* Get file size
*/
export async function getFileSize(path: string): Promise<number> {
const getStat = util.promisify(fs.stat);
return (await getStat(path)).size;
return (await fs.stat(path)).size;
}
/**
@ -182,7 +179,7 @@ export async function getFileSize(path: string): Promise<number> {
*/
async function calcHash(path: string): Promise<string> {
const hash = crypto.createHash("md5").setEncoding("hex");
await pipeline(fs.createReadStream(path), hash);
await stream.pipeline(createReadStream(path), hash);
return hash.read();
}
@ -196,7 +193,7 @@ async function detectImageSize(path: string): Promise<{
hUnits: string;
orientation?: number;
}> {
const readable = fs.createReadStream(path);
const readable = createReadStream(path);
const imageSize = await probeImageSize(readable);
readable.destroy();
return imageSize;
@ -214,7 +211,7 @@ function getBlurhash(path: string): Promise<string> {
.toBuffer((err, buffer, { width, height }) => {
if (err) return reject(err);
let hash;
let hash: string;
try {
hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7);

View File

@ -1,10 +0,0 @@
import type { Note } from "@/models/entities/note.js";
export default function (note: Note): boolean {
return (
note.renoteId != null &&
(note.text != null ||
note.hasPoll ||
(note.fileIds != null && note.fileIds.length > 0))
);
}

View File

@ -1,4 +1,4 @@
import watch from "@/services/note/watch.js";
import { watchNote } from "backend-rs";
import define from "@/server/api/define.js";
import { getNote } from "@/server/api/common/getters.js";
import { ApiError } from "@/server/api/error.js";
@ -34,5 +34,5 @@ export default define(meta, paramDef, async (ps, user) => {
throw err;
});
await watch(user.id, note);
await watchNote(user.id, note.userId, note.id);
});

View File

@ -1,4 +1,4 @@
import unwatch from "@/services/note/unwatch.js";
import { unwatchNote } from "backend-rs";
import define from "@/server/api/define.js";
import { getNote } from "@/server/api/common/getters.js";
import { ApiError } from "@/server/api/error.js";
@ -34,5 +34,5 @@ export default define(meta, paramDef, async (ps, user) => {
throw err;
});
await unwatch(user.id, note);
await unwatchNote(user.id, note.id);
});

View File

@ -47,6 +47,7 @@ import {
addNoteToAntenna,
checkWordMute,
genId,
genIdAt,
isSilencedServer,
} from "backend-rs";
import { countSameRenotes } from "@/misc/count-same-renotes.js";
@ -711,7 +712,7 @@ async function insertNote(
data.createdAt = new Date();
}
const insert = new Note({
id: genId(data.createdAt),
id: genIdAt(data.createdAt),
createdAt: data.createdAt,
fileIds: data.files ? data.files.map((file) => file.id) : [],
replyId: data.reply ? data.reply.id : null,

View File

@ -1,10 +0,0 @@
import type { User } from "@/models/entities/user.js";
import { NoteWatchings } from "@/models/index.js";
import type { Note } from "@/models/entities/note.js";
export default async (me: User["id"], note: Note) => {
await NoteWatchings.delete({
noteId: note.id,
userId: me,
});
};

View File

@ -1,20 +0,0 @@
import type { User } from "@/models/entities/user.js";
import type { Note } from "@/models/entities/note.js";
import { NoteWatchings } from "@/models/index.js";
import { genId } from "backend-rs";
import type { NoteWatching } from "@/models/entities/note-watching.js";
export default async (me: User["id"], note: Note) => {
// 自分の投稿はwatchできない
if (me === note.userId) {
return;
}
await NoteWatchings.insert({
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: me,
noteUserId: note.userId,
} as NoteWatching);
};