refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View File

@ -0,0 +1,54 @@
import { Antenna } from '@/models/entities/antenna';
import { Note } from '@/models/entities/note';
import { AntennaNotes, Mutings, Notes } from '@/models/index';
import { genId } from '@/misc/gen-id';
import { isMutedUserRelated } from '@/misc/is-muted-user-related';
import { publishAntennaStream, publishMainStream } from '@/services/stream';
import { User } from '@/models/entities/user';
export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }) {
// 通知しない設定になっているか、自分自身の投稿なら既読にする
const read = !antenna.notify || (antenna.userId === noteUser.id);
AntennaNotes.insert({
id: genId(),
antennaId: antenna.id,
noteId: note.id,
read: read,
});
publishAntennaStream(antenna.id, 'note', note);
if (!read) {
const mutings = await Mutings.find({
where: {
muterId: antenna.userId
},
select: ['muteeId']
});
// Copy
const _note: Note = {
...note
};
if (note.replyId != null) {
_note.reply = await Notes.findOneOrFail(note.replyId);
}
if (note.renoteId != null) {
_note.renote = await Notes.findOneOrFail(note.renoteId);
}
if (isMutedUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) {
return;
}
// 2秒経っても既読にならなかったら通知
setTimeout(async () => {
const unread = await AntennaNotes.findOne({ antennaId: antenna.id, read: false });
if (unread) {
publishMainStream(antenna.userId, 'unreadAntenna', antenna);
}
}, 2000);
}
}

View File

@ -0,0 +1,129 @@
import { publishMainStream, publishUserEvent } from '@/services/stream';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import renderUndo from '@/remote/activitypub/renderer/undo';
import renderBlock from '@/remote/activitypub/renderer/block';
import { deliver } from '@/queue/index';
import renderReject from '@/remote/activitypub/renderer/reject';
import { User } from '@/models/entities/user';
import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLists } from '@/models/index';
import { perUserFollowingChart } from '@/services/chart/index';
import { genId } from '@/misc/gen-id';
import { IdentifiableError } from '@/misc/identifiable-error';
export default async function(blocker: User, blockee: User) {
await Promise.all([
cancelRequest(blocker, blockee),
cancelRequest(blockee, blocker),
unFollow(blocker, blockee),
unFollow(blockee, blocker),
removeFromList(blockee, blocker),
]);
await Blockings.insert({
id: genId(),
createdAt: new Date(),
blockerId: blocker.id,
blockeeId: blockee.id,
});
if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) {
const content = renderActivity(renderBlock(blocker, blockee));
deliver(blocker, content, blockee.inbox);
}
}
async function cancelRequest(follower: User, followee: User) {
const request = await FollowRequests.findOne({
followeeId: followee.id,
followerId: follower.id
});
if (request == null) {
return;
}
await FollowRequests.delete({
followeeId: followee.id,
followerId: follower.id
});
if (Users.isLocalUser(followee)) {
Users.pack(followee, followee, {
detail: true
}).then(packed => publishMainStream(followee.id, 'meUpdated', packed));
}
if (Users.isLocalUser(follower)) {
Users.pack(followee, follower, {
detail: true
}).then(packed => {
publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed);
});
}
// リモートにフォローリクエストをしていたらUndoFollow送信
if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));
deliver(follower, content, followee.inbox);
}
// リモートからフォローリクエストを受けていたらReject送信
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
const content = renderActivity(renderReject(renderFollow(follower, followee, request.requestId!), followee));
deliver(followee, content, follower.inbox);
}
}
async function unFollow(follower: User, followee: User) {
const following = await Followings.findOne({
followerId: follower.id,
followeeId: followee.id
});
if (following == null) {
return;
}
Followings.delete(following.id);
//#region Decrement following count
Users.decrement({ id: follower.id }, 'followingCount', 1);
//#endregion
//#region Decrement followers count
Users.decrement({ id: followee.id }, 'followersCount', 1);
//#endregion
perUserFollowingChart.update(follower, followee, false);
// Publish unfollow event
if (Users.isLocalUser(follower)) {
Users.pack(followee, follower, {
detail: true
}).then(packed => {
publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed);
});
}
// リモートにフォローをしていたらUndoFollow送信
if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));
deliver(follower, content, followee.inbox);
}
}
async function removeFromList(listOwner: User, user: User) {
const userLists = await UserLists.find({
userId: listOwner.id,
});
for (const userList of userLists) {
await UserListJoinings.delete({
userListId: userList.id,
userId: user.id,
});
}
}

View File

@ -0,0 +1,29 @@
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderBlock from '@/remote/activitypub/renderer/block';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { deliver } from '@/queue/index';
import Logger from '../logger';
import { User } from '@/models/entities/user';
import { Blockings, Users } from '@/models/index';
const logger = new Logger('blocking/delete');
export default async function(blocker: User, blockee: User) {
const blocking = await Blockings.findOne({
blockerId: blocker.id,
blockeeId: blockee.id
});
if (blocking == null) {
logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした');
return;
}
Blockings.delete(blocking.id);
// deliver if remote bloking
if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) {
const content = renderActivity(renderUndo(renderBlock(blocker, blockee), blocker));
deliver(blocker, content, blockee.inbox);
}
}

View File

@ -0,0 +1,47 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { User } from '@/models/entities/user';
import { SchemaType } from '@/misc/schema';
import { Users } from '@/models/index';
import { name, schema } from '../schemas/active-users';
type ActiveUsersLog = SchemaType<typeof schema>;
export default class ActiveUsersChart extends Chart<ActiveUsersLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: ActiveUsersLog): DeepPartial<ActiveUsersLog> {
return {};
}
@autobind
protected aggregate(logs: ActiveUsersLog[]): ActiveUsersLog {
return {
local: {
users: logs.reduce((a, b) => a.concat(b.local.users), [] as ActiveUsersLog['local']['users']),
},
remote: {
users: logs.reduce((a, b) => a.concat(b.remote.users), [] as ActiveUsersLog['remote']['users']),
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<ActiveUsersLog>> {
return {};
}
@autobind
public async update(user: { id: User['id'], host: User['host'] }) {
const update: Obj = {
users: [user.id]
};
await this.inc({
[Users.isLocalUser(user) ? 'local' : 'remote']: update
});
}
}

View File

@ -0,0 +1,91 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { DriveFiles } from '@/models/index';
import { Not, IsNull } from 'typeorm';
import { DriveFile } from '@/models/entities/drive-file';
import { name, schema } from '../schemas/drive';
type DriveLog = SchemaType<typeof schema>;
export default class DriveChart extends Chart<DriveLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: DriveLog): DeepPartial<DriveLog> {
return {
local: {
totalCount: latest.local.totalCount,
totalSize: latest.local.totalSize,
},
remote: {
totalCount: latest.remote.totalCount,
totalSize: latest.remote.totalSize,
}
};
}
@autobind
protected aggregate(logs: DriveLog[]): DriveLog {
return {
local: {
totalCount: logs[0].local.totalCount,
totalSize: logs[0].local.totalSize,
incCount: logs.reduce((a, b) => a + b.local.incCount, 0),
incSize: logs.reduce((a, b) => a + b.local.incSize, 0),
decCount: logs.reduce((a, b) => a + b.local.decCount, 0),
decSize: logs.reduce((a, b) => a + b.local.decSize, 0),
},
remote: {
totalCount: logs[0].remote.totalCount,
totalSize: logs[0].remote.totalSize,
incCount: logs.reduce((a, b) => a + b.remote.incCount, 0),
incSize: logs.reduce((a, b) => a + b.remote.incSize, 0),
decCount: logs.reduce((a, b) => a + b.remote.decCount, 0),
decSize: logs.reduce((a, b) => a + b.remote.decSize, 0),
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<DriveLog>> {
const [localCount, remoteCount, localSize, remoteSize] = await Promise.all([
DriveFiles.count({ userHost: null }),
DriveFiles.count({ userHost: Not(IsNull()) }),
DriveFiles.calcDriveUsageOfLocal(),
DriveFiles.calcDriveUsageOfRemote()
]);
return {
local: {
totalCount: localCount,
totalSize: localSize,
},
remote: {
totalCount: remoteCount,
totalSize: remoteSize,
}
};
}
@autobind
public async update(file: DriveFile, isAdditional: boolean) {
const update: Obj = {};
update.totalCount = isAdditional ? 1 : -1;
update.totalSize = isAdditional ? file.size : -file.size;
if (isAdditional) {
update.incCount = 1;
update.incSize = file.size;
} else {
update.decCount = 1;
update.decSize = file.size;
}
await this.inc({
[file.userHost === null ? 'local' : 'remote']: update
});
}
}

View File

@ -0,0 +1,62 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { Instances } from '@/models/index';
import { name, schema } from '../schemas/federation';
type FederationLog = SchemaType<typeof schema>;
export default class FederationChart extends Chart<FederationLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: FederationLog): DeepPartial<FederationLog> {
return {
instance: {
total: latest.instance.total,
}
};
}
@autobind
protected aggregate(logs: FederationLog[]): FederationLog {
return {
instance: {
total: logs[0].instance.total,
inc: logs.reduce((a, b) => a + b.instance.inc, 0),
dec: logs.reduce((a, b) => a + b.instance.dec, 0),
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<FederationLog>> {
const [total] = await Promise.all([
Instances.count({})
]);
return {
instance: {
total: total,
}
};
}
@autobind
public async update(isAdditional: boolean) {
const update: Obj = {};
update.total = isAdditional ? 1 : -1;
if (isAdditional) {
update.inc = 1;
} else {
update.dec = 1;
}
await this.inc({
instance: update
});
}
}

View File

@ -0,0 +1,47 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { User } from '@/models/entities/user';
import { SchemaType } from '@/misc/schema';
import { Users } from '@/models/index';
import { name, schema } from '../schemas/hashtag';
type HashtagLog = SchemaType<typeof schema>;
export default class HashtagChart extends Chart<HashtagLog> {
constructor() {
super(name, schema, true);
}
@autobind
protected genNewLog(latest: HashtagLog): DeepPartial<HashtagLog> {
return {};
}
@autobind
protected aggregate(logs: HashtagLog[]): HashtagLog {
return {
local: {
users: logs.reduce((a, b) => a.concat(b.local.users), [] as HashtagLog['local']['users']),
},
remote: {
users: logs.reduce((a, b) => a.concat(b.remote.users), [] as HashtagLog['remote']['users']),
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<HashtagLog>> {
return {};
}
@autobind
public async update(hashtag: string, user: { id: User['id'], host: User['host'] }) {
const update: Obj = {
users: [user.id]
};
await this.inc({
[Users.isLocalUser(user) ? 'local' : 'remote']: update
}, hashtag);
}
}

View File

@ -0,0 +1,217 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { DriveFiles, Followings, Users, Notes } from '@/models/index';
import { DriveFile } from '@/models/entities/drive-file';
import { name, schema } from '../schemas/instance';
import { Note } from '@/models/entities/note';
import { toPuny } from '@/misc/convert-host';
type InstanceLog = SchemaType<typeof schema>;
export default class InstanceChart extends Chart<InstanceLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: InstanceLog): DeepPartial<InstanceLog> {
return {
notes: {
total: latest.notes.total,
},
users: {
total: latest.users.total,
},
following: {
total: latest.following.total,
},
followers: {
total: latest.followers.total,
},
drive: {
totalFiles: latest.drive.totalFiles,
totalUsage: latest.drive.totalUsage,
}
};
}
@autobind
protected aggregate(logs: InstanceLog[]): InstanceLog {
return {
requests: {
failed: logs.reduce((a, b) => a + b.requests.failed, 0),
succeeded: logs.reduce((a, b) => a + b.requests.succeeded, 0),
received: logs.reduce((a, b) => a + b.requests.received, 0),
},
notes: {
total: logs[0].notes.total,
inc: logs.reduce((a, b) => a + b.notes.inc, 0),
dec: logs.reduce((a, b) => a + b.notes.dec, 0),
diffs: {
reply: logs.reduce((a, b) => a + b.notes.diffs.reply, 0),
renote: logs.reduce((a, b) => a + b.notes.diffs.renote, 0),
normal: logs.reduce((a, b) => a + b.notes.diffs.normal, 0),
},
},
users: {
total: logs[0].users.total,
inc: logs.reduce((a, b) => a + b.users.inc, 0),
dec: logs.reduce((a, b) => a + b.users.dec, 0),
},
following: {
total: logs[0].following.total,
inc: logs.reduce((a, b) => a + b.following.inc, 0),
dec: logs.reduce((a, b) => a + b.following.dec, 0),
},
followers: {
total: logs[0].followers.total,
inc: logs.reduce((a, b) => a + b.followers.inc, 0),
dec: logs.reduce((a, b) => a + b.followers.dec, 0),
},
drive: {
totalFiles: logs[0].drive.totalFiles,
totalUsage: logs[0].drive.totalUsage,
incFiles: logs.reduce((a, b) => a + b.drive.incFiles, 0),
incUsage: logs.reduce((a, b) => a + b.drive.incUsage, 0),
decFiles: logs.reduce((a, b) => a + b.drive.decFiles, 0),
decUsage: logs.reduce((a, b) => a + b.drive.decUsage, 0),
},
};
}
@autobind
protected async fetchActual(group: string): Promise<DeepPartial<InstanceLog>> {
const [
notesCount,
usersCount,
followingCount,
followersCount,
driveFiles,
driveUsage,
] = await Promise.all([
Notes.count({ userHost: group }),
Users.count({ host: group }),
Followings.count({ followerHost: group }),
Followings.count({ followeeHost: group }),
DriveFiles.count({ userHost: group }),
DriveFiles.calcDriveUsageOfHost(group),
]);
return {
notes: {
total: notesCount,
},
users: {
total: usersCount,
},
following: {
total: followingCount,
},
followers: {
total: followersCount,
},
drive: {
totalFiles: driveFiles,
totalUsage: driveUsage,
}
};
}
@autobind
public async requestReceived(host: string) {
await this.inc({
requests: {
received: 1
}
}, toPuny(host));
}
@autobind
public async requestSent(host: string, isSucceeded: boolean) {
const update: Obj = {};
if (isSucceeded) {
update.succeeded = 1;
} else {
update.failed = 1;
}
await this.inc({
requests: update
}, toPuny(host));
}
@autobind
public async newUser(host: string) {
await this.inc({
users: {
total: 1,
inc: 1
}
}, toPuny(host));
}
@autobind
public async updateNote(host: string, note: Note, isAdditional: boolean) {
const diffs = {} as any;
if (note.replyId != null) {
diffs.reply = isAdditional ? 1 : -1;
} else if (note.renoteId != null) {
diffs.renote = isAdditional ? 1 : -1;
} else {
diffs.normal = isAdditional ? 1 : -1;
}
await this.inc({
notes: {
total: isAdditional ? 1 : -1,
inc: isAdditional ? 1 : 0,
dec: isAdditional ? 0 : 1,
diffs: diffs
}
}, toPuny(host));
}
@autobind
public async updateFollowing(host: string, isAdditional: boolean) {
await this.inc({
following: {
total: isAdditional ? 1 : -1,
inc: isAdditional ? 1 : 0,
dec: isAdditional ? 0 : 1,
}
}, toPuny(host));
}
@autobind
public async updateFollowers(host: string, isAdditional: boolean) {
await this.inc({
followers: {
total: isAdditional ? 1 : -1,
inc: isAdditional ? 1 : 0,
dec: isAdditional ? 0 : 1,
}
}, toPuny(host));
}
@autobind
public async updateDrive(file: DriveFile, isAdditional: boolean) {
const update: Obj = {};
update.totalFiles = isAdditional ? 1 : -1;
update.totalUsage = isAdditional ? file.size : -file.size;
if (isAdditional) {
update.incFiles = 1;
update.incUsage = file.size;
} else {
update.decFiles = 1;
update.decUsage = file.size;
}
await this.inc({
drive: update
}, file.userHost);
}
}

View File

@ -0,0 +1,45 @@
import autobind from 'autobind-decorator';
import Chart, { DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { name, schema } from '../schemas/network';
type NetworkLog = SchemaType<typeof schema>;
export default class NetworkChart extends Chart<NetworkLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: NetworkLog): DeepPartial<NetworkLog> {
return {};
}
@autobind
protected aggregate(logs: NetworkLog[]): NetworkLog {
return {
incomingRequests: logs.reduce((a, b) => a + b.incomingRequests, 0),
outgoingRequests: logs.reduce((a, b) => a + b.outgoingRequests, 0),
totalTime: logs.reduce((a, b) => a + b.totalTime, 0),
incomingBytes: logs.reduce((a, b) => a + b.incomingBytes, 0),
outgoingBytes: logs.reduce((a, b) => a + b.outgoingBytes, 0),
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<NetworkLog>> {
return {};
}
@autobind
public async update(incomingRequests: number, time: number, incomingBytes: number, outgoingBytes: number) {
const inc: DeepPartial<NetworkLog> = {
incomingRequests: incomingRequests,
totalTime: time,
incomingBytes: incomingBytes,
outgoingBytes: outgoingBytes
};
await this.inc(inc);
}
}

View File

@ -0,0 +1,97 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { Notes } from '@/models/index';
import { Not, IsNull } from 'typeorm';
import { Note } from '@/models/entities/note';
import { name, schema } from '../schemas/notes';
type NotesLog = SchemaType<typeof schema>;
export default class NotesChart extends Chart<NotesLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: NotesLog): DeepPartial<NotesLog> {
return {
local: {
total: latest.local.total,
},
remote: {
total: latest.remote.total,
}
};
}
@autobind
protected aggregate(logs: NotesLog[]): NotesLog {
return {
local: {
total: logs[0].local.total,
inc: logs.reduce((a, b) => a + b.local.inc, 0),
dec: logs.reduce((a, b) => a + b.local.dec, 0),
diffs: {
reply: logs.reduce((a, b) => a + b.local.diffs.reply, 0),
renote: logs.reduce((a, b) => a + b.local.diffs.renote, 0),
normal: logs.reduce((a, b) => a + b.local.diffs.normal, 0),
},
},
remote: {
total: logs[0].remote.total,
inc: logs.reduce((a, b) => a + b.remote.inc, 0),
dec: logs.reduce((a, b) => a + b.remote.dec, 0),
diffs: {
reply: logs.reduce((a, b) => a + b.remote.diffs.reply, 0),
renote: logs.reduce((a, b) => a + b.remote.diffs.renote, 0),
normal: logs.reduce((a, b) => a + b.remote.diffs.normal, 0),
},
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<NotesLog>> {
const [localCount, remoteCount] = await Promise.all([
Notes.count({ userHost: null }),
Notes.count({ userHost: Not(IsNull()) })
]);
return {
local: {
total: localCount,
},
remote: {
total: remoteCount,
}
};
}
@autobind
public async update(note: Note, isAdditional: boolean) {
const update: Obj = {
diffs: {}
};
update.total = isAdditional ? 1 : -1;
if (isAdditional) {
update.inc = 1;
} else {
update.dec = 1;
}
if (note.replyId != null) {
update.diffs.reply = isAdditional ? 1 : -1;
} else if (note.renoteId != null) {
update.diffs.renote = isAdditional ? 1 : -1;
} else {
update.diffs.normal = isAdditional ? 1 : -1;
}
await this.inc({
[note.userHost === null ? 'local' : 'remote']: update
});
}
}

View File

@ -0,0 +1,64 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { DriveFiles } from '@/models/index';
import { DriveFile } from '@/models/entities/drive-file';
import { name, schema } from '../schemas/per-user-drive';
type PerUserDriveLog = SchemaType<typeof schema>;
export default class PerUserDriveChart extends Chart<PerUserDriveLog> {
constructor() {
super(name, schema, true);
}
@autobind
protected genNewLog(latest: PerUserDriveLog): DeepPartial<PerUserDriveLog> {
return {
totalCount: latest.totalCount,
totalSize: latest.totalSize,
};
}
@autobind
protected aggregate(logs: PerUserDriveLog[]): PerUserDriveLog {
return {
totalCount: logs[0].totalCount,
totalSize: logs[0].totalSize,
incCount: logs.reduce((a, b) => a + b.incCount, 0),
incSize: logs.reduce((a, b) => a + b.incSize, 0),
decCount: logs.reduce((a, b) => a + b.decCount, 0),
decSize: logs.reduce((a, b) => a + b.decSize, 0),
};
}
@autobind
protected async fetchActual(group: string): Promise<DeepPartial<PerUserDriveLog>> {
const [count, size] = await Promise.all([
DriveFiles.count({ userId: group }),
DriveFiles.calcDriveUsageOf(group)
]);
return {
totalCount: count,
totalSize: size,
};
}
@autobind
public async update(file: DriveFile, isAdditional: boolean) {
const update: Obj = {};
update.totalCount = isAdditional ? 1 : -1;
update.totalSize = isAdditional ? file.size : -file.size;
if (isAdditional) {
update.incCount = 1;
update.incSize = file.size;
} else {
update.decCount = 1;
update.decSize = file.size;
}
await this.inc(update, file.userId);
}
}

View File

@ -0,0 +1,121 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { Followings, Users } from '@/models/index';
import { Not, IsNull } from 'typeorm';
import { User } from '@/models/entities/user';
import { name, schema } from '../schemas/per-user-following';
type PerUserFollowingLog = SchemaType<typeof schema>;
export default class PerUserFollowingChart extends Chart<PerUserFollowingLog> {
constructor() {
super(name, schema, true);
}
@autobind
protected genNewLog(latest: PerUserFollowingLog): DeepPartial<PerUserFollowingLog> {
return {
local: {
followings: {
total: latest.local.followings.total,
},
followers: {
total: latest.local.followers.total,
}
},
remote: {
followings: {
total: latest.remote.followings.total,
},
followers: {
total: latest.remote.followers.total,
}
}
};
}
@autobind
protected aggregate(logs: PerUserFollowingLog[]): PerUserFollowingLog {
return {
local: {
followings: {
total: logs[0].local.followings.total,
inc: logs.reduce((a, b) => a + b.local.followings.inc, 0),
dec: logs.reduce((a, b) => a + b.local.followings.dec, 0),
},
followers: {
total: logs[0].local.followers.total,
inc: logs.reduce((a, b) => a + b.local.followers.inc, 0),
dec: logs.reduce((a, b) => a + b.local.followers.dec, 0),
},
},
remote: {
followings: {
total: logs[0].remote.followings.total,
inc: logs.reduce((a, b) => a + b.remote.followings.inc, 0),
dec: logs.reduce((a, b) => a + b.remote.followings.dec, 0),
},
followers: {
total: logs[0].remote.followers.total,
inc: logs.reduce((a, b) => a + b.remote.followers.inc, 0),
dec: logs.reduce((a, b) => a + b.remote.followers.dec, 0),
},
},
};
}
@autobind
protected async fetchActual(group: string): Promise<DeepPartial<PerUserFollowingLog>> {
const [
localFollowingsCount,
localFollowersCount,
remoteFollowingsCount,
remoteFollowersCount
] = await Promise.all([
Followings.count({ followerId: group, followeeHost: null }),
Followings.count({ followeeId: group, followerHost: null }),
Followings.count({ followerId: group, followeeHost: Not(IsNull()) }),
Followings.count({ followeeId: group, followerHost: Not(IsNull()) })
]);
return {
local: {
followings: {
total: localFollowingsCount,
},
followers: {
total: localFollowersCount,
}
},
remote: {
followings: {
total: remoteFollowingsCount,
},
followers: {
total: remoteFollowersCount,
}
}
};
}
@autobind
public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean) {
const update: Obj = {};
update.total = isFollow ? 1 : -1;
if (isFollow) {
update.inc = 1;
} else {
update.dec = 1;
}
this.inc({
[Users.isLocalUser(follower) ? 'local' : 'remote']: { followings: update }
}, follower.id);
this.inc({
[Users.isLocalUser(followee) ? 'local' : 'remote']: { followers: update }
}, followee.id);
}
}

View File

@ -0,0 +1,72 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { User } from '@/models/entities/user';
import { SchemaType } from '@/misc/schema';
import { Notes } from '@/models/index';
import { Note } from '@/models/entities/note';
import { name, schema } from '../schemas/per-user-notes';
type PerUserNotesLog = SchemaType<typeof schema>;
export default class PerUserNotesChart extends Chart<PerUserNotesLog> {
constructor() {
super(name, schema, true);
}
@autobind
protected genNewLog(latest: PerUserNotesLog): DeepPartial<PerUserNotesLog> {
return {
total: latest.total,
};
}
@autobind
protected aggregate(logs: PerUserNotesLog[]): PerUserNotesLog {
return {
total: logs[0].total,
inc: logs.reduce((a, b) => a + b.inc, 0),
dec: logs.reduce((a, b) => a + b.dec, 0),
diffs: {
reply: logs.reduce((a, b) => a + b.diffs.reply, 0),
renote: logs.reduce((a, b) => a + b.diffs.renote, 0),
normal: logs.reduce((a, b) => a + b.diffs.normal, 0),
},
};
}
@autobind
protected async fetchActual(group: string): Promise<DeepPartial<PerUserNotesLog>> {
const [count] = await Promise.all([
Notes.count({ userId: group }),
]);
return {
total: count,
};
}
@autobind
public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean) {
const update: Obj = {
diffs: {}
};
update.total = isAdditional ? 1 : -1;
if (isAdditional) {
update.inc = 1;
} else {
update.dec = 1;
}
if (note.replyId != null) {
update.diffs.reply = isAdditional ? 1 : -1;
} else if (note.renoteId != null) {
update.diffs.renote = isAdditional ? 1 : -1;
} else {
update.diffs.normal = isAdditional ? 1 : -1;
}
await this.inc(update, user.id);
}
}

View File

@ -0,0 +1,44 @@
import autobind from 'autobind-decorator';
import Chart, { DeepPartial } from '../../core';
import { User } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { SchemaType } from '@/misc/schema';
import { Users } from '@/models/index';
import { name, schema } from '../schemas/per-user-reactions';
type PerUserReactionsLog = SchemaType<typeof schema>;
export default class PerUserReactionsChart extends Chart<PerUserReactionsLog> {
constructor() {
super(name, schema, true);
}
@autobind
protected genNewLog(latest: PerUserReactionsLog): DeepPartial<PerUserReactionsLog> {
return {};
}
@autobind
protected aggregate(logs: PerUserReactionsLog[]): PerUserReactionsLog {
return {
local: {
count: logs.reduce((a, b) => a + b.local.count, 0),
},
remote: {
count: logs.reduce((a, b) => a + b.remote.count, 0),
},
};
}
@autobind
protected async fetchActual(group: string): Promise<DeepPartial<PerUserReactionsLog>> {
return {};
}
@autobind
public async update(user: { id: User['id'], host: User['host'] }, note: Note) {
this.inc({
[Users.isLocalUser(user) ? 'local' : 'remote']: { count: 1 }
}, note.userId);
}
}

View File

@ -0,0 +1,58 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { name, schema } from '../schemas/test-grouped';
type TestGroupedLog = SchemaType<typeof schema>;
export default class TestGroupedChart extends Chart<TestGroupedLog> {
private total = {} as Record<string, number>;
constructor() {
super(name, schema, true);
}
@autobind
protected genNewLog(latest: TestGroupedLog): DeepPartial<TestGroupedLog> {
return {
foo: {
total: latest.foo.total,
},
};
}
@autobind
protected aggregate(logs: TestGroupedLog[]): TestGroupedLog {
return {
foo: {
total: logs[0].foo.total,
inc: logs.reduce((a, b) => a + b.foo.inc, 0),
dec: logs.reduce((a, b) => a + b.foo.dec, 0),
},
};
}
@autobind
protected async fetchActual(group: string): Promise<DeepPartial<TestGroupedLog>> {
return {
foo: {
total: this.total[group],
},
};
}
@autobind
public async increment(group: string) {
if (this.total[group] == null) this.total[group] = 0;
const update: Obj = {};
update.total = 1;
update.inc = 1;
this.total[group]++;
await this.inc({
foo: update
}, group);
}
}

View File

@ -0,0 +1,36 @@
import autobind from 'autobind-decorator';
import Chart, { DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { name, schema } from '../schemas/test-unique';
type TestUniqueLog = SchemaType<typeof schema>;
export default class TestUniqueChart extends Chart<TestUniqueLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: TestUniqueLog): DeepPartial<TestUniqueLog> {
return {};
}
@autobind
protected aggregate(logs: TestUniqueLog[]): TestUniqueLog {
return {
foo: logs.reduce((a, b) => a.concat(b.foo), [] as TestUniqueLog['foo']),
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<TestUniqueLog>> {
return {};
}
@autobind
public async uniqueIncrement(key: string) {
await this.inc({
foo: [key]
});
}
}

View File

@ -0,0 +1,69 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { name, schema } from '../schemas/test';
type TestLog = SchemaType<typeof schema>;
export default class TestChart extends Chart<TestLog> {
public total = 0; // publicにするのはテストのため
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: TestLog): DeepPartial<TestLog> {
return {
foo: {
total: latest.foo.total,
},
};
}
@autobind
protected aggregate(logs: TestLog[]): TestLog {
return {
foo: {
total: logs[0].foo.total,
inc: logs.reduce((a, b) => a + b.foo.inc, 0),
dec: logs.reduce((a, b) => a + b.foo.dec, 0),
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<TestLog>> {
return {
foo: {
total: this.total,
},
};
}
@autobind
public async increment() {
const update: Obj = {};
update.total = 1;
update.inc = 1;
this.total++;
await this.inc({
foo: update
});
}
@autobind
public async decrement() {
const update: Obj = {};
update.total = -1;
update.dec = 1;
this.total--;
await this.inc({
foo: update
});
}
}

View File

@ -0,0 +1,76 @@
import autobind from 'autobind-decorator';
import Chart, { Obj, DeepPartial } from '../../core';
import { SchemaType } from '@/misc/schema';
import { Users } from '@/models/index';
import { Not, IsNull } from 'typeorm';
import { User } from '@/models/entities/user';
import { name, schema } from '../schemas/users';
type UsersLog = SchemaType<typeof schema>;
export default class UsersChart extends Chart<UsersLog> {
constructor() {
super(name, schema);
}
@autobind
protected genNewLog(latest: UsersLog): DeepPartial<UsersLog> {
return {
local: {
total: latest.local.total,
},
remote: {
total: latest.remote.total,
}
};
}
@autobind
protected aggregate(logs: UsersLog[]): UsersLog {
return {
local: {
total: logs[0].local.total,
inc: logs.reduce((a, b) => a + b.local.inc, 0),
dec: logs.reduce((a, b) => a + b.local.dec, 0),
},
remote: {
total: logs[0].remote.total,
inc: logs.reduce((a, b) => a + b.remote.inc, 0),
dec: logs.reduce((a, b) => a + b.remote.dec, 0),
},
};
}
@autobind
protected async fetchActual(): Promise<DeepPartial<UsersLog>> {
const [localCount, remoteCount] = await Promise.all([
Users.count({ host: null }),
Users.count({ host: Not(IsNull()) })
]);
return {
local: {
total: localCount,
},
remote: {
total: remoteCount,
}
};
}
@autobind
public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean) {
const update: Obj = {};
update.total = isAdditional ? 1 : -1;
if (isAdditional) {
update.inc = 1;
} else {
update.dec = 1;
}
await this.inc({
[Users.isLocalUser(user) ? 'local' : 'remote']: update
});
}
}

View File

@ -0,0 +1,35 @@
export const logSchema = {
/**
* アクティブユーザー
*/
users: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'string' as const,
optional: false as const, nullable: false as const,
}
},
};
/**
* アクティブユーザーに関するチャート
*/
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'activeUsers';

View File

@ -0,0 +1,68 @@
const logSchema = {
/**
* 集計期間時点での、全ドライブファイル数
*/
totalCount: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 集計期間時点での、全ドライブファイルの合計サイズ
*/
totalSize: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 増加したドライブファイル数
*/
incCount: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 増加したドライブ使用量
*/
incSize: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 減少したドライブファイル数
*/
decCount: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 減少したドライブ使用量
*/
decSize: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
};
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'drive';

View File

@ -0,0 +1,29 @@
/**
* フェデレーションに関するチャート
*/
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
instance: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
}
}
};
export const name = 'federation';

View File

@ -0,0 +1,35 @@
export const logSchema = {
/**
* 投稿したユーザー
*/
users: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'string' as const,
optional: false as const, nullable: false as const,
}
},
};
/**
* ハッシュタグに関するチャート
*/
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'hashtag';

View File

@ -0,0 +1,157 @@
/**
* インスタンスごとのチャート
*/
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
requests: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
failed: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
succeeded: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
received: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
notes: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
diffs: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
normal: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
reply: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
renote: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
}
},
users: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
following: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
followers: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
drive: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
totalFiles: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
totalUsage: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
incFiles: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
incUsage: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
decFiles: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
decUsage: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
}
};
export const name = 'instance';

View File

@ -0,0 +1,31 @@
/**
* ネットワークに関するチャート
*/
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
incomingRequests: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
outgoingRequests: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
totalTime: { // TIP: (totalTime / incomingRequests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる
type: 'number' as const,
optional: false as const, nullable: false as const,
},
incomingBytes: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
outgoingBytes: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
};
export const name = 'network';

View File

@ -0,0 +1,56 @@
const logSchema = {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
diffs: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
normal: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
reply: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
renote: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
};
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'notes';

View File

@ -0,0 +1,55 @@
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
/**
* 集計期間時点での、全ドライブファイル数
*/
totalCount: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 集計期間時点での、全ドライブファイルの合計サイズ
*/
totalSize: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 増加したドライブファイル数
*/
incCount: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 増加したドライブ使用量
*/
incSize: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 減少したドライブファイル数
*/
decCount: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 減少したドライブ使用量
*/
decSize: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
};
export const name = 'perUserDrive';

View File

@ -0,0 +1,86 @@
export const logSchema = {
/**
* フォローしている
*/
followings: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
/**
* フォローしている合計
*/
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* フォローした数
*/
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* フォロー解除した数
*/
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
/**
* フォローされている
*/
followers: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
/**
* フォローされている合計
*/
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* フォローされた数
*/
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* フォロー解除された数
*/
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
};
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'perUserFollowing';

View File

@ -0,0 +1,43 @@
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
diffs: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
normal: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
reply: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
renote: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
},
}
};
export const name = 'perUserNotes';

View File

@ -0,0 +1,31 @@
export const logSchema = {
/**
* フォローしている合計
*/
count: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
};
/**
* ユーザーごとのリアクションに関するチャート
*/
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'perUserReaction';

View File

@ -0,0 +1,28 @@
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
foo: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
}
}
};
export const name = 'testGrouped';

View File

@ -0,0 +1,16 @@
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
foo: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'string' as const,
optional: false as const, nullable: false as const,
}
},
}
};
export const name = 'testUnique';

View File

@ -0,0 +1,28 @@
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
foo: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
}
}
}
};
export const name = 'test';

View File

@ -0,0 +1,44 @@
const logSchema = {
/**
* 集計期間時点での、全ユーザー数
*/
total: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 増加したユーザー数
*/
inc: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
/**
* 減少したユーザー数
*/
dec: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
};
export const schema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
local: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
remote: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: logSchema
},
}
};
export const name = 'users';

View File

@ -0,0 +1,563 @@
/**
* チャートエンジン
*
* Tests located in test/chart
*/
import * as nestedProperty from 'nested-property';
import autobind from 'autobind-decorator';
import Logger from '../logger';
import { SimpleSchema } from '@/misc/simple-schema';
import { EntitySchema, getRepository, Repository, LessThan, Between } from 'typeorm';
import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time';
import { getChartInsertLock } from '@/misc/app-lock';
const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test');
export type Obj = { [key: string]: any };
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
type ArrayValue<T> = {
[P in keyof T]: T[P] extends number ? T[P][] : ArrayValue<T[P]>;
};
type Log = {
id: number;
/**
* 集計のグループ
*/
group: string | null;
/**
* 集計日時のUnixタイムスタンプ(秒)
*/
date: number;
};
const camelToSnake = (str: string) => {
return str.replace(/([A-Z])/g, s => '_' + s.charAt(0).toLowerCase());
};
const removeDuplicates = (array: any[]) => Array.from(new Set(array));
/**
* 様々なチャートの管理を司るクラス
*/
export default abstract class Chart<T extends Record<string, any>> {
private static readonly columnPrefix = '___';
private static readonly columnDot = '_';
private name: string;
private buffer: {
diff: DeepPartial<T>;
group: string | null;
}[] = [];
public schema: SimpleSchema;
protected repository: Repository<Log>;
protected abstract genNewLog(latest: T): DeepPartial<T>;
/**
* @param logs 日時が新しい方が先頭
*/
protected abstract aggregate(logs: T[]): T;
protected abstract fetchActual(group: string | null): Promise<DeepPartial<T>>;
@autobind
private static convertSchemaToFlatColumnDefinitions(schema: SimpleSchema) {
const columns = {} as any;
const flatColumns = (x: Obj, path?: string) => {
for (const [k, v] of Object.entries(x)) {
const p = path ? `${path}${this.columnDot}${k}` : k;
if (v.type === 'object') {
flatColumns(v.properties, p);
} else if (v.type === 'number') {
columns[this.columnPrefix + p] = {
type: 'bigint',
};
} else if (v.type === 'array' && v.items.type === 'string') {
columns[this.columnPrefix + p] = {
type: 'varchar',
array: true,
};
}
}
};
flatColumns(schema.properties!);
return columns;
}
@autobind
private static convertFlattenColumnsToObject(x: Record<string, any>): Record<string, any> {
const obj = {} as any;
for (const k of Object.keys(x).filter(k => k.startsWith(Chart.columnPrefix))) {
// now k is ___x_y_z
const path = k.substr(Chart.columnPrefix.length).split(Chart.columnDot).join('.');
nestedProperty.set(obj, path, x[k]);
}
return obj;
}
@autobind
private static convertObjectToFlattenColumns(x: Record<string, any>) {
const columns = {} as Record<string, number | unknown[]>;
const flatten = (x: Obj, path?: string) => {
for (const [k, v] of Object.entries(x)) {
const p = path ? `${path}${this.columnDot}${k}` : k;
if (typeof v === 'object' && !Array.isArray(v)) {
flatten(v, p);
} else {
columns[this.columnPrefix + p] = v;
}
}
};
flatten(x);
return columns;
}
@autobind
private static countUniqueFields(x: Record<string, any>) {
const exec = (x: Obj) => {
const res = {} as Record<string, any>;
for (const [k, v] of Object.entries(x)) {
if (typeof v === 'object' && !Array.isArray(v)) {
res[k] = exec(v);
} else if (Array.isArray(v)) {
res[k] = Array.from(new Set(v)).length;
} else {
res[k] = v;
}
}
return res;
};
return exec(x);
}
@autobind
private static convertQuery(diff: Record<string, number | unknown[]>) {
const query: Record<string, Function> = {};
for (const [k, v] of Object.entries(diff)) {
if (typeof v === 'number') {
if (v > 0) query[k] = () => `"${k}" + ${v}`;
if (v < 0) query[k] = () => `"${k}" - ${Math.abs(v)}`;
} else if (Array.isArray(v)) {
// TODO: item が文字列以外の場合も対応
// TODO: item をSQLエスケープ
const items = v.map(item => `"${item}"`).join(',');
query[k] = () => `array_cat("${k}", '{${items}}'::varchar[])`;
}
}
return query;
}
@autobind
private static dateToTimestamp(x: Date): Log['date'] {
return Math.floor(x.getTime() / 1000);
}
@autobind
private static parseDate(date: Date): [number, number, number, number, number, number, number] {
const y = date.getUTCFullYear();
const m = date.getUTCMonth();
const d = date.getUTCDate();
const h = date.getUTCHours();
const _m = date.getUTCMinutes();
const _s = date.getUTCSeconds();
const _ms = date.getUTCMilliseconds();
return [y, m, d, h, _m, _s, _ms];
}
@autobind
private static getCurrentDate() {
return Chart.parseDate(new Date());
}
@autobind
public static schemaToEntity(name: string, schema: SimpleSchema): EntitySchema {
return new EntitySchema({
name: `__chart__${camelToSnake(name)}`,
columns: {
id: {
type: 'integer',
primary: true,
generated: true
},
date: {
type: 'integer',
},
group: {
type: 'varchar',
length: 128,
nullable: true
},
...Chart.convertSchemaToFlatColumnDefinitions(schema)
},
indices: [{
columns: ['date', 'group'],
unique: true,
}, { // groupにnullが含まれると↑のuniqueは機能しないので↓の部分インデックスでカバー
columns: ['date'],
unique: true,
where: '"group" IS NULL'
}]
});
}
constructor(name: string, schema: SimpleSchema, grouped = false) {
this.name = name;
this.schema = schema;
const entity = Chart.schemaToEntity(name, schema);
const keys = ['date'];
if (grouped) keys.push('group');
entity.options.uniques = [{
columns: keys
}];
this.repository = getRepository<Log>(entity);
}
@autobind
private getNewLog(latest: T | null): T {
const log = latest ? this.genNewLog(latest) : {};
const flatColumns = (x: Obj, path?: string) => {
for (const [k, v] of Object.entries(x)) {
const p = path ? `${path}.${k}` : k;
if (v.type === 'object') {
flatColumns(v.properties, p);
} else {
if (nestedProperty.get(log, p) == null) {
const emptyValue = v.type === 'number' ? 0 : [];
nestedProperty.set(log, p, emptyValue);
}
}
}
};
flatColumns(this.schema.properties!);
return log as T;
}
@autobind
private getLatestLog(group: string | null = null): Promise<Log | null> {
return this.repository.findOne({
group: group,
}, {
order: {
date: -1
}
}).then(x => x || null);
}
@autobind
private async getCurrentLog(group: string | null = null): Promise<Log> {
const [y, m, d, h] = Chart.getCurrentDate();
const current = dateUTC([y, m, d, h]);
// 現在(=今のHour)のログ
const currentLog = await this.repository.findOne({
date: Chart.dateToTimestamp(current),
...(group ? { group: group } : {})
});
// ログがあればそれを返して終了
if (currentLog != null) {
return currentLog;
}
let log: Log;
let data: T;
// 集計期間が変わってから、初めてのチャート更新なら
// 最も最近のログを持ってくる
// * 例えば集計期間が「日」である場合で考えると、
// * 昨日何もチャートを更新するような出来事がなかった場合は、
// * ログがそもそも作られずドキュメントが存在しないということがあり得るため、
// * 「昨日の」と決め打ちせずに「もっとも最近の」とします
const latest = await this.getLatestLog(group);
if (latest != null) {
const obj = Chart.convertFlattenColumnsToObject(latest) as T;
// 空ログデータを作成
data = this.getNewLog(obj);
} else {
// ログが存在しなかったら
// (Misskeyインスタンスを建てて初めてのチャート更新時など)
// 初期ログデータを作成
data = this.getNewLog(null);
logger.info(`${this.name + (group ? `:${group}` : '')}: Initial commit created`);
}
const date = Chart.dateToTimestamp(current);
const lockKey = `${this.name}:${date}:${group}`;
const unlock = await getChartInsertLock(lockKey);
try {
// ロック内でもう1回チェックする
const currentLog = await this.repository.findOne({
date: date,
...(group ? { group: group } : {})
});
// ログがあればそれを返して終了
if (currentLog != null) return currentLog;
// 新規ログ挿入
log = await this.repository.insert({
group: group,
date: date,
...Chart.convertObjectToFlattenColumns(data)
}).then(x => this.repository.findOneOrFail(x.identifiers[0]));
logger.info(`${this.name + (group ? `:${group}` : '')}: New commit created`);
return log;
} finally {
unlock();
}
}
@autobind
protected commit(diff: DeepPartial<T>, group: string | null = null): void {
this.buffer.push({
diff, group,
});
}
@autobind
public async save() {
if (this.buffer.length === 0) {
logger.info(`${this.name}: Write skipped`);
return;
}
// TODO: 前の時間のログがbufferにあった場合のハンドリング
// 例えば、save が20分ごとに行われるとして、前回行われたのは 01:50 だったとする。
// 次に save が行われるのは 02:10 ということになるが、もし 01:55 に新規ログが buffer に追加されたとすると、
// そのログは本来は 01:00~ のログとしてDBに保存されて欲しいのに、02:00~ のログ扱いになってしまう。
// これを回避するための実装は複雑になりそうなため、一旦保留。
const update = async (log: Log) => {
const finalDiffs = {} as Record<string, number | unknown[]>;
for (const diff of this.buffer.filter(q => q.group === log.group).map(q => q.diff)) {
const columns = Chart.convertObjectToFlattenColumns(diff);
for (const [k, v] of Object.entries(columns)) {
if (finalDiffs[k] == null) {
finalDiffs[k] = v;
} else {
if (typeof finalDiffs[k] === 'number') {
(finalDiffs[k] as number) += v as number;
} else {
(finalDiffs[k] as unknown[]) = (finalDiffs[k] as unknown[]).concat(v);
}
}
}
}
const query = Chart.convertQuery(finalDiffs);
// ログ更新
await this.repository.createQueryBuilder()
.update()
.set(query)
.where('id = :id', { id: log.id })
.execute();
logger.info(`${this.name + (log.group ? `:${log.group}` : '')}: Updated`);
// TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする
this.buffer = this.buffer.filter(q => q.group !== log.group);
};
const groups = removeDuplicates(this.buffer.map(log => log.group));
await Promise.all(groups.map(group => this.getCurrentLog(group).then(log => update(log))));
}
@autobind
public async resync(group: string | null = null): Promise<any> {
const data = await this.fetchActual(group);
const update = async (log: Log) => {
await this.repository.createQueryBuilder()
.update()
.set(Chart.convertObjectToFlattenColumns(data))
.where('id = :id', { id: log.id })
.execute();
};
return this.getCurrentLog(group).then(log => update(log));
}
@autobind
protected async inc(inc: DeepPartial<T>, group: string | null = null): Promise<void> {
await this.commit(inc, group);
}
@autobind
public async getChart(span: 'hour' | 'day', amount: number, cursor: Date | null, group: string | null = null): Promise<ArrayValue<T>> {
const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate();
const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never;
const lt = dateUTC([y, m, d, h, _m, _s, _ms]);
const gt =
span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') :
span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') :
null as never;
// ログ取得
let logs = await this.repository.find({
where: {
group: group,
date: Between(Chart.dateToTimestamp(gt), Chart.dateToTimestamp(lt))
},
order: {
date: -1
},
});
// 要求された範囲にログがひとつもなかったら
if (logs.length === 0) {
// もっとも新しいログを持ってくる
// (すくなくともひとつログが無いと隙間埋めできないため)
const recentLog = await this.repository.findOne({
group: group,
}, {
order: {
date: -1
},
});
if (recentLog) {
logs = [recentLog];
}
// 要求された範囲の最も古い箇所に位置するログが存在しなかったら
} else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) {
// 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する
// (隙間埋めできないため)
const outdatedLog = await this.repository.findOne({
group: group,
date: LessThan(Chart.dateToTimestamp(gt))
}, {
order: {
date: -1
},
});
if (outdatedLog) {
logs.push(outdatedLog);
}
}
const chart: T[] = [];
if (span === 'hour') {
for (let i = (amount - 1); i >= 0; i--) {
const current = subtractTime(dateUTC([y, m, d, h]), i, 'hour');
const log = logs.find(l => isTimeSame(new Date(l.date * 1000), current));
if (log) {
const data = Chart.convertFlattenColumnsToObject(log);
chart.unshift(Chart.countUniqueFields(data) as T);
} else {
// 隙間埋め
const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current));
const data = latest ? Chart.convertFlattenColumnsToObject(latest) as T : null;
chart.unshift(Chart.countUniqueFields(this.getNewLog(data)) as T);
}
}
} else if (span === 'day') {
const logsForEachDays: T[][] = [];
let currentDay = -1;
let currentDayIndex = -1;
for (let i = ((amount - 1) * 24) + h; i >= 0; i--) {
const current = subtractTime(dateUTC([y, m, d, h]), i, 'hour');
const _currentDay = Chart.parseDate(current)[2];
if (currentDay != _currentDay) currentDayIndex++;
currentDay = _currentDay;
const log = logs.find(l => isTimeSame(new Date(l.date * 1000), current));
if (log) {
if (logsForEachDays[currentDayIndex]) {
logsForEachDays[currentDayIndex].unshift(Chart.convertFlattenColumnsToObject(log) as T);
} else {
logsForEachDays[currentDayIndex] = [Chart.convertFlattenColumnsToObject(log) as T];
}
} else {
// 隙間埋め
const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current));
const data = latest ? Chart.convertFlattenColumnsToObject(latest) as T : null;
const newLog = this.getNewLog(data);
if (logsForEachDays[currentDayIndex]) {
logsForEachDays[currentDayIndex].unshift(newLog);
} else {
logsForEachDays[currentDayIndex] = [newLog];
}
}
}
for (const logs of logsForEachDays) {
const log = this.aggregate(logs);
chart.unshift(Chart.countUniqueFields(log) as T);
}
}
const res: ArrayValue<T> = {} as any;
/**
* [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }]
* を
* { foo: [1, 2, 3], bar: [5, 6, 7] }
* にする
*/
const compact = (x: Obj, path?: string) => {
for (const [k, v] of Object.entries(x)) {
const p = path ? `${path}.${k}` : k;
if (typeof v === 'object' && !Array.isArray(v)) {
compact(v, p);
} else {
const values = chart.map(s => nestedProperty.get(s, p));
nestedProperty.set(res, p, values);
}
}
};
compact(chart[0]);
return res;
}
}
export function convertLog(logSchema: SimpleSchema): SimpleSchema {
const v: SimpleSchema = JSON.parse(JSON.stringify(logSchema)); // copy
if (v.type === 'number') {
v.type = 'array';
v.items = {
type: 'number' as const,
optional: false as const, nullable: false as const,
};
} else if (v.type === 'object') {
for (const k of Object.keys(v.properties!)) {
v.properties![k] = convertLog(v.properties![k]);
}
}
return v;
}

View File

@ -0,0 +1,15 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import Chart from './core';
//const _filename = fileURLToPath(import.meta.url);
const _filename = __filename;
const _dirname = dirname(_filename);
export const entities = Object.values(require('require-all')({
dirname: _dirname + '/charts/schemas',
filter: /^.+\.[jt]s$/,
resolve: (x: any) => {
return Chart.schemaToEntity(x.name, x.schema);
}
}));

View File

@ -0,0 +1,50 @@
import FederationChart from './charts/classes/federation';
import NotesChart from './charts/classes/notes';
import UsersChart from './charts/classes/users';
import NetworkChart from './charts/classes/network';
import ActiveUsersChart from './charts/classes/active-users';
import InstanceChart from './charts/classes/instance';
import PerUserNotesChart from './charts/classes/per-user-notes';
import DriveChart from './charts/classes/drive';
import PerUserReactionsChart from './charts/classes/per-user-reactions';
import HashtagChart from './charts/classes/hashtag';
import PerUserFollowingChart from './charts/classes/per-user-following';
import PerUserDriveChart from './charts/classes/per-user-drive';
import { beforeShutdown } from '@/misc/before-shutdown';
export const federationChart = new FederationChart();
export const notesChart = new NotesChart();
export const usersChart = new UsersChart();
export const networkChart = new NetworkChart();
export const activeUsersChart = new ActiveUsersChart();
export const instanceChart = new InstanceChart();
export const perUserNotesChart = new PerUserNotesChart();
export const driveChart = new DriveChart();
export const perUserReactionsChart = new PerUserReactionsChart();
export const hashtagChart = new HashtagChart();
export const perUserFollowingChart = new PerUserFollowingChart();
export const perUserDriveChart = new PerUserDriveChart();
const charts = [
federationChart,
notesChart,
usersChart,
networkChart,
activeUsersChart,
instanceChart,
perUserNotesChart,
driveChart,
perUserReactionsChart,
hashtagChart,
perUserFollowingChart,
perUserDriveChart,
];
// 20分おきにメモリ情報をDBに書き込み
setInterval(() => {
for (const chart of charts) {
chart.save();
}
}, 1000 * 60 * 20);
beforeShutdown(() => Promise.all(charts.map(chart => chart.save())));

View File

@ -0,0 +1,61 @@
import { publishMainStream } from '@/services/stream';
import pushSw from './push-notification';
import { Notifications, Mutings, UserProfiles, Users } from '@/models/index';
import { genId } from '@/misc/gen-id';
import { User } from '@/models/entities/user';
import { Notification } from '@/models/entities/notification';
import { sendEmailNotification } from './send-email-notification';
export async function createNotification(
notifieeId: User['id'],
type: Notification['type'],
data: Partial<Notification>
) {
if (data.notifierId && (notifieeId === data.notifierId)) {
return null;
}
const profile = await UserProfiles.findOne({ userId: notifieeId });
const isMuted = profile?.mutingNotificationTypes.includes(type);
// Create notification
const notification = await Notifications.save({
id: genId(),
createdAt: new Date(),
notifieeId: notifieeId,
type: type,
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
isRead: isMuted,
...data
} as Partial<Notification>);
const packed = await Notifications.pack(notification, {});
// Publish notification event
publishMainStream(notifieeId, 'notification', packed);
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(async () => {
const fresh = await Notifications.findOne(notification.id);
if (fresh == null) return; // 既に削除されているかもしれない
if (fresh.isRead) return;
//#region ただしミュートしているユーザーからの通知なら無視
const mutings = await Mutings.find({
muterId: notifieeId
});
if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
return;
}
//#endregion
publishMainStream(notifieeId, 'unreadNotification', packed);
pushSw(notifieeId, 'notification', packed);
if (type === 'follow') sendEmailNotification.follow(notifieeId, await Users.findOneOrFail(data.notifierId!));
if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, await Users.findOneOrFail(data.notifierId!));
}, 2000);
return notification;
}

View File

@ -0,0 +1,67 @@
import * as bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import generateNativeUserToken from '../server/api/common/generate-native-user-token';
import { genRsaKeyPair } from '@/misc/gen-key-pair';
import { User } from '@/models/entities/user';
import { UserProfile } from '@/models/entities/user-profile';
import { getConnection } from 'typeorm';
import { genId } from '@/misc/gen-id';
import { UserKeypair } from '@/models/entities/user-keypair';
import { UsedUsername } from '@/models/entities/used-username';
export async function createSystemUser(username: string) {
const password = uuid();
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
// Generate secret
const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(4096);
let account!: User;
// Start transaction
await getConnection().transaction(async transactionalEntityManager => {
const exist = await transactionalEntityManager.findOne(User, {
usernameLower: username.toLowerCase(),
host: null
});
if (exist) throw new Error('the user is already exists');
account = await transactionalEntityManager.insert(User, {
id: genId(),
createdAt: new Date(),
username: username,
usernameLower: username.toLowerCase(),
host: null,
token: secret,
isAdmin: false,
isLocked: true,
isExplorable: false,
isBot: true,
}).then(x => transactionalEntityManager.findOneOrFail(User, x.identifiers[0]));
await transactionalEntityManager.insert(UserKeypair, {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
userId: account.id
});
await transactionalEntityManager.insert(UserProfile, {
userId: account.id,
autoAcceptFollowed: false,
password: hash,
});
await transactionalEntityManager.insert(UsedUsername, {
createdAt: new Date(),
username: username.toLowerCase(),
});
});
return account;
}

View File

@ -0,0 +1,466 @@
import * as fs from 'fs';
import { v4 as uuid } from 'uuid';
import { publishMainStream, publishDriveStream } from '@/services/stream';
import { deleteFile } from './delete-file';
import { fetchMeta } from '@/misc/fetch-meta';
import { GenerateVideoThumbnail } from './generate-video-thumbnail';
import { driveLogger } from './logger';
import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng, convertSharpToPngOrJpeg } from './image-processor';
import { contentDisposition } from '@/misc/content-disposition';
import { getFileInfo } from '@/misc/get-file-info';
import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index';
import { InternalStorage } from './internal-storage';
import { DriveFile } from '@/models/entities/drive-file';
import { IRemoteUser, User } from '@/models/entities/user';
import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index';
import { genId } from '@/misc/gen-id';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error';
import * as S3 from 'aws-sdk/clients/s3';
import { getS3 } from './s3';
import * as sharp from 'sharp';
const logger = driveLogger.createSubLogger('register', 'yellow');
/***
* Save file
* @param path Path for original
* @param name Name for original
* @param type Content-Type for original
* @param hash Hash for original
* @param size Size for original
*/
async function save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<DriveFile> {
// thunbnail, webpublic を必要なら生成
const alts = await generateAlts(path, type, !file.uri);
const meta = await fetchMeta();
if (meta.useObjectStorage) {
//#region ObjectStorage params
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']);
if (ext === '') {
if (type === 'image/jpeg') ext = '.jpg';
if (type === 'image/png') ext = '.png';
if (type === 'image/webp') ext = '.webp';
if (type === 'image/apng') ext = '.apng';
if (type === 'image/vnd.mozilla.apng') ext = '.apng';
}
const baseUrl = meta.objectStorageBaseUrl
|| `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
// for original
const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`;
const url = `${ baseUrl }/${ key }`;
// for alts
let webpublicKey: string | null = null;
let webpublicUrl: string | null = null;
let thumbnailKey: string | null = null;
let thumbnailUrl: string | null = null;
//#endregion
//#region Uploads
logger.info(`uploading original: ${key}`);
const uploads = [
upload(key, fs.createReadStream(path), type, name)
];
if (alts.webpublic) {
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`;
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
logger.info(`uploading webpublic: ${webpublicKey}`);
uploads.push(upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
}
if (alts.thumbnail) {
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`;
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
logger.info(`uploading thumbnail: ${thumbnailKey}`);
uploads.push(upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
}
await Promise.all(uploads);
//#endregion
file.url = url;
file.thumbnailUrl = thumbnailUrl;
file.webpublicUrl = webpublicUrl;
file.accessKey = key;
file.thumbnailAccessKey = thumbnailKey;
file.webpublicAccessKey = webpublicKey;
file.name = name;
file.type = type;
file.md5 = hash;
file.size = size;
file.storedInternal = false;
return await DriveFiles.save(file);
} else { // use internal storage
const accessKey = uuid();
const thumbnailAccessKey = 'thumbnail-' + uuid();
const webpublicAccessKey = 'webpublic-' + uuid();
const url = InternalStorage.saveFromPath(accessKey, path);
let thumbnailUrl: string | null = null;
let webpublicUrl: string | null = null;
if (alts.thumbnail) {
thumbnailUrl = InternalStorage.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data);
logger.info(`thumbnail stored: ${thumbnailAccessKey}`);
}
if (alts.webpublic) {
webpublicUrl = InternalStorage.saveFromBuffer(webpublicAccessKey, alts.webpublic.data);
logger.info(`web stored: ${webpublicAccessKey}`);
}
file.storedInternal = true;
file.url = url;
file.thumbnailUrl = thumbnailUrl;
file.webpublicUrl = webpublicUrl;
file.accessKey = accessKey;
file.thumbnailAccessKey = thumbnailAccessKey;
file.webpublicAccessKey = webpublicAccessKey;
file.name = name;
file.type = type;
file.md5 = hash;
file.size = size;
return await DriveFiles.save(file);
}
}
/**
* Generate webpublic, thumbnail, etc
* @param path Path for original
* @param type Content-Type for original
* @param generateWeb Generate webpublic or not
*/
export async function generateAlts(path: string, type: string, generateWeb: boolean) {
if (type.startsWith('video/')) {
try {
const thumbnail = await GenerateVideoThumbnail(path);
return {
webpublic: null,
thumbnail
};
} catch (e) {
logger.warn(`GenerateVideoThumbnail failed: ${e}`);
return {
webpublic: null,
thumbnail: null
};
}
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) {
logger.debug(`web image and thumbnail not created (not an required file)`);
return {
webpublic: null,
thumbnail: null
};
}
let img: sharp.Sharp | null = null;
try {
img = sharp(path);
const metadata = await img.metadata();
const isAnimated = metadata.pages && metadata.pages > 1;
// skip animated
if (isAnimated) {
return {
webpublic: null,
thumbnail: null
};
}
} catch (e) {
logger.warn(`sharp failed: ${e}`);
return {
webpublic: null,
thumbnail: null
};
}
// #region webpublic
let webpublic: IImage | null = null;
if (generateWeb) {
logger.info(`creating web image`);
try {
if (['image/jpeg'].includes(type)) {
webpublic = await convertSharpToJpeg(img, 2048, 2048);
} else if (['image/webp'].includes(type)) {
webpublic = await convertSharpToWebp(img, 2048, 2048);
} else if (['image/png'].includes(type)) {
webpublic = await convertSharpToPng(img, 2048, 2048);
} else {
logger.debug(`web image not created (not an required image)`);
}
} catch (e) {
logger.warn(`web image not created (an error occured)`, e);
}
} else {
logger.info(`web image not created (from remote)`);
}
// #endregion webpublic
// #region thumbnail
let thumbnail: IImage | null = null;
try {
if (['image/jpeg', 'image/webp'].includes(type)) {
thumbnail = await convertSharpToJpeg(img, 498, 280);
} else if (['image/png'].includes(type)) {
thumbnail = await convertSharpToPngOrJpeg(img, 498, 280);
} else {
logger.debug(`thumbnail not created (not an required file)`);
}
} catch (e) {
logger.warn(`thumbnail not created (an error occured)`, e);
}
// #endregion thumbnail
return {
webpublic,
thumbnail,
};
}
/**
* Upload to ObjectStorage
*/
async function upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
if (type === 'image/apng') type = 'image/png';
const meta = await fetchMeta();
const params = {
Bucket: meta.objectStorageBucket,
Key: key,
Body: stream,
ContentType: type,
CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest;
if (filename) params.ContentDisposition = contentDisposition('inline', filename);
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = getS3(meta);
const upload = s3.upload(params, {
partSize: s3.endpoint?.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024
});
const result = await upload.promise();
if (result) logger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
}
async function deleteOldFile(user: IRemoteUser) {
const q = DriveFiles.createQueryBuilder('file')
.where('file.userId = :userId', { userId: user.id })
.andWhere('file.isLink = FALSE');
if (user.avatarId) {
q.andWhere('file.id != :avatarId', { avatarId: user.avatarId });
}
if (user.bannerId) {
q.andWhere('file.id != :bannerId', { bannerId: user.bannerId });
}
q.orderBy('file.id', 'ASC');
const oldFile = await q.getOne();
if (oldFile) {
deleteFile(oldFile, true);
}
}
/**
* Add file to drive
*
* @param user User who wish to add file
* @param path File path
* @param name Name
* @param comment Comment
* @param folderId Folder ID
* @param force If set to true, forcibly upload the file even if there is a file with the same hash.
* @param isLink Do not save file to local
* @param url URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL)
* @param uri URL of source (リモートインスタンスのURLからアップロードされた場合の元URL)
* @param sensitive Mark file as sensitive
* @return Created drive file
*/
export default async function(
user: { id: User['id']; host: User['host'] } | null,
path: string,
name: string | null = null,
comment: string | null = null,
folderId: any = null,
force: boolean = false,
isLink: boolean = false,
url: string | null = null,
uri: string | null = null,
sensitive: boolean | null = null
): Promise<DriveFile> {
const info = await getFileInfo(path);
logger.info(`${JSON.stringify(info)}`);
// detect name
const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
if (user && !force) {
// Check if there is a file with the same hash
const much = await DriveFiles.findOne({
md5: info.md5,
userId: user.id,
});
if (much) {
logger.info(`file with same hash is found: ${much.id}`);
return much;
}
}
//#region Check drive usage
if (user && !isLink) {
const usage = await DriveFiles.calcDriveUsageOf(user);
const instance = await fetchMeta();
const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
logger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
// If usage limit exceeded
if (usage + info.size > driveCapacity) {
if (Users.isLocalUser(user)) {
throw new Error('no-free-space');
} else {
// (アバターまたはバナーを含まず)最も古いファイルを削除する
deleteOldFile(await Users.findOneOrFail(user.id) as IRemoteUser);
}
}
}
//#endregion
const fetchFolder = async () => {
if (!folderId) {
return null;
}
const driveFolder = await DriveFolders.findOne({
id: folderId,
userId: user ? user.id : null
});
if (driveFolder == null) throw new Error('folder-not-found');
return driveFolder;
};
const properties: {
width?: number;
height?: number;
} = {};
if (info.width) {
properties['width'] = info.width;
properties['height'] = info.height;
}
const profile = user ? await UserProfiles.findOne(user.id) : null;
const folder = await fetchFolder();
let file = new DriveFile();
file.id = genId();
file.createdAt = new Date();
file.userId = user ? user.id : null;
file.userHost = user ? user.host : null;
file.folderId = folder !== null ? folder.id : null;
file.comment = comment;
file.properties = properties;
file.blurhash = info.blurhash || null;
file.isLink = isLink;
file.isSensitive = user
? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
(sensitive !== null && sensitive !== undefined)
? sensitive
: false
: false;
if (url !== null) {
file.src = url;
if (isLink) {
file.url = url;
// ローカルプロキシ用
file.accessKey = uuid();
file.thumbnailAccessKey = 'thumbnail-' + uuid();
file.webpublicAccessKey = 'webpublic-' + uuid();
}
}
if (uri !== null) {
file.uri = uri;
}
if (isLink) {
try {
file.size = 0;
file.md5 = info.md5;
file.name = detectedName;
file.type = info.type.mime;
file.storedInternal = false;
file = await DriveFiles.save(file);
} catch (e) {
// duplicate key error (when already registered)
if (isDuplicateKeyValueError(e)) {
logger.info(`already registered ${file.uri}`);
file = await DriveFiles.findOne({
uri: file.uri,
userId: user ? user.id : null
}) as DriveFile;
} else {
logger.error(e);
throw e;
}
}
} else {
file = await (save(file, path, detectedName, info.type.mime, info.md5, info.size));
}
logger.succ(`drive file has been created ${file.id}`);
if (user) {
DriveFiles.pack(file, { self: true }).then(packedFile => {
// Publish driveFileCreated event
publishMainStream(user.id, 'driveFileCreated', packedFile);
publishDriveStream(user.id, 'fileCreated', packedFile);
});
}
// 統計を更新
driveChart.update(file, true);
perUserDriveChart.update(file, true);
if (file.userHost !== null) {
instanceChart.updateDrive(file, true);
Instances.increment({ host: file.userHost }, 'driveUsage', file.size);
Instances.increment({ host: file.userHost }, 'driveFiles', 1);
}
return file;
}

View File

@ -0,0 +1,103 @@
import { DriveFile } from '@/models/entities/drive-file';
import { InternalStorage } from './internal-storage';
import { DriveFiles, Instances } from '@/models/index';
import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index';
import { createDeleteObjectStorageFileJob } from '@/queue/index';
import { fetchMeta } from '@/misc/fetch-meta';
import { getS3 } from './s3';
import { v4 as uuid } from 'uuid';
export async function deleteFile(file: DriveFile, isExpired = false) {
if (file.storedInternal) {
InternalStorage.del(file.accessKey!);
if (file.thumbnailUrl) {
InternalStorage.del(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
InternalStorage.del(file.webpublicAccessKey!);
}
} else if (!file.isLink) {
createDeleteObjectStorageFileJob(file.accessKey!);
if (file.thumbnailUrl) {
createDeleteObjectStorageFileJob(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
createDeleteObjectStorageFileJob(file.webpublicAccessKey!);
}
}
postProcess(file, isExpired);
}
export async function deleteFileSync(file: DriveFile, isExpired = false) {
if (file.storedInternal) {
InternalStorage.del(file.accessKey!);
if (file.thumbnailUrl) {
InternalStorage.del(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
InternalStorage.del(file.webpublicAccessKey!);
}
} else if (!file.isLink) {
const promises = [];
promises.push(deleteObjectStorageFile(file.accessKey!));
if (file.thumbnailUrl) {
promises.push(deleteObjectStorageFile(file.thumbnailAccessKey!));
}
if (file.webpublicUrl) {
promises.push(deleteObjectStorageFile(file.webpublicAccessKey!));
}
await Promise.all(promises);
}
postProcess(file, isExpired);
}
async function postProcess(file: DriveFile, isExpired = false) {
// リモートファイル期限切れ削除後は直リンクにする
if (isExpired && file.userHost !== null && file.uri != null) {
DriveFiles.update(file.id, {
isLink: true,
url: file.uri,
thumbnailUrl: null,
webpublicUrl: null,
storedInternal: false,
// ローカルプロキシ用
accessKey: uuid(),
thumbnailAccessKey: 'thumbnail-' + uuid(),
webpublicAccessKey: 'webpublic-' + uuid(),
});
} else {
DriveFiles.delete(file.id);
}
// 統計を更新
driveChart.update(file, false);
perUserDriveChart.update(file, false);
if (file.userHost !== null) {
instanceChart.updateDrive(file, false);
Instances.decrement({ host: file.userHost }, 'driveUsage', file.size);
Instances.decrement({ host: file.userHost }, 'driveFiles', 1);
}
}
export async function deleteObjectStorageFile(key: string) {
const meta = await fetchMeta();
const s3 = getS3(meta);
await s3.deleteObject({
Bucket: meta.objectStorageBucket!,
Key: key
}).promise();
}

View File

@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as tmp from 'tmp';
import { IImage, convertToJpeg } from './image-processor';
import * as FFmpeg from 'fluent-ffmpeg';
export async function GenerateVideoThumbnail(path: string): Promise<IImage> {
const [outDir, cleanup] = await new Promise<[string, any]>((res, rej) => {
tmp.dir((e, path, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
await new Promise((res, rej) => {
FFmpeg({
source: path
})
.on('end', res)
.on('error', rej)
.screenshot({
folder: outDir,
filename: 'output.png',
count: 1,
timestamps: ['5%']
});
});
const outPath = `${outDir}/output.png`;
const thumbnail = await convertToJpeg(outPath, 498, 280);
// cleanup
await fs.promises.unlink(outPath);
cleanup();
return thumbnail;
}

View File

@ -0,0 +1,107 @@
import * as sharp from 'sharp';
export type IImage = {
data: Buffer;
ext: string | null;
type: string;
};
/**
* Convert to JPEG
* with resize, remove metadata, resolve orientation, stop animation
*/
export async function convertToJpeg(path: string, width: number, height: number): Promise<IImage> {
return convertSharpToJpeg(await sharp(path), width, height);
}
export async function convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true
})
.rotate()
.jpeg({
quality: 85,
progressive: true
})
.toBuffer();
return {
data,
ext: 'jpg',
type: 'image/jpeg'
};
}
/**
* Convert to WebP
* with resize, remove metadata, resolve orientation, stop animation
*/
export async function convertToWebp(path: string, width: number, height: number): Promise<IImage> {
return convertSharpToWebp(await sharp(path), width, height);
}
export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true
})
.rotate()
.webp({
quality: 85
})
.toBuffer();
return {
data,
ext: 'webp',
type: 'image/webp'
};
}
/**
* Convert to PNG
* with resize, remove metadata, resolve orientation, stop animation
*/
export async function convertToPng(path: string, width: number, height: number): Promise<IImage> {
return convertSharpToPng(await sharp(path), width, height);
}
export async function convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true
})
.rotate()
.png()
.toBuffer();
return {
data,
ext: 'png',
type: 'image/png'
};
}
/**
* Convert to PNG or JPEG
* with resize, remove metadata, resolve orientation, stop animation
*/
export async function convertToPngOrJpeg(path: string, width: number, height: number): Promise<IImage> {
return convertSharpToPngOrJpeg(await sharp(path), width, height);
}
export async function convertSharpToPngOrJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
const stats = await sharp.stats();
const metadata = await sharp.metadata();
// 不透明で300x300pxの範囲を超えていればJPEG
if (stats.isOpaque && ((metadata.width && metadata.width >= 300) || (metadata.height && metadata!.height >= 300))) {
return await convertSharpToJpeg(sharp, width, height);
} else {
return await convertSharpToPng(sharp, width, height);
}
}

View File

@ -0,0 +1,35 @@
import * as fs from 'fs';
import * as Path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import config from '@/config/index';
//const _filename = fileURLToPath(import.meta.url);
const _filename = __filename;
const _dirname = dirname(_filename);
export class InternalStorage {
private static readonly path = Path.resolve(_dirname, '../../../../../files');
public static resolvePath = (key: string) => Path.resolve(InternalStorage.path, key);
public static read(key: string) {
return fs.createReadStream(InternalStorage.resolvePath(key));
}
public static saveFromPath(key: string, srcPath: string) {
fs.mkdirSync(InternalStorage.path, { recursive: true });
fs.copyFileSync(srcPath, InternalStorage.resolvePath(key));
return `${config.url}/files/${key}`;
}
public static saveFromBuffer(key: string, data: Buffer) {
fs.mkdirSync(InternalStorage.path, { recursive: true });
fs.writeFileSync(InternalStorage.resolvePath(key), data);
return `${config.url}/files/${key}`;
}
public static del(key: string) {
fs.unlink(InternalStorage.resolvePath(key), () => {});
}
}

View File

@ -0,0 +1,3 @@
import Logger from '../logger';
export const driveLogger = new Logger('drive', 'blue');

View File

@ -0,0 +1,24 @@
import { URL } from 'url';
import * as S3 from 'aws-sdk/clients/s3';
import { Meta } from '@/models/entities/meta';
import { getAgentByUrl } from '@/misc/fetch';
export function getS3(meta: Meta) {
const u = meta.objectStorageEndpoint != null
? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}`
: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`;
return new S3({
endpoint: meta.objectStorageEndpoint || undefined,
accessKeyId: meta.objectStorageAccessKey!,
secretAccessKey: meta.objectStorageSecretKey!,
region: meta.objectStorageRegion || undefined,
sslEnabled: meta.objectStorageUseSSL,
s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted
? false
: meta.objectStorageS3ForcePathStyle,
httpOptions: {
agent: getAgentByUrl(new URL(u), !meta.objectStorageUseProxy)
}
});
}

View File

@ -0,0 +1,62 @@
import { URL } from 'url';
import create from './add-file';
import { User } from '@/models/entities/user';
import { driveLogger } from './logger';
import { createTemp } from '@/misc/create-temp';
import { downloadUrl } from '@/misc/download-url';
import { DriveFolder } from '@/models/entities/drive-folder';
import { DriveFile } from '@/models/entities/drive-file';
import { DriveFiles } from '@/models/index';
const logger = driveLogger.createSubLogger('downloader');
export default async (
url: string,
user: { id: User['id']; host: User['host'] } | null,
folderId: DriveFolder['id'] | null = null,
uri: string | null = null,
sensitive = false,
force = false,
link = false,
comment = null
): Promise<DriveFile> => {
let name = new URL(url).pathname.split('/').pop() || null;
if (name == null || !DriveFiles.validateFileName(name)) {
name = null;
}
// If the comment is same as the name, skip comment
// (image.name is passed in when receiving attachment)
if (comment !== null && name == comment) {
comment = null;
}
// Create temp file
const [path, cleanup] = await createTemp();
// write content at URL to temp file
await downloadUrl(url, path);
let driveFile: DriveFile;
let error;
try {
driveFile = await create(user, path, name, comment, folderId, force, link, url, uri, sensitive);
logger.succ(`Got: ${driveFile.id}`);
} catch (e) {
error = e;
logger.error(`Failed to create drive file: ${e}`, {
url: url,
e: e
});
}
// clean-up
cleanup();
if (error) {
throw error;
} else {
return driveFile!;
}
};

View File

@ -0,0 +1,265 @@
import { DOMWindow, JSDOM } from 'jsdom';
import fetch from 'node-fetch';
import { getJson, getHtml, getAgentByUrl } from '@/misc/fetch';
import { Instance } from '@/models/entities/instance';
import { Instances } from '@/models/index';
import { getFetchInstanceMetadataLock } from '@/misc/app-lock';
import Logger from './logger';
import { URL } from 'url';
const logger = new Logger('metadata', 'cyan');
export async function fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
const unlock = await getFetchInstanceMetadataLock(instance.host);
if (!force) {
const _instance = await Instances.findOne({ host: instance.host });
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
unlock();
return;
}
}
logger.info(`Fetching metadata of ${instance.host} ...`);
try {
const [info, dom, manifest] = await Promise.all([
fetchNodeinfo(instance).catch(() => null),
fetchDom(instance).catch(() => null),
fetchManifest(instance).catch(() => null),
]);
const [favicon, icon, themeColor, name, description] = await Promise.all([
fetchFaviconUrl(instance, dom).catch(() => null),
fetchIconUrl(instance, dom, manifest).catch(() => null),
getThemeColor(dom, manifest).catch(() => null),
getSiteName(info, dom, manifest).catch(() => null),
getDescription(info, dom, manifest).catch(() => null),
]);
logger.succ(`Successfuly fetched metadata of ${instance.host}`);
const updates = {
infoUpdatedAt: new Date(),
} as Record<string, any>;
if (info) {
updates.softwareName = info.software?.name.toLowerCase();
updates.softwareVersion = info.software?.version;
updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null;
}
if (name) updates.name = name;
if (description) updates.description = description;
if (icon || favicon) updates.iconUrl = icon || favicon;
if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor;
await Instances.update(instance.id, updates);
logger.succ(`Successfuly updated metadata of ${instance.host}`);
} catch (e) {
logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally {
unlock();
}
}
type NodeInfo = {
openRegistrations?: any;
software?: {
name?: any;
version?: any;
};
metadata?: {
name?: any;
nodeName?: any;
nodeDescription?: any;
description?: any;
maintainer?: {
name?: any;
email?: any;
};
};
};
async function fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try {
const wellknown = await getJson('https://' + instance.host + '/.well-known/nodeinfo')
.catch(e => {
if (e.statusCode === 404) {
throw 'No nodeinfo provided';
} else {
throw e.statusCode || e.message;
}
});
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
throw 'No wellknown links';
}
const links = wellknown.links as any[];
const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1');
const link = lnik2_1 || lnik2_0 || lnik1_0;
if (link == null) {
throw 'No nodeinfo link provided';
}
const info = await getJson(link.href)
.catch(e => {
throw e.statusCode || e.message;
});
logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
return info;
} catch (e) {
logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e}`);
throw e;
}
}
async function fetchDom(instance: Instance): Promise<DOMWindow['document']> {
logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host;
const html = await getHtml(url);
const { window } = new JSDOM(html);
const doc = window.document;
return doc;
}
async function fetchManifest(instance: Instance): Promise<Record<string, any> | null> {
const url = 'https://' + instance.host;
const manifestUrl = url + '/manifest.json';
const manifest = await getJson(manifestUrl);
return manifest;
}
async function fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> {
const url = 'https://' + instance.host;
if (doc) {
const href = doc.querySelector('link[rel="icon"]')?.getAttribute('href');
if (href) {
return (new URL(href, url)).href;
}
}
const faviconUrl = url + '/favicon.ico';
const favicon = await fetch(faviconUrl, {
timeout: 10000,
agent: getAgentByUrl,
});
if (favicon.ok) {
return faviconUrl;
}
return null;
}
async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
}
if (doc) {
const url = 'https://' + instance.host;
const hrefAppleTouchIconPrecomposed = doc.querySelector('link[rel="apple-touch-icon-precomposed"]')?.getAttribute('href');
const hrefAppleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href');
const hrefIcon = doc.querySelector('link[rel="icon"]')?.getAttribute('href');
const href = hrefAppleTouchIconPrecomposed || hrefAppleTouchIcon || hrefIcon;
if (href) {
return (new URL(href, url)).href;
}
}
return null;
}
async function getThemeColor(doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (doc) {
const themeColor = doc.querySelector('meta[name="theme-color"]')?.getAttribute('content');
if (themeColor) {
return themeColor;
}
}
if (manifest) {
return manifest.theme_color;
}
return null;
}
async function getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (info.metadata.nodeName || info.metadata.name) {
return info.metadata.nodeName || info.metadata.name;
}
}
if (doc) {
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest?.name || manifest?.short_name;
}
return null;
}
async function getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (info.metadata.nodeDescription || info.metadata.description) {
return info.metadata.nodeDescription || info.metadata.description;
}
}
if (doc) {
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
if (meta) {
return meta;
}
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest?.name || manifest?.short_name;
}
return null;
}

View File

@ -0,0 +1,180 @@
import { publishMainStream, publishUserEvent } from '@/services/stream';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import renderAccept from '@/remote/activitypub/renderer/accept';
import renderReject from '@/remote/activitypub/renderer/reject';
import { deliver } from '@/queue/index';
import createFollowRequest from './requests/create';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
import Logger from '../logger';
import { IdentifiableError } from '@/misc/identifiable-error';
import { User } from '@/models/entities/user';
import { Followings, Users, FollowRequests, Blockings, Instances, UserProfiles } from '@/models/index';
import { instanceChart, perUserFollowingChart } from '@/services/chart/index';
import { genId } from '@/misc/gen-id';
import { createNotification } from '../create-notification';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error';
const logger = new Logger('following/create');
export async function insertFollowingDoc(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }) {
if (follower.id === followee.id) return;
let alreadyFollowed = false;
await Followings.insert({
id: genId(),
createdAt: new Date(),
followerId: follower.id,
followeeId: followee.id,
// 非正規化
followerHost: follower.host,
followerInbox: Users.isRemoteUser(follower) ? follower.inbox : null,
followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : null,
followeeHost: followee.host,
followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : null,
followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : null
}).catch(e => {
if (isDuplicateKeyValueError(e) && Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
alreadyFollowed = true;
} else {
throw e;
}
});
const req = await FollowRequests.findOne({
followeeId: followee.id,
followerId: follower.id
});
if (req) {
await FollowRequests.delete({
followeeId: followee.id,
followerId: follower.id
});
// 通知を作成
createNotification(follower.id, 'followRequestAccepted', {
notifierId: followee.id,
});
}
if (alreadyFollowed) return;
//#region Increment counts
Users.increment({ id: follower.id }, 'followingCount', 1);
Users.increment({ id: followee.id }, 'followersCount', 1);
//#endregion
//#region Update instance stats
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
registerOrFetchInstanceDoc(follower.host).then(i => {
Instances.increment({ id: i.id }, 'followingCount', 1);
instanceChart.updateFollowing(i.host, true);
});
} else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
registerOrFetchInstanceDoc(followee.host).then(i => {
Instances.increment({ id: i.id }, 'followersCount', 1);
instanceChart.updateFollowers(i.host, true);
});
}
//#endregion
perUserFollowingChart.update(follower, followee, true);
// Publish follow event
if (Users.isLocalUser(follower)) {
Users.pack(followee.id, follower, {
detail: true
}).then(packed => {
publishUserEvent(follower.id, 'follow', packed);
publishMainStream(follower.id, 'follow', packed);
});
}
// Publish followed event
if (Users.isLocalUser(followee)) {
Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'followed', packed));
// 通知を作成
createNotification(followee.id, 'follow', {
notifierId: follower.id
});
}
}
export default async function(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string) {
const [follower, followee] = await Promise.all([
Users.findOneOrFail(_follower.id),
Users.findOneOrFail(_followee.id)
]);
// check blocking
const [blocking, blocked] = await Promise.all([
Blockings.findOne({
blockerId: follower.id,
blockeeId: followee.id,
}),
Blockings.findOne({
blockerId: followee.id,
blockeeId: follower.id,
})
]);
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocked) {
// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
const content = renderActivity(renderReject(renderFollow(follower, followee, requestId), followee));
deliver(followee , content, follower.inbox);
return;
} else if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocking) {
// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
await Blockings.delete(blocking.id);
} else {
// それ以外は単純に例外
if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
}
const followeeProfile = await UserProfiles.findOneOrFail(followee.id);
// フォロー対象が鍵アカウントである or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (Users.isLocalUser(follower) && Users.isRemoteUser(followee))) {
let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー
const following = await Followings.findOne({
followerId: follower.id,
followeeId: followee.id,
});
if (following) {
autoAccept = true;
}
// フォローしているユーザーは自動承認オプション
if (!autoAccept && (Users.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
const followed = await Followings.findOne({
followerId: followee.id,
followeeId: follower.id
});
if (followed) autoAccept = true;
}
if (!autoAccept) {
await createFollowRequest(follower, followee, requestId);
return;
}
}
await insertFollowingDoc(followee, follower);
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
const content = renderActivity(renderAccept(renderFollow(follower, followee, requestId), followee));
deliver(followee, content, follower.inbox);
}
}

View File

@ -0,0 +1,69 @@
import { publishMainStream, publishUserEvent } from '@/services/stream';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { deliver } from '@/queue/index';
import Logger from '../logger';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
import { User } from '@/models/entities/user';
import { Followings, Users, Instances } from '@/models/index';
import { instanceChart, perUserFollowingChart } from '@/services/chart/index';
const logger = new Logger('following/delete');
export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, silent = false) {
const following = await Followings.findOne({
followerId: follower.id,
followeeId: followee.id
});
if (following == null) {
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
return;
}
await Followings.delete(following.id);
decrementFollowing(follower, followee);
// Publish unfollow event
if (!silent && Users.isLocalUser(follower)) {
Users.pack(followee.id, follower, {
detail: true
}).then(packed => {
publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed);
});
}
if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));
deliver(follower, content, followee.inbox);
}
}
export async function decrementFollowing(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }) {
//#region Decrement following count
Users.decrement({ id: follower.id }, 'followingCount', 1);
//#endregion
//#region Decrement followers count
Users.decrement({ id: followee.id }, 'followersCount', 1);
//#endregion
//#region Update instance stats
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
registerOrFetchInstanceDoc(follower.host).then(i => {
Instances.decrement({ id: i.id }, 'followingCount', 1);
instanceChart.updateFollowing(i.host, false);
});
} else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
registerOrFetchInstanceDoc(followee.host).then(i => {
Instances.decrement({ id: i.id }, 'followersCount', 1);
instanceChart.updateFollowers(i.host, false);
});
}
//#endregion
perUserFollowingChart.update(follower, followee, false);
}

View File

@ -0,0 +1,18 @@
import accept from './accept';
import { User } from '@/models/entities/user';
import { FollowRequests, Users } from '@/models/index';
/**
* 指定したユーザー宛てのフォローリクエストをすべて承認
* @param user ユーザー
*/
export default async function(user: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }) {
const requests = await FollowRequests.find({
followeeId: user.id
});
for (const request of requests) {
const follower = await Users.findOneOrFail(request.followerId);
accept(user, follower);
}
}

View File

@ -0,0 +1,31 @@
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import renderAccept from '@/remote/activitypub/renderer/accept';
import { deliver } from '@/queue/index';
import { publishMainStream } from '@/services/stream';
import { insertFollowingDoc } from '../create';
import { User, ILocalUser } from '@/models/entities/user';
import { FollowRequests, Users } from '@/models/index';
import { IdentifiableError } from '@/misc/identifiable-error';
export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, follower: User) {
const request = await FollowRequests.findOne({
followeeId: followee.id,
followerId: follower.id
});
if (request == null) {
throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.');
}
await insertFollowingDoc(followee, follower);
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
const content = renderActivity(renderAccept(renderFollow(follower, followee, request.requestId!), followee));
deliver(followee, content, follower.inbox);
}
Users.pack(followee.id, followee, {
detail: true
}).then(packed => publishMainStream(followee.id, 'meUpdated', packed));
}

View File

@ -0,0 +1,36 @@
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { deliver } from '@/queue/index';
import { publishMainStream } from '@/services/stream';
import { IdentifiableError } from '@/misc/identifiable-error';
import { User, ILocalUser } from '@/models/entities/user';
import { Users, FollowRequests } from '@/models/index';
export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host'] }) {
if (Users.isRemoteUser(followee)) {
const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));
if (Users.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
deliver(follower, content, followee.inbox);
}
}
const request = await FollowRequests.findOne({
followeeId: followee.id,
followerId: follower.id
});
if (request == null) {
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
}
await FollowRequests.delete({
followeeId: followee.id,
followerId: follower.id
});
Users.pack(followee.id, followee, {
detail: true
}).then(packed => publishMainStream(followee.id, 'meUpdated', packed));
}

View File

@ -0,0 +1,63 @@
import { publishMainStream } from '@/services/stream';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import { deliver } from '@/queue/index';
import { User } from '@/models/entities/user';
import { Blockings, FollowRequests, Users } from '@/models/index';
import { genId } from '@/misc/gen-id';
import { createNotification } from '../../create-notification';
export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, requestId?: string) {
if (follower.id === followee.id) return;
// check blocking
const [blocking, blocked] = await Promise.all([
Blockings.findOne({
blockerId: follower.id,
blockeeId: followee.id,
}),
Blockings.findOne({
blockerId: followee.id,
blockeeId: follower.id,
})
]);
if (blocking != null) throw new Error('blocking');
if (blocked != null) throw new Error('blocked');
const followRequest = await FollowRequests.save({
id: genId(),
createdAt: new Date(),
followerId: follower.id,
followeeId: followee.id,
requestId,
// 非正規化
followerHost: follower.host,
followerInbox: Users.isRemoteUser(follower) ? follower.inbox : undefined,
followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : undefined,
followeeHost: followee.host,
followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : undefined,
followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : undefined
});
// Publish receiveRequest event
if (Users.isLocalUser(followee)) {
Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'receiveFollowRequest', packed));
Users.pack(followee.id, followee, {
detail: true
}).then(packed => publishMainStream(followee.id, 'meUpdated', packed));
// 通知を作成
createNotification(followee.id, 'receiveFollowRequest', {
notifierId: follower.id,
followRequestId: followRequest.id
});
}
if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
const content = renderActivity(renderFollow(follower, followee));
deliver(follower, content, followee.inbox);
}
}

View File

@ -0,0 +1,46 @@
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderFollow from '@/remote/activitypub/renderer/follow';
import renderReject from '@/remote/activitypub/renderer/reject';
import { deliver } from '@/queue/index';
import { publishMainStream, publishUserEvent } from '@/services/stream';
import { User, ILocalUser } from '@/models/entities/user';
import { Users, FollowRequests, Followings } from '@/models/index';
import { decrementFollowing } from '../delete';
export default async function(followee: { id: User['id']; host: User['host']; uri: User['host'] }, follower: User) {
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) {
const request = await FollowRequests.findOne({
followeeId: followee.id,
followerId: follower.id
});
const content = renderActivity(renderReject(renderFollow(follower, followee, request!.requestId!), followee));
deliver(followee, content, follower.inbox);
}
const request = await FollowRequests.findOne({
followeeId: followee.id,
followerId: follower.id
});
if (request) {
await FollowRequests.delete(request.id);
} else {
const following = await Followings.findOne({
followeeId: followee.id,
followerId: follower.id
});
if (following) {
await Followings.delete(following.id);
decrementFollowing(follower, followee);
}
}
Users.pack(followee.id, follower, {
detail: true
}).then(packed => {
publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed);
});
}

View File

@ -0,0 +1,92 @@
import config from '@/config/index';
import renderAdd from '@/remote/activitypub/renderer/add';
import renderRemove from '@/remote/activitypub/renderer/remove';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { IdentifiableError } from '@/misc/identifiable-error';
import { User } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { Notes, UserNotePinings, Users } from '@/models/index';
import { UserNotePining } from '@/models/entities/user-note-pining';
import { genId } from '@/misc/gen-id';
import { deliverToFollowers } from '@/remote/activitypub/deliver-manager';
import { deliverToRelays } from '../relay';
/**
* 指定した投稿をピン留めします
* @param user
* @param noteId
*/
export async function addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
// Fetch pinee
const note = await Notes.findOne({
id: noteId,
userId: user.id
});
if (note == null) {
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
}
const pinings = await UserNotePinings.find({ userId: user.id });
if (pinings.length >= 5) {
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
}
if (pinings.some(pining => pining.noteId === note.id)) {
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
}
await UserNotePinings.insert({
id: genId(),
createdAt: new Date(),
userId: user.id,
noteId: note.id
} as UserNotePining);
// Deliver to remote followers
if (Users.isLocalUser(user)) {
deliverPinnedChange(user.id, note.id, true);
}
}
/**
* 指定した投稿のピン留めを解除します
* @param user
* @param noteId
*/
export async function removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
// Fetch unpinee
const note = await Notes.findOne({
id: noteId,
userId: user.id
});
if (note == null) {
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
}
UserNotePinings.delete({
userId: user.id,
noteId: note.id
});
// Deliver to remote followers
if (Users.isLocalUser(user)) {
deliverPinnedChange(user.id, noteId, false);
}
}
export async function deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) {
const user = await Users.findOne(userId);
if (user == null) throw new Error('user not found');
if (!Users.isLocalUser(user)) return;
const target = `${config.url}/users/${user.id}/collections/featured`;
const item = `${config.url}/notes/${noteId}`;
const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}

View File

@ -0,0 +1,19 @@
import renderUpdate from '@/remote/activitypub/renderer/update';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { Users } from '@/models/index';
import { User } from '@/models/entities/user';
import { renderPerson } from '@/remote/activitypub/renderer/person';
import { deliverToFollowers } from '@/remote/activitypub/deliver-manager';
import { deliverToRelays } from '../relay';
export async function publishToFollowers(userId: User['id']) {
const user = await Users.findOne(userId);
if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (Users.isLocalUser(user)) {
const content = renderActivity(renderUpdate(await renderPerson(user), user));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}
}

View File

@ -0,0 +1,13 @@
import { ModerationLogs } from '@/models/index';
import { genId } from '@/misc/gen-id';
import { User } from '@/models/entities/user';
export async function insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record<string, any>) {
await ModerationLogs.insert({
id: genId(),
createdAt: new Date(),
userId: moderator.id,
type: type,
info: info || {}
});
}

View File

@ -0,0 +1,27 @@
import { createSystemUser } from './create-system-user';
import { ILocalUser } from '@/models/entities/user';
import { Users } from '@/models/index';
import { Cache } from '@/misc/cache';
const ACTOR_USERNAME = 'instance.actor' as const;
const cache = new Cache<ILocalUser>(Infinity);
export async function getInstanceActor(): Promise<ILocalUser> {
const cached = cache.get(null);
if (cached) return cached;
const user = await Users.findOne({
host: null,
username: ACTOR_USERNAME
}) as ILocalUser | undefined;
if (user) {
cache.set(null, user);
return user;
} else {
const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser;
cache.set(null, created);
return created;
}
}

View File

@ -0,0 +1,127 @@
import * as cluster from 'cluster';
import * as chalk from 'chalk';
import * as dateformat from 'dateformat';
import { envOption } from '../env';
import config from '@/config/index';
import * as SyslogPro from 'syslog-pro';
type Domain = {
name: string;
color?: string;
};
type Level = 'error' | 'success' | 'warning' | 'debug' | 'info';
export default class Logger {
private domain: Domain;
private parentLogger: Logger | null = null;
private store: boolean;
private syslogClient: any | null = null;
constructor(domain: string, color?: string, store = true) {
this.domain = {
name: domain,
color: color,
};
this.store = store;
if (config.syslog) {
this.syslogClient = new SyslogPro.RFC5424({
applacationName: 'Misskey',
timestamp: true,
encludeStructuredData: true,
color: true,
extendedColor: true,
server: {
target: config.syslog.host,
port: config.syslog.port,
}
});
}
}
public createSubLogger(domain: string, color?: string, store = true): Logger {
const logger = new Logger(domain, color, store);
logger.parentLogger = this;
return logger;
}
private log(level: Level, message: string, data?: Record<string, any> | null, important = false, subDomains: Domain[] = [], store = true): void {
if (envOption.quiet) return;
if (!this.store) store = false;
if (level === 'debug') store = false;
if (this.parentLogger) {
this.parentLogger.log(level, message, data, important, [this.domain].concat(subDomains), store);
return;
}
const time = dateformat(new Date(), 'HH:MM:ss');
const worker = cluster.isPrimary ? '*' : cluster.worker.id;
const l =
level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') :
level === 'warning' ? chalk.yellow('WARN') :
level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') :
level === 'debug' ? chalk.gray('VERB') :
level === 'info' ? chalk.blue('INFO') :
null;
const domains = [this.domain].concat(subDomains).map(d => d.color ? chalk.keyword(d.color)(d.name) : chalk.white(d.name));
const m =
level === 'error' ? chalk.red(message) :
level === 'warning' ? chalk.yellow(message) :
level === 'success' ? chalk.green(message) :
level === 'debug' ? chalk.gray(message) :
level === 'info' ? message :
null;
let log = `${l} ${worker}\t[${domains.join(' ')}]\t${m}`;
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
console.log(important ? chalk.bold(log) : log);
if (store) {
if (this.syslogClient) {
const send =
level === 'error' ? this.syslogClient.error :
level === 'warning' ? this.syslogClient.warning :
level === 'success' ? this.syslogClient.info :
level === 'debug' ? this.syslogClient.info :
level === 'info' ? this.syslogClient.info :
null as never;
send.bind(this.syslogClient)(message).catch(() => {});
}
}
}
public error(x: string | Error, data?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
if (x instanceof Error) {
data = data || {};
data.e = x;
this.log('error', x.toString(), data, important);
} else if (typeof x === 'object') {
this.log('error', `${(x as any).message || (x as any).name || x}`, data, important);
} else {
this.log('error', `${x}`, data, important);
}
}
public warn(message: string, data?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
this.log('warning', message, data, important);
}
public succ(message: string, data?: Record<string, any> | null, important = false): void { // 何かに成功した状況で使う
this.log('success', message, data, important);
}
public debug(message: string, data?: Record<string, any> | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報)
if (process.env.NODE_ENV != 'production' || envOption.verbose) {
this.log('debug', message, data, important);
}
}
public info(message: string, data?: Record<string, any> | null, important = false): void { // それ以外
this.log('info', message, data, important);
}
}

View File

@ -0,0 +1,108 @@
import { User } from '@/models/entities/user';
import { UserGroup } from '@/models/entities/user-group';
import { DriveFile } from '@/models/entities/drive-file';
import { MessagingMessages, UserGroupJoinings, Mutings, Users } from '@/models/index';
import { genId } from '@/misc/gen-id';
import { MessagingMessage } from '@/models/entities/messaging-message';
import { publishMessagingStream, publishMessagingIndexStream, publishMainStream, publishGroupMessagingStream } from '@/services/stream';
import pushNotification from '../push-notification';
import { Not } from 'typeorm';
import { Note } from '@/models/entities/note';
import renderNote from '@/remote/activitypub/renderer/note';
import renderCreate from '@/remote/activitypub/renderer/create';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { deliver } from '@/queue/index';
export async function createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: User | undefined, recipientGroup: UserGroup | undefined, text: string | undefined, file: DriveFile | null, uri?: string) {
const message = {
id: genId(),
createdAt: new Date(),
fileId: file ? file.id : null,
recipientId: recipientUser ? recipientUser.id : null,
groupId: recipientGroup ? recipientGroup.id : null,
text: text ? text.trim() : null,
userId: user.id,
isRead: false,
reads: [] as any[],
uri
} as MessagingMessage;
await MessagingMessages.insert(message);
const messageObj = await MessagingMessages.pack(message);
if (recipientUser) {
if (Users.isLocalUser(user)) {
// 自分のストリーム
publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj);
publishMessagingIndexStream(message.userId, 'message', messageObj);
publishMainStream(message.userId, 'messagingMessage', messageObj);
}
if (Users.isLocalUser(recipientUser)) {
// 相手のストリーム
publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj);
publishMessagingIndexStream(recipientUser.id, 'message', messageObj);
publishMainStream(recipientUser.id, 'messagingMessage', messageObj);
}
} else if (recipientGroup) {
// グループのストリーム
publishGroupMessagingStream(recipientGroup.id, 'message', messageObj);
// メンバーのストリーム
const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id });
for (const joining of joinings) {
publishMessagingIndexStream(joining.userId, 'message', messageObj);
publishMainStream(joining.userId, 'messagingMessage', messageObj);
}
}
// 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
setTimeout(async () => {
const freshMessage = await MessagingMessages.findOne(message.id);
if (freshMessage == null) return; // メッセージが削除されている場合もある
if (recipientUser && Users.isLocalUser(recipientUser)) {
if (freshMessage.isRead) return; // 既読
//#region ただしミュートされているなら発行しない
const mute = await Mutings.find({
muterId: recipientUser.id,
});
if (mute.map(m => m.muteeId).includes(user.id)) return;
//#endregion
publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj);
pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj);
} else if (recipientGroup) {
const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id, userId: Not(user.id) });
for (const joining of joinings) {
if (freshMessage.reads.includes(joining.userId)) return; // 既読
publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj);
pushNotification(joining.userId, 'unreadMessagingMessage', messageObj);
}
}
}, 2000);
if (recipientUser && Users.isLocalUser(user) && Users.isRemoteUser(recipientUser)) {
const note = {
id: message.id,
createdAt: message.createdAt,
fileIds: message.fileId ? [ message.fileId ] : [],
text: message.text,
userId: message.userId,
visibility: 'specified',
mentions: [ recipientUser ].map(u => u.id),
mentionedRemoteUsers: JSON.stringify([ recipientUser ].map(u => ({
uri: u.uri,
username: u.username,
host: u.host
}))),
} as Note;
const activity = renderActivity(renderCreate(await renderNote(note, false, true), note));
deliver(user, activity, recipientUser.inbox);
}
return messageObj;
}

View File

@ -0,0 +1,30 @@
import config from '@/config/index';
import { MessagingMessages, Users } from '@/models/index';
import { MessagingMessage } from '@/models/entities/messaging-message';
import { publishGroupMessagingStream, publishMessagingStream } from '@/services/stream';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderDelete from '@/remote/activitypub/renderer/delete';
import renderTombstone from '@/remote/activitypub/renderer/tombstone';
import { deliver } from '@/queue/index';
export async function deleteMessage(message: MessagingMessage) {
await MessagingMessages.delete(message.id);
postDeleteMessage(message);
}
async function postDeleteMessage(message: MessagingMessage) {
if (message.recipientId) {
const user = await Users.findOneOrFail(message.userId);
const recipient = await Users.findOneOrFail(message.recipientId);
if (Users.isLocalUser(user)) publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id);
if (Users.isLocalUser(recipient)) publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id);
if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) {
const activity = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${message.id}`), user));
deliver(user, activity, recipient.inbox);
}
} else if (message.groupId) {
publishGroupMessagingStream(message.groupId, 'deleted', message.id);
}
}

View File

@ -0,0 +1,645 @@
import * as mfm from 'mfm-js';
import es from '../../db/elasticsearch';
import { publishMainStream, publishNotesStream } from '@/services/stream';
import DeliverManager from '@/remote/activitypub/deliver-manager';
import renderNote from '@/remote/activitypub/renderer/note';
import renderCreate from '@/remote/activitypub/renderer/create';
import renderAnnounce from '@/remote/activitypub/renderer/announce';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { resolveUser } from '@/remote/resolve-user';
import config from '@/config/index';
import { updateHashtags } from '../update-hashtag';
import { concat } from '@/prelude/array';
import { insertNoteUnread } from '@/services/note/unread';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
import { extractMentions } from '@/misc/extract-mentions';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm';
import { extractHashtags } from '@/misc/extract-hashtags';
import { Note, IMentionedRemoteUsers } from '@/models/entities/note';
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings, NoteThreadMutings } from '@/models/index';
import { DriveFile } from '@/models/entities/drive-file';
import { App } from '@/models/entities/app';
import { Not, getConnection, In } from 'typeorm';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user';
import { genId } from '@/misc/gen-id';
import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index';
import { Poll, IPoll } from '@/models/entities/poll';
import { createNotification } from '../create-notification';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error';
import { checkHitAntenna } from '@/misc/check-hit-antenna';
import { checkWordMute } from '@/misc/check-word-mute';
import { addNoteToAntenna } from '../add-note-to-antenna';
import { countSameRenotes } from '@/misc/count-same-renotes';
import { deliverToRelays } from '../relay';
import { Channel } from '@/models/entities/channel';
import { normalizeForSearch } from '@/misc/normalize-for-search';
import { getAntennas } from '@/misc/antenna-cache';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager {
private notifier: { id: User['id']; };
private note: Note;
private queue: {
target: ILocalUser['id'];
reason: NotificationType;
}[];
constructor(notifier: { id: User['id']; }, note: Note) {
this.notifier = notifier;
this.note = note;
this.queue = [];
}
public push(notifiee: ILocalUser['id'], reason: NotificationType) {
// 自分自身へは通知しない
if (this.notifier.id === notifiee) return;
const exist = this.queue.find(x => x.target === notifiee);
if (exist) {
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
if (reason != 'mention') {
exist.reason = reason;
}
} else {
this.queue.push({
reason: reason,
target: notifiee
});
}
}
public async deliver() {
for (const x of this.queue) {
// ミュート情報を取得
const mentioneeMutes = await Mutings.find({
muterId: x.target
});
const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId);
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id
});
}
}
}
}
type Option = {
createdAt?: Date | null;
name?: string | null;
text?: string | null;
reply?: Note | null;
renote?: Note | null;
files?: DriveFile[] | null;
poll?: IPoll | null;
viaMobile?: boolean | null;
localOnly?: boolean | null;
cw?: string | null;
visibility?: string;
visibleUsers?: User[] | null;
channel?: Channel | null;
apMentions?: User[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
uri?: string | null;
url?: string | null;
app?: App | null;
};
export default async (user: { id: User['id']; username: User['username']; host: User['host']; isSilenced: User['isSilenced']; }, data: Option, silent = false) => new Promise<Note>(async (res, rej) => {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
if (data.reply.channelId) {
data.channel = await Channels.findOne(data.reply.channelId);
} else {
data.channel = null;
}
}
// チャンネル内にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && (data.channel == null) && data.reply.channelId) {
data.channel = await Channels.findOne(data.reply.channelId);
}
if (data.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public';
if (data.viaMobile == null) data.viaMobile = false;
if (data.localOnly == null) data.localOnly = false;
if (data.channel != null) data.visibility = 'public';
if (data.channel != null) data.visibleUsers = [];
if (data.channel != null) data.localOnly = true;
// サイレンス
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
data.visibility = 'home';
}
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
return rej('Renote target is not public or home');
}
// Renote対象がpublicではないならhomeにする
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// Renote対象がfollowersならfollowersにする
if (data.renote && data.renote.visibility === 'followers') {
data.visibility = 'followers';
}
// 返信対象がpublicではないならhomeにする
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// ローカルのみをRenoteしたらローカルのみにする
if (data.renote && data.renote.localOnly && data.channel == null) {
data.localOnly = true;
}
// ローカルのみにリプライしたらローカルのみにする
if (data.reply && data.reply.localOnly && data.channel == null) {
data.localOnly = true;
}
if (data.text) {
data.text = data.text.trim();
}
let tags = data.apHashtags;
let emojis = data.apEmojis;
let mentionedUsers = data.apMentions;
// Parse MFM if needed
if (!tags || !emojis || !mentionedUsers) {
const tokens = data.text ? mfm.parse(data.text)! : [];
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
const choiceTokens = data.poll && data.poll.choices
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
: [];
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
tags = data.apHashtags || extractHashtags(combinedTokens);
emojis = data.apEmojis || extractCustomEmojisFromMfm(combinedTokens);
mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens);
}
tags = tags.filter(tag => Array.from(tag || '').length <= 128).splice(0, 32);
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
mentionedUsers.push(await Users.findOneOrFail(data.reply.userId));
}
if (data.visibility == 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
if (!mentionedUsers.some(x => x.id === u.id)) {
mentionedUsers.push(u);
}
}
if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
data.visibleUsers.push(await Users.findOneOrFail(data.reply.userId));
}
}
const note = await insertNote(user, data, tags, emojis, mentionedUsers);
res(note);
// 統計を更新
notesChart.update(note, true);
perUserNotesChart.update(user, note, true);
// Register host
if (Users.isRemoteUser(user)) {
registerOrFetchInstanceDoc(user.host).then(i => {
Instances.increment({ id: i.id }, 'notesCount', 1);
instanceChart.updateNote(i.host, note, true);
});
}
// ハッシュタグ更新
if (data.visibility === 'public' || data.visibility === 'home') {
updateHashtags(user, tags);
}
// Increment notes count (user)
incNotesCountOfUser(user);
// Word mute
// TODO: cache
UserProfiles.find({
enableWordMute: true
}).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
MutedNotes.insert({
id: genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
// Antenna
Followings.createQueryBuilder('following')
.andWhere(`following.followeeId = :userId`, { userId: note.userId })
.getMany()
.then(async followings => {
const blockings = await Blockings.find({ blockerId: user.id }); // TODO: キャッシュしたい
const followers = followings.map(f => f.followerId);
for (const antenna of (await getAntennas())) {
if (blockings.some(blocking => blocking.blockeeId === antenna.userId)) continue; // この処理は checkHitAntenna 内でやるようにしてもいいかも
checkHitAntenna(antenna, note, user, followers).then(hit => {
if (hit) {
addNoteToAntenna(antenna, note, user);
}
});
}
});
// Channel
if (note.channelId) {
ChannelFollowings.find({ followeeId: note.channelId }).then(followings => {
for (const following of followings) {
insertNoteUnread(following.followerId, note, {
isSpecified: false,
isMentioned: false,
});
}
});
}
if (data.reply) {
saveReply(data.reply, note);
}
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (data.renote && (await countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
incRenoteCount(data.renote);
}
if (!silent) {
// ローカルユーザーのチャートはタイムライン取得時に更新しているのでリモートユーザーの場合だけでよい
if (Users.isRemoteUser(user)) activeUsersChart.update(user);
// 未読通知を作成
if (data.visibility == 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
// ローカルユーザーのみ
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: true,
isMentioned: false,
});
}
} else {
for (const u of mentionedUsers) {
// ローカルユーザーのみ
if (!Users.isLocalUser(u)) continue;
insertNoteUnread(u.id, note, {
isSpecified: false,
isMentioned: true,
});
}
}
// Pack the note
const noteObj = await Notes.pack(note);
publishNotesStream(noteObj);
const nm = new NotificationManager(user, note);
const nmRelatedPromises = [];
await createMentionedEvents(mentionedUsers, note, nm);
// If has in reply to note
if (data.reply) {
// Fetch watchers
nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm));
// 通知
if (data.reply.userHost === null) {
const threadMuted = await NoteThreadMutings.findOne({
userId: data.reply.userId,
threadId: data.reply.threadId || data.reply.id,
});
if (!threadMuted) {
nm.push(data.reply.userId, 'reply');
publishMainStream(data.reply.userId, 'reply', noteObj);
}
}
}
// If it is renote
if (data.renote) {
const type = data.text ? 'quote' : 'renote';
// Notify
if (data.renote.userHost === null) {
nm.push(data.renote.userId, type);
}
// Fetch watchers
nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type));
// Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
publishMainStream(data.renote.userId, 'renote', noteObj);
}
}
Promise.all(nmRelatedPromises).then(() => {
nm.deliver();
});
//#region AP deliver
if (Users.isLocalUser(user)) {
(async () => {
const noteActivity = await renderNoteOrRenoteActivity(data, note);
const dm = new DeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) {
dm.addDirectRecipe(u as IRemoteUser);
}
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
if (data.reply && data.reply.userHost !== null) {
const u = await Users.findOne(data.reply.userId);
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
if (data.renote && data.renote.userHost !== null) {
const u = await Users.findOne(data.renote.userId);
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// フォロワーに配送
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
}
if (['public'].includes(note.visibility)) {
deliverToRelays(user, noteActivity);
}
dm.execute();
})();
}
//#endregion
}
if (data.channel) {
Channels.increment({ id: data.channel.id }, 'notesCount', 1);
Channels.update(data.channel.id, {
lastNotedAt: new Date(),
});
Notes.count({
userId: user.id,
channelId: data.channel.id,
}).then(count => {
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
if (count === 1) {
Channels.increment({ id: data.channel!.id }, 'usersCount', 1);
}
});
}
// Register to search database
index(note);
});
async function renderNoteOrRenoteActivity(data: Option, note: Note) {
if (data.localOnly) return null;
const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length == 0)
? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote.id}`, note)
: renderCreate(await renderNote(note, false), note);
return renderActivity(content);
}
function incRenoteCount(renote: Note) {
Notes.createQueryBuilder().update()
.set({
renoteCount: () => '"renoteCount" + 1',
score: () => '"score" + 1'
})
.where('id = :id', { id: renote.id })
.execute();
}
async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: User[]) {
const insert = new Note({
id: genId(data.createdAt!),
createdAt: data.createdAt!,
fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
threadId: data.reply
? data.reply.threadId
? data.reply.threadId
: data.reply.id
: null,
name: data.name,
text: data.text,
hasPoll: data.poll != null,
cw: data.cw == null ? null : data.cw,
tags: tags.map(tag => normalizeForSearch(tag)),
emojis,
userId: user.id,
viaMobile: data.viaMobile!,
localOnly: data.localOnly!,
visibility: data.visibility as any,
visibleUserIds: data.visibility == 'specified'
? data.visibleUsers
? data.visibleUsers.map(u => u.id)
: []
: [],
attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
// 以下非正規化データ
replyUserId: data.reply ? data.reply.userId : null,
replyUserHost: data.reply ? data.reply.userHost : null,
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
});
if (data.uri != null) insert.uri = data.uri;
if (data.url != null) insert.url = data.url;
// Append mentions data
if (mentionedUsers.length > 0) {
insert.mentions = mentionedUsers.map(u => u.id);
const profiles = await UserProfiles.find({ userId: In(insert.mentions) });
insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => Users.isRemoteUser(u)).map(u => {
const profile = profiles.find(p => p.userId == u.id);
const url = profile != null ? profile.url : null;
return {
uri: u.uri,
url: url == null ? undefined : url,
username: u.username,
host: u.host
} as IMentionedRemoteUsers[0];
}));
}
// 投稿を作成
try {
if (insert.hasPoll) {
// Start transaction
await getConnection().transaction(async transactionalEntityManager => {
await transactionalEntityManager.insert(Note, insert);
const poll = new Poll({
noteId: insert.id,
choices: data.poll!.choices,
expiresAt: data.poll!.expiresAt,
multiple: data.poll!.multiple,
votes: new Array(data.poll!.choices.length).fill(0),
noteVisibility: insert.visibility,
userId: user.id,
userHost: user.host
});
await transactionalEntityManager.insert(Poll, poll);
});
} else {
await Notes.insert(insert);
}
return insert;
} catch (e) {
// duplicate key error
if (isDuplicateKeyValueError(e)) {
const err = new Error('Duplicated note');
err.name = 'duplicated';
throw err;
}
console.error(e);
throw e;
}
}
function index(note: Note) {
if (note.text == null || config.elasticsearch == null) return;
es!.index({
index: config.elasticsearch.index || 'misskey_note',
id: note.id.toString(),
body: {
text: normalizeForSearch(note.text),
userId: note.userId,
userHost: note.userHost
}
});
}
async function notifyToWatchersOfRenotee(renote: Note, user: { id: User['id']; }, nm: NotificationManager, type: NotificationType) {
const watchers = await NoteWatchings.find({
noteId: renote.id,
userId: Not(user.id)
});
for (const watcher of watchers) {
nm.push(watcher.userId, type);
}
}
async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, nm: NotificationManager) {
const watchers = await NoteWatchings.find({
noteId: reply.id,
userId: Not(user.id)
});
for (const watcher of watchers) {
nm.push(watcher.userId, 'reply');
}
}
async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager) {
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
const threadMuted = await NoteThreadMutings.findOne({
userId: u.id,
threadId: note.threadId || note.id,
});
if (threadMuted) {
continue;
}
const detailPackedNote = await Notes.pack(note, u, {
detail: true
});
publishMainStream(u.id, 'mention', detailPackedNote);
// Create notification
nm.push(u.id, 'mention');
}
}
function saveReply(reply: Note, note: Note) {
Notes.increment({ id: reply.id }, 'repliesCount', 1);
}
function incNotesCountOfUser(user: { id: User['id']; }) {
Users.createQueryBuilder().update()
.set({
updatedAt: new Date(),
notesCount: () => '"notesCount" + 1'
})
.where('id = :id', { id: user.id })
.execute();
}
async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
if (tokens == null) return [];
const mentions = extractMentions(tokens);
let mentionedUsers = (await Promise.all(mentions.map(m =>
resolveUser(m.username, m.host || user.host).catch(() => null)
))).filter(x => x != null) as User[];
// Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) =>
i === self.findIndex(u2 => u.id === u2.id)
);
return mentionedUsers;
}

View File

@ -0,0 +1,137 @@
import { publishNoteStream } from '@/services/stream';
import renderDelete from '@/remote/activitypub/renderer/delete';
import renderAnnounce from '@/remote/activitypub/renderer/announce';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderTombstone from '@/remote/activitypub/renderer/tombstone';
import config from '@/config/index';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user';
import { Note, IMentionedRemoteUsers } from '@/models/entities/note';
import { Notes, Users, Instances } from '@/models/index';
import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index';
import { deliverToFollowers, deliverToUser } from '@/remote/activitypub/deliver-manager';
import { countSameRenotes } from '@/misc/count-same-renotes';
import { deliverToRelays } from '../relay';
import { Brackets, In } from 'typeorm';
/**
* 投稿を削除します。
* @param user 投稿者
* @param note 投稿
*/
export default async function(user: User, note: Note, quiet = false) {
const deletedAt = new Date();
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
Notes.decrement({ id: note.renoteId }, 'renoteCount', 1);
Notes.decrement({ id: note.renoteId }, 'score', 1);
}
if (!quiet) {
publishNoteStream(note.id, 'deleted', {
deletedAt: deletedAt
});
//#region ローカルの投稿なら削除アクティビティを配送
if (Users.isLocalUser(user) && !note.localOnly) {
let renote: Note | undefined;
// if deletd note is renote
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length == 0)) {
renote = await Notes.findOne({
id: note.renoteId
});
}
const content = renderActivity(renote
? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), user)
: renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user));
deliverToConcerned(user, note, content);
}
// also deliever delete activity to cascaded notes
const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
for (const cascadingNote of cascadingNotes) {
if (!cascadingNote.user) continue;
if (!Users.isLocalUser(cascadingNote.user)) continue;
const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
deliverToConcerned(cascadingNote.user, cascadingNote, content);
}
//#endregion
// 統計を更新
notesChart.update(note, false);
perUserNotesChart.update(user, note, false);
if (Users.isRemoteUser(user)) {
registerOrFetchInstanceDoc(user.host).then(i => {
Instances.decrement({ id: i.id }, 'notesCount', 1);
instanceChart.updateNote(i.host, note, false);
});
}
}
await Notes.delete({
id: note.id,
userId: user.id
});
}
async function findCascadingNotes(note: Note) {
const cascadingNotes: Note[] = [];
const recursive = async (noteId: string) => {
const query = Notes.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
q.where('note.renoteId = :noteId', { noteId })
.andWhere('note.text IS NOT NULL');
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
for (const reply of replies) {
cascadingNotes.push(reply);
await recursive(reply.id);
}
};
await recursive(note.id);
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
}
async function getMentionedRemoteUsers(note: Note) {
const where = [] as any[];
// mention / reply / dm
const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
if (uris.length > 0) {
where.push(
{ uri: In(uris) }
);
}
// renote / quote
if (note.renoteUserId) {
where.push({
id: note.renoteUserId
});
}
if (where.length === 0) return [];
return await Users.find({
where
}) as IRemoteUser[];
}
async function deliverToConcerned(user: ILocalUser, note: Note, content: any) {
deliverToFollowers(user, content);
deliverToRelays(user, content);
const remoteUsers = await getMentionedRemoteUsers(note);
for (const remoteUser of remoteUsers) {
deliverToUser(user, content, remoteUser);
}
}

View File

@ -0,0 +1,22 @@
import renderUpdate from '@/remote/activitypub/renderer/update';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import renderNote from '@/remote/activitypub/renderer/note';
import { Users, Notes } from '@/models/index';
import { Note } from '@/models/entities/note';
import { deliverToFollowers } from '@/remote/activitypub/deliver-manager';
import { deliverToRelays } from '../../relay';
export async function deliverQuestionUpdate(noteId: Note['id']) {
const note = await Notes.findOne(noteId);
if (note == null) throw new Error('note not found');
const user = await Users.findOne(note.userId);
if (user == null) throw new Error('note not found');
if (Users.isLocalUser(user)) {
const content = renderActivity(renderUpdate(await renderNote(note, false), user));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}
}

View File

@ -0,0 +1,81 @@
import { publishNoteStream } from '@/services/stream';
import { User } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { PollVotes, NoteWatchings, Polls, Blockings } from '@/models/index';
import { Not } from 'typeorm';
import { genId } from '@/misc/gen-id';
import { createNotification } from '../../create-notification';
export default async function(user: User, note: Note, choice: number) {
const poll = await Polls.findOne(note.id);
if (poll == null) throw new Error('poll not found');
// Check whether is valid choice
if (poll.choices[choice] == null) throw new Error('invalid choice param');
// Check blocking
if (note.userId !== user.id) {
const block = await Blockings.findOne({
blockerId: note.userId,
blockeeId: user.id,
});
if (block) {
throw new Error('blocked');
}
}
// if already voted
const exist = await PollVotes.find({
noteId: note.id,
userId: user.id
});
if (poll.multiple) {
if (exist.some(x => x.choice === choice)) {
throw new Error('already voted');
}
} else if (exist.length !== 0) {
throw new Error('already voted');
}
// Create vote
await PollVotes.insert({
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
choice: choice
});
// Increment votes count
const index = choice + 1; // In SQL, array index is 1 based
await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
publishNoteStream(note.id, 'pollVoted', {
choice: choice,
userId: user.id
});
// Notify
createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id,
choice: choice
});
// Fetch watchers
NoteWatchings.find({
noteId: note.id,
userId: Not(user.id),
})
.then(watchers => {
for (const watcher of watchers) {
createNotification(watcher.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id,
choice: choice
});
}
});
}

View File

@ -0,0 +1,135 @@
import { publishNoteStream } from '@/services/stream';
import { renderLike } from '@/remote/activitypub/renderer/like';
import DeliverManager from '@/remote/activitypub/deliver-manager';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { toDbReaction, decodeReaction } from '@/misc/reaction-lib';
import { User, IRemoteUser } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings } from '@/models/index';
import { Not } from 'typeorm';
import { perUserReactionsChart } from '@/services/chart/index';
import { genId } from '@/misc/gen-id';
import { createNotification } from '../../create-notification';
import deleteReaction from './delete';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error';
import { NoteReaction } from '@/models/entities/note-reaction';
import { IdentifiableError } from '@/misc/identifiable-error';
export default async (user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) => {
// Check blocking
if (note.userId !== user.id) {
const block = await Blockings.findOne({
blockerId: note.userId,
blockeeId: user.id,
});
if (block) {
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
}
}
// TODO: cache
reaction = await toDbReaction(reaction, user.host);
const record: NoteReaction = {
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
reaction
};
// Create reaction
try {
await NoteReactions.insert(record);
} catch (e) {
if (isDuplicateKeyValueError(e)) {
const exists = await NoteReactions.findOneOrFail({
noteId: note.id,
userId: user.id,
});
if (exists.reaction !== reaction) {
// 別のリアクションがすでにされていたら置き換える
await deleteReaction(user, note);
await NoteReactions.insert(record);
} else {
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
} else {
throw e;
}
}
// Increment reactions count
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
await Notes.createQueryBuilder().update()
.set({
reactions: () => sql,
score: () => '"score" + 1'
})
.where('id = :id', { id: note.id })
.execute();
perUserReactionsChart.update(user, note);
// カスタム絵文字リアクションだったら絵文字情報も送る
const decodedReaction = decodeReaction(reaction);
let emoji = await Emojis.findOne({
where: {
name: decodedReaction.name,
host: decodedReaction.host
},
select: ['name', 'host', 'url']
});
if (emoji) {
emoji = {
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
url: emoji.url
} as any;
}
publishNoteStream(note.id, 'reacted', {
reaction: decodedReaction.reaction,
emoji: emoji,
userId: user.id
});
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
createNotification(note.userId, 'reaction', {
notifierId: user.id,
noteId: note.id,
reaction: reaction
});
}
// Fetch watchers
NoteWatchings.find({
noteId: note.id,
userId: Not(user.id)
}).then(watchers => {
for (const watcher of watchers) {
createNotification(watcher.userId, 'reaction', {
notifierId: user.id,
noteId: note.id,
reaction: reaction
});
}
});
//#region 配信
if (Users.isLocalUser(user) && !note.localOnly) {
const content = renderActivity(await renderLike(record, note));
const dm = new DeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await Users.findOne(note.userId);
dm.addDirectRecipe(reactee as IRemoteUser);
}
dm.addFollowersRecipe();
dm.execute();
}
//#endregion
};

View File

@ -0,0 +1,58 @@
import { publishNoteStream } from '@/services/stream';
import { renderLike } from '@/remote/activitypub/renderer/like';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import DeliverManager from '@/remote/activitypub/deliver-manager';
import { IdentifiableError } from '@/misc/identifiable-error';
import { User, IRemoteUser } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { NoteReactions, Users, Notes } from '@/models/index';
import { decodeReaction } from '@/misc/reaction-lib';
export default async (user: { id: User['id']; host: User['host']; }, note: Note) => {
// if already unreacted
const exist = await NoteReactions.findOne({
noteId: note.id,
userId: user.id,
});
if (exist == null) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
// Delete reaction
const result = await NoteReactions.delete(exist.id);
if (result.affected !== 1) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
// Decrement reactions count
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
await Notes.createQueryBuilder().update()
.set({
reactions: () => sql,
})
.where('id = :id', { id: note.id })
.execute();
Notes.decrement({ id: note.id }, 'score', 1);
publishNoteStream(note.id, 'unreacted', {
reaction: decodeReaction(exist.reaction).reaction,
userId: user.id
});
//#region 配信
if (Users.isLocalUser(user) && !note.localOnly) {
const content = renderActivity(renderUndo(await renderLike(exist, note), user));
const dm = new DeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await Users.findOne(note.userId);
dm.addDirectRecipe(reactee as IRemoteUser);
}
dm.addFollowersRecipe();
dm.execute();
}
//#endregion
};

View File

@ -0,0 +1,132 @@
import { publishMainStream } from '@/services/stream';
import { Note } from '@/models/entities/note';
import { User } from '@/models/entities/user';
import { NoteUnreads, AntennaNotes, Users, Followings, ChannelFollowings } from '@/models/index';
import { Not, IsNull, In } from 'typeorm';
import { Channel } from '@/models/entities/channel';
import { checkHitAntenna } from '@/misc/check-hit-antenna';
import { getAntennas } from '@/misc/antenna-cache';
import { readNotificationByQuery } from '@/server/api/common/read-notification';
import { Packed } from '@/misc/schema';
/**
* Mark notes as read
*/
export default async function(
userId: User['id'],
notes: (Note | Packed<'Note'>)[],
info?: {
following: Set<User['id']>;
followingChannels: Set<Channel['id']>;
}
) {
const following = info?.following ? info.following : new Set<string>((await Followings.find({
where: {
followerId: userId
},
select: ['followeeId']
})).map(x => x.followeeId));
const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await ChannelFollowings.find({
where: {
followerId: userId
},
select: ['followeeId']
})).map(x => x.followeeId));
const myAntennas = (await getAntennas()).filter(a => a.userId === userId);
const readMentions: (Note | Packed<'Note'>)[] = [];
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
for (const note of notes) {
if (note.mentions && note.mentions.includes(userId)) {
readMentions.push(note);
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
readSpecifiedNotes.push(note);
}
if (note.channelId && followingChannels.has(note.channelId)) {
readChannelNotes.push(note);
}
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) {
if (await checkHitAntenna(antenna, note, note.user as any, undefined, Array.from(following))) {
readAntennaNotes.push(note);
}
}
}
}
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
// Remove the record
await NoteUnreads.delete({
userId: userId,
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
});
// TODO: ↓まとめてクエリしたい
NoteUnreads.count({
userId: userId,
isMentioned: true
}).then(mentionsCount => {
if (mentionsCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadMentions');
}
});
NoteUnreads.count({
userId: userId,
isSpecified: true
}).then(specifiedCount => {
if (specifiedCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
}
});
NoteUnreads.count({
userId: userId,
noteChannelId: Not(IsNull())
}).then(channelNoteCount => {
if (channelNoteCount === 0) {
// 全て既読になったイベントを発行
publishMainStream(userId, 'readAllChannels');
}
});
readNotificationByQuery(userId, {
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
});
}
if (readAntennaNotes.length > 0) {
await AntennaNotes.update({
antennaId: In(myAntennas.map(a => a.id)),
noteId: In(readAntennaNotes.map(n => n.id))
}, {
read: true
});
// TODO: まとめてクエリしたい
for (const antenna of myAntennas) {
const count = await AntennaNotes.count({
antennaId: antenna.id,
read: false
});
if (count === 0) {
publishMainStream(userId, 'readAntenna', antenna);
}
}
Users.getHasUnreadAntenna(userId).then(unread => {
if (!unread) {
publishMainStream(userId, 'readAllAntennas');
}
});
}
}

View File

@ -0,0 +1,55 @@
import { Note } from '@/models/entities/note';
import { publishMainStream } from '@/services/stream';
import { User } from '@/models/entities/user';
import { Mutings, NoteThreadMutings, NoteUnreads } from '@/models/index';
import { genId } from '@/misc/gen-id';
export async function insertNoteUnread(userId: User['id'], note: Note, params: {
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
isSpecified: boolean;
isMentioned: boolean;
}) {
//#region ミュートしているなら無視
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
const mute = await Mutings.find({
muterId: userId
});
if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion
// スレッドミュート
const threadMute = await NoteThreadMutings.findOne({
userId: userId,
threadId: note.threadId || note.id,
});
if (threadMute) return;
const unread = {
id: genId(),
noteId: note.id,
userId: userId,
isSpecified: params.isSpecified,
isMentioned: params.isMentioned,
noteChannelId: note.channelId,
noteUserId: note.userId,
};
await NoteUnreads.insert(unread);
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(async () => {
const exist = await NoteUnreads.findOne(unread.id);
if (exist == null) return;
if (params.isMentioned) {
publishMainStream(userId, 'unreadMention', note.id);
}
if (params.isSpecified) {
publishMainStream(userId, 'unreadSpecifiedNote', note.id);
}
if (note.channelId) {
publishMainStream(userId, 'unreadChannel', note.id);
}
}, 2000);
}

View File

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

View File

@ -0,0 +1,20 @@
import { User } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { NoteWatchings } from '@/models/index';
import { genId } from '@/misc/gen-id';
import { NoteWatching } from '@/models/entities/note-watching';
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);
};

View File

@ -0,0 +1,53 @@
import * as push from 'web-push';
import config from '@/config/index';
import { SwSubscriptions } from '@/models/index';
import { fetchMeta } from '@/misc/fetch-meta';
import { Packed } from '@/misc/schema';
type notificationType = 'notification' | 'unreadMessagingMessage';
type notificationBody = Packed<'Notification'> | Packed<'MessagingMessage'>;
export default async function(userId: string, type: notificationType, body: notificationBody) {
const meta = await fetchMeta();
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
push.setVapidDetails(config.url,
meta.swPublicKey,
meta.swPrivateKey);
// Fetch
const subscriptions = await SwSubscriptions.find({
userId: userId
});
for (const subscription of subscriptions) {
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
auth: subscription.auth,
p256dh: subscription.publickey
}
};
push.sendNotification(pushSubscription, JSON.stringify({
type, body
}), {
proxy: config.proxy
}).catch((err: any) => {
//swLogger.info(err.statusCode);
//swLogger.info(err.headers);
//swLogger.info(err.body);
if (err.statusCode === 410) {
SwSubscriptions.delete({
userId: userId,
endpoint: subscription.endpoint,
auth: subscription.auth,
publickey: subscription.publickey
});
}
});
}
}

View File

@ -0,0 +1,34 @@
import { Instance } from '@/models/entities/instance';
import { Instances } from '@/models/index';
import { federationChart } from '@/services/chart/index';
import { genId } from '@/misc/gen-id';
import { toPuny } from '@/misc/convert-host';
import { Cache } from '@/misc/cache';
const cache = new Cache<Instance>(1000 * 60 * 60);
export async function registerOrFetchInstanceDoc(host: string): Promise<Instance> {
host = toPuny(host);
const cached = cache.get(host);
if (cached) return cached;
const index = await Instances.findOne({ host });
if (index == null) {
const i = await Instances.save({
id: genId(),
host,
caughtAt: new Date(),
lastCommunicatedAt: new Date(),
});
federationChart.update(true);
cache.set(host, i);
return i;
} else {
cache.set(host, index);
return index;
}
}

View File

@ -0,0 +1,94 @@
import { createSystemUser } from './create-system-user';
import { renderFollowRelay } from '@/remote/activitypub/renderer/follow-relay';
import { renderActivity, attachLdSignature } from '@/remote/activitypub/renderer/index';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { deliver } from '@/queue/index';
import { ILocalUser, User } from '@/models/entities/user';
import { Users, Relays } from '@/models/index';
import { genId } from '@/misc/gen-id';
const ACTOR_USERNAME = 'relay.actor' as const;
export async function getRelayActor(): Promise<ILocalUser> {
const user = await Users.findOne({
host: null,
username: ACTOR_USERNAME
});
if (user) return user as ILocalUser;
const created = await createSystemUser(ACTOR_USERNAME);
return created as ILocalUser;
}
export async function addRelay(inbox: string) {
const relay = await Relays.save({
id: genId(),
inbox,
status: 'requesting'
});
const relayActor = await getRelayActor();
const follow = await renderFollowRelay(relay, relayActor);
const activity = renderActivity(follow);
deliver(relayActor, activity, relay.inbox);
return relay;
}
export async function removeRelay(inbox: string) {
const relay = await Relays.findOne({
inbox
});
if (relay == null) {
throw 'relay not found';
}
const relayActor = await getRelayActor();
const follow = renderFollowRelay(relay, relayActor);
const undo = renderUndo(follow, relayActor);
const activity = renderActivity(undo);
deliver(relayActor, activity, relay.inbox);
await Relays.delete(relay.id);
}
export async function listRelay() {
const relays = await Relays.find();
return relays;
}
export async function relayAccepted(id: string) {
const result = await Relays.update(id, {
status: 'accepted'
});
return JSON.stringify(result);
}
export async function relayRejected(id: string) {
const result = await Relays.update(id, {
status: 'rejected'
});
return JSON.stringify(result);
}
export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) {
if (activity == null) return;
const relays = await Relays.find({
status: 'accepted'
});
if (relays.length === 0) return;
const copy = JSON.parse(JSON.stringify(activity));
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
const signed = await attachLdSignature(copy, user);
for (const relay of relays) {
deliver(user, signed, relay.inbox);
}
}

View File

@ -0,0 +1,31 @@
import { UserProfiles } from '@/models/index';
import { User } from '@/models/entities/user';
import { sendEmail } from './send-email';
import { I18n } from '@/misc/i18n';
import * as Acct from 'misskey-js/built/acct';
const locales = require('../../../../locales/index.js');
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
async function follow(userId: User['id'], follower: User) {
const userProfile = await UserProfiles.findOneOrFail({ userId: userId });
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return;
const locale = locales[userProfile.lang || 'ja-JP'];
const i18n = new I18n(locale);
// TODO: render user information html
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
}
async function receiveFollowRequest(userId: User['id'], follower: User) {
const userProfile = await UserProfiles.findOneOrFail({ userId: userId });
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return;
const locale = locales[userProfile.lang || 'ja-JP'];
const i18n = new I18n(locale);
// TODO: render user information html
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
}
export const sendEmailNotification = {
follow,
receiveFollowRequest,
};

View File

@ -0,0 +1,123 @@
import * as nodemailer from 'nodemailer';
import { fetchMeta } from '@/misc/fetch-meta';
import Logger from './logger';
import config from '@/config/index';
export const logger = new Logger('email');
export async function sendEmail(to: string, subject: string, html: string, text: string) {
const meta = await fetchMeta(true);
const iconUrl = `${config.url}/static-assets/mi-white.png`;
const emailSettingUrl = `${config.url}/settings/email`;
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
const transporter = nodemailer.createTransport({
host: meta.smtpHost,
port: meta.smtpPort,
secure: meta.smtpSecure,
ignoreTLS: !enableAuth,
proxy: config.proxySmtp,
auth: enableAuth ? {
user: meta.smtpUser,
pass: meta.smtpPass
} : undefined
} as any);
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
from: meta.email!,
to: to,
subject: subject,
text: text,
html: `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${ subject }</title>
<style>
html {
background: #eee;
}
body {
padding: 16px;
margin: 0;
font-family: sans-serif;
font-size: 14px;
}
a {
text-decoration: none;
color: #86b300;
}
a:hover {
text-decoration: underline;
}
main {
max-width: 500px;
margin: 0 auto;
background: #fff;
color: #555;
}
main > header {
padding: 32px;
background: #86b300;
}
main > header > img {
max-width: 128px;
max-height: 28px;
vertical-align: bottom;
}
main > article {
padding: 32px;
}
main > article > h1 {
margin: 0 0 1em 0;
}
main > footer {
padding: 32px;
border-top: solid 1px #eee;
}
nav {
box-sizing: border-box;
max-width: 500px;
margin: 16px auto 0 auto;
padding: 0 32px;
}
nav > a {
color: #888;
}
</style>
</head>
<body>
<main>
<header>
<img src="${ meta.logoImageUrl || meta.iconUrl || iconUrl }"/>
</header>
<article>
<h1>${ subject }</h1>
<div>${ html }</div>
</article>
<footer>
<a href="${ emailSettingUrl }">${ 'Email setting' }</a>
</footer>
</main>
<nav>
<a href="${ config.url }">${ config.host }</a>
</nav>
</body>
</html>
`
});
logger.info('Message sent: %s', info.messageId);
} catch (e) {
logger.error(e);
throw e;
}
}

View File

@ -0,0 +1,129 @@
import { redisClient } from '../db/redis';
import { User } from '@/models/entities/user';
import { Note } from '@/models/entities/note';
import { UserList } from '@/models/entities/user-list';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { UserGroup } from '@/models/entities/user-group';
import config from '@/config/index';
import { Antenna } from '@/models/entities/antenna';
import { Channel } from '@/models/entities/channel';
import {
StreamChannels,
AdminStreamTypes,
AntennaStreamTypes,
BroadcastTypes,
ChannelStreamTypes,
DriveStreamTypes,
GroupMessagingStreamTypes,
InternalStreamTypes,
MainStreamTypes,
MessagingIndexStreamTypes,
MessagingStreamTypes,
NoteStreamTypes,
ReversiGameStreamTypes,
ReversiStreamTypes,
UserListStreamTypes,
UserStreamTypes
} from '@/server/api/stream/types';
import { Packed } from '@/misc/schema';
class Publisher {
private publish = (channel: StreamChannels, type: string | null, value?: any): void => {
const message = type == null ? value : value == null ?
{ type: type, body: null } :
{ type: type, body: value };
redisClient.publish(config.host, JSON.stringify({
channel: channel,
message: message
}));
}
public publishInternalEvent = <K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void => {
this.publish('internal', type, typeof value === 'undefined' ? null : value);
}
public publishUserEvent = <K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void => {
this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishBroadcastStream = <K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void => {
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
}
public publishMainStream = <K extends keyof MainStreamTypes>(userId: User['id'], type: K, value?: MainStreamTypes[K]): void => {
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishDriveStream = <K extends keyof DriveStreamTypes>(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void => {
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishNoteStream = <K extends keyof NoteStreamTypes>(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void => {
this.publish(`noteStream:${noteId}`, type, {
id: noteId,
body: value
});
}
public publishChannelStream = <K extends keyof ChannelStreamTypes>(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void => {
this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value);
}
public publishUserListStream = <K extends keyof UserListStreamTypes>(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void => {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
}
public publishAntennaStream = <K extends keyof AntennaStreamTypes>(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void => {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
}
public publishMessagingStream = <K extends keyof MessagingStreamTypes>(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void => {
this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
}
public publishGroupMessagingStream = <K extends keyof GroupMessagingStreamTypes>(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void => {
this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value);
}
public publishMessagingIndexStream = <K extends keyof MessagingIndexStreamTypes>(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void => {
this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishReversiStream = <K extends keyof ReversiStreamTypes>(userId: User['id'], type: K, value?: ReversiStreamTypes[K]): void => {
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishReversiGameStream = <K extends keyof ReversiGameStreamTypes>(gameId: ReversiGame['id'], type: K, value?: ReversiGameStreamTypes[K]): void => {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
public publishNotesStream = (note: Packed<'Note'>): void => {
this.publish('notesStream', null, note);
}
public publishAdminStream = <K extends keyof AdminStreamTypes>(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void => {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
}
const publisher = new Publisher();
export default publisher;
export const publishInternalEvent = publisher.publishInternalEvent;
export const publishUserEvent = publisher.publishUserEvent;
export const publishBroadcastStream = publisher.publishBroadcastStream;
export const publishMainStream = publisher.publishMainStream;
export const publishDriveStream = publisher.publishDriveStream;
export const publishNoteStream = publisher.publishNoteStream;
export const publishNotesStream = publisher.publishNotesStream;
export const publishChannelStream = publisher.publishChannelStream;
export const publishUserListStream = publisher.publishUserListStream;
export const publishAntennaStream = publisher.publishAntennaStream;
export const publishMessagingStream = publisher.publishMessagingStream;
export const publishGroupMessagingStream = publisher.publishGroupMessagingStream;
export const publishMessagingIndexStream = publisher.publishMessagingIndexStream;
export const publishReversiStream = publisher.publishReversiStream;
export const publishReversiGameStream = publisher.publishReversiGameStream;
export const publishAdminStream = publisher.publishAdminStream;

View File

@ -0,0 +1,34 @@
import renderDelete from '@/remote/activitypub/renderer/delete';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { deliver } from '@/queue/index';
import config from '@/config/index';
import { User } from '@/models/entities/user';
import { Users, Followings } from '@/models/index';
import { Not, IsNull } from 'typeorm';
export async function doPostSuspend(user: { id: User['id']; host: User['host'] }) {
if (Users.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user));
const queue: string[] = [];
const followings = await Followings.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) }
],
select: ['followerSharedInbox', 'followeeSharedInbox']
});
const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
deliver(user, content, inbox);
}
}
}

View File

@ -0,0 +1,35 @@
import renderDelete from '@/remote/activitypub/renderer/delete';
import renderUndo from '@/remote/activitypub/renderer/undo';
import { renderActivity } from '@/remote/activitypub/renderer/index';
import { deliver } from '@/queue/index';
import config from '@/config/index';
import { User } from '@/models/entities/user';
import { Users, Followings } from '@/models/index';
import { Not, IsNull } from 'typeorm';
export async function doPostUnsuspend(user: User) {
if (Users.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
const content = renderActivity(renderUndo(renderDelete(`${config.url}/users/${user.id}`, user), user));
const queue: string[] = [];
const followings = await Followings.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) }
],
select: ['followerSharedInbox', 'followeeSharedInbox']
});
const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
deliver(user as any, content, inbox);
}
}
}

View File

@ -0,0 +1,128 @@
import { User } from '@/models/entities/user';
import { Hashtags, Users } from '@/models/index';
import { hashtagChart } from '@/services/chart/index';
import { genId } from '@/misc/gen-id';
import { Hashtag } from '@/models/entities/hashtag';
import { normalizeForSearch } from '@/misc/normalize-for-search';
export async function updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) {
for (const tag of tags) {
await updateHashtag(user, tag);
}
}
export async function updateUsertags(user: User, tags: string[]) {
for (const tag of tags) {
await updateHashtag(user, tag, true, true);
}
for (const tag of (user.tags || []).filter(x => !tags.includes(x))) {
await updateHashtag(user, tag, true, false);
}
}
export async function updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) {
tag = normalizeForSearch(tag);
const index = await Hashtags.findOne({ name: tag });
if (index == null && !inc) return;
if (index != null) {
const q = Hashtags.createQueryBuilder('tag').update()
.where('name = :name', { name: tag });
const set = {} as any;
if (isUserAttached) {
if (inc) {
// 自分が初めてこのタグを使ったなら
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => `"attachedUsersCount" + 1`;
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (Users.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" + 1`;
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (Users.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" + 1`;
}
} else {
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => `"attachedUsersCount" - 1`;
if (Users.isLocalUser(user)) {
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" - 1`;
} else {
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" - 1`;
}
}
} else {
// 自分が初めてこのタグを使ったなら
if (!index.mentionedUserIds.some(id => id === user.id)) {
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
set.mentionedUsersCount = () => `"mentionedUsersCount" + 1`;
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (Users.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
set.mentionedLocalUsersCount = () => `"mentionedLocalUsersCount" + 1`;
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (Users.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
set.mentionedRemoteUsersCount = () => `"mentionedRemoteUsersCount" + 1`;
}
}
if (Object.keys(set).length > 0) {
q.set(set);
q.execute();
}
} else {
if (isUserAttached) {
Hashtags.insert({
id: genId(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0,
} as Hashtag);
} else {
Hashtags.insert({
id: genId(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as Hashtag);
}
}
if (!isUserAttached) {
hashtagChart.update(tag, user);
}
}

View File

@ -0,0 +1,27 @@
import { publishUserListStream } from '@/services/stream';
import { User } from '@/models/entities/user';
import { UserList } from '@/models/entities/user-list';
import { UserListJoinings, Users } from '@/models/index';
import { UserListJoining } from '@/models/entities/user-list-joining';
import { genId } from '@/misc/gen-id';
import { fetchProxyAccount } from '@/misc/fetch-proxy-account';
import createFollowing from '../following/create';
export async function pushUserToUserList(target: User, list: UserList) {
await UserListJoinings.insert({
id: genId(),
createdAt: new Date(),
userId: target.id,
userListId: list.id
} as UserListJoining);
publishUserListStream(list.id, 'userAdded', await Users.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
if (Users.isRemoteUser(target)) {
const proxy = await fetchProxyAccount();
if (proxy) {
createFollowing(proxy, target);
}
}
}

View File

@ -0,0 +1,34 @@
import validateEmail from 'deep-email-validator';
import { UserProfiles } from '@/models';
export async function validateEmailForAccount(emailAddress: string): Promise<{
available: boolean;
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp';
}> {
const exist = await UserProfiles.count({
emailVerified: true,
email: emailAddress,
});
const validated = await validateEmail({
email: emailAddress,
validateRegex: true,
validateMx: true,
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
});
const available = exist === 0 && validated.valid;
return {
available,
reason: available ? null :
exist !== 0 ? 'used' :
validated.reason === 'regex' ? 'format' :
validated.reason === 'disposable' ? 'disposable' :
validated.reason === 'mx' ? 'mx' :
validated.reason === 'smtp' ? 'smtp' :
null,
};
}