Compare commits

...

15 Commits

Author SHA1 Message Date
Derek 905e5d7226 Now departing integration hell - come back soon ;) 2022-07-07 03:37:33 -04:00
Derek a8ea81015b Move channel to + route 2022-07-07 01:41:27 -04:00
Derek dc31a8f16c Add channel webfinger support
Uses non-standard `grp` schema
2022-07-07 00:23:28 -04:00
Derek a08f1a990a Pass channel slug as group name (similar to user) 2022-07-06 20:10:16 -04:00
Derek 26cb49aa4a Fix nullable constraint for remote channels 2022-07-06 19:21:11 -04:00
Derek 8454a73d19 Fix Channel entity constructor 2022-06-15 00:00:25 -04:00
Derek 8d61f8275e Fix reference in group resolver 2022-06-14 23:49:35 -04:00
Derek ab94aa2eb8 Pass resolver down to channel resolve during note resolve 2022-06-14 23:41:15 -04:00
Derek 009c995c78 Fix types and an import 2022-06-14 23:26:36 -04:00
Derek aa4b8f22ab Fix using channel entity rather than its repo 2022-06-14 22:34:12 -04:00
Derek e8cfa11781 Fix ANOTHER import 2022-06-14 22:12:08 -04:00
Derek 5692e1caad Fix update script to work with new branches 2022-06-14 22:11:39 -04:00
Derek 213bc16447 Fix import issues
i am smort
2022-06-14 21:43:14 -04:00
Derek 2fc1098d39 Initial incoming post -> channel~
Also adds channel "username" like access via url/+channel-slug (a new, 
required, and currently unsettable, channel option)

Completely untested, and known unfinshed in a few places, good luck 
future me!
2022-05-31 03:33:36 -04:00
Derek f6f06dc98d Fix channel creation post actor conversion 2022-05-31 01:41:50 -04:00
26 changed files with 718 additions and 98 deletions

View File

@ -7,12 +7,12 @@ export class channelActors1653237040103 {
await queryRunner.query(`CREATE TABLE "channel_keypair" ("channelId" character varying(32) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096) NOT NULL, CONSTRAINT "REL_7be19c4d7a902ff9fd664ca9ae" UNIQUE ("channelId"), CONSTRAINT "PK_f771c2d8b4078218f64014c7cb" PRIMARY KEY ("channelId"))`);
await queryRunner.query(`ALTER TABLE "channel" ADD COLUMN "emojis" character varying(128) array NOT NULL DEFAULT '{}'::varchar[]`);
await queryRunner.query(`ALTER TABLE "channel" ADD COLUMN "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[]`);
await queryRunner.query(`ALTER TABLE "channel" ALTER COLUMN "createdAt" DROP NOT NULL`);
const channels = await queryRunner.query(`SELECT id FROM "channel"`);
for (let i = 0; i < channels.length; i++) {
let channelId = channels[i].id;
console.log(channelId);
const keypair = await genRsaKeyPair(4096);
await queryRunner.query(`INSERT INTO "channel_keypair" ("publicKey", "privateKey", "channelId") VALUES ('${keypair.publicKey}', '${keypair.privateKey}', '${channelId}')`)
@ -23,5 +23,6 @@ export class channelActors1653237040103 {
await queryRunner.query(`DROP TABLE "channel_keypair"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "emojis"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "tags"`);
await queryRunner.query(`ALTER TABLE "channel" ALTER COLUMN "createdAt" SET NOT NULL`);
}
}

View File

@ -0,0 +1,45 @@
import { toPuny } from '../built/misc/convert-host.js';
export class channelBoxes1653969505036 {
name = 'channelBoxes1653969505036'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel" ADD "slug" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "channel"."slug" IS 'URL safe channel name.'`);
const channels = await queryRunner.query(`SELECT id, name FROM "channel"`);
for (let i = 0; i < channels.length; i++) {
const channelId = channels[i].id;
const channelName = channels[i].name;
const slug = toPuny(channelName.replace(' ', '-'));
await queryRunner.query(`UPDATE "channel" SET "slug"='${slug}' WHERE "id" = '${channelId}'`);
}
await queryRunner.query(`ALTER TABLE "channel" ALTER COLUMN "slug" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "channel" ADD "host" character varying(128)`);
await queryRunner.query(`COMMENT ON COLUMN "channel"."host" IS 'The host of the Channel. It will be null if the origin of the channel is local.'`);
await queryRunner.query(`ALTER TABLE "channel" ADD "inbox" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "channel"."inbox" IS 'The inbox URL of the Channel. It will be null if the origin of the channel is local.'`);
await queryRunner.query(`ALTER TABLE "channel" ADD "sharedInbox" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "channel"."sharedInbox" IS 'The sharedInbox URL of the Channel. It will be null if the origin of the channel is local.'`);
await queryRunner.query(`ALTER TABLE "channel" ADD "uri" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "channel"."uri" IS 'The URI of the Channel. It will be null if the origin of the channel is local.'`);
await queryRunner.query(`ALTER TABLE "channel" ADD "followersUri" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "channel"."followersUri" IS 'The URI of the channel Follower Collection. It will be null if the origin of the channel is local.'`);
await queryRunner.query(`CREATE INDEX "IDX_8479b8802f0cd5d60c61a5ad20" ON "channel" ("slug") `);
await queryRunner.query(`CREATE INDEX "IDX_ddc763034a97bf9035c8a6bd12" ON "channel" ("host") `);
await queryRunner.query(`CREATE INDEX "IDX_bd0e56edb9ccb54f92b3693d14" ON "channel" ("uri") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4bfbb3a1ec83af58d47b298942" ON "channel" ("slug", "host") `);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "followersUri"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "uri"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "sharedInbox"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "inbox"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "host"`);
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "slug"`);
}
}

View File

@ -0,0 +1,14 @@
export type Grp = {
name: string;
host: string | null;
};
export function parse(grp: string): Grp {
if (grp.startsWith('+')) grp = grp.substr(1);
const split = grp.split('@', 2);
return { name: split[0], host: split[1] || null };
}
export function toString(grp: Grp): string {
return grp.host == null ? grp.name : `${grp.name}@${grp.host}`;
}

View File

@ -9,7 +9,7 @@ const userCache = new Cache<UserKeypair>(Infinity);
const channelCache = new Cache<ChannelKeypair>(Infinity);
export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await cache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
return await userCache.fetch(userId, () => UserKeypairs.findOneByOrFail({ userId }));
}
export async function getChannelKeypair(channelId: Channel['id']): Promise<ChannelKeypair> {

View File

@ -4,6 +4,7 @@ import { id } from '../id.js';
import { DriveFile } from './drive-file.js';
@Entity()
@Index(['slug', 'host'], { unique: true })
export class Channel {
@PrimaryColumn(id())
public id: string;
@ -11,6 +12,7 @@ export class Channel {
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the Channel.',
nullable: true,
})
public createdAt: Date;
@ -40,6 +42,13 @@ export class Channel {
})
public name: string;
@Index()
@Column('varchar', {
length: 512,
comment: 'URL safe channel name.',
})
public slug: string;
@Column('varchar', {
length: 2048, nullable: true,
comment: 'The description of the Channel.',
@ -83,4 +92,45 @@ export class Channel {
comment: 'The count of users.',
})
public usersCount: number;
@Index()
@Column('varchar', {
length: 128, nullable: true,
comment: 'The host of the Channel. It will be null if the origin of the channel is local.',
})
public host: string | null;
@Column('varchar', {
length: 512, nullable: true,
comment: 'The inbox URL of the Channel. It will be null if the origin of the channel is local.',
})
public inbox: string | null;
@Column('varchar', {
length: 512, nullable: true,
comment: 'The sharedInbox URL of the Channel. It will be null if the origin of the channel is local.',
})
public sharedInbox: string | null;
@Index()
@Column('varchar', {
length: 512, nullable: true,
comment: 'The URI of the Channel. It will be null if the origin of the channel is local.',
})
public uri: string | null;
@Column('varchar', {
length: 512, nullable: true,
comment: 'The URI of the channel Follower Collection. It will be null if the origin of the channel is local.',
})
public followersUri: string | null;
constructor(data: Partial<Channel>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}

View File

@ -23,7 +23,7 @@ export const ChannelRepository = db.getRepository(Channel).extend({
return {
id: channel.id,
createdAt: channel.createdAt.toISOString(),
createdAt: channel.createdAt ? channel.createdAt.toISOString() : null,
lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null,
name: channel.name,
description: channel.description,

View File

@ -16,7 +16,7 @@ const userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3);
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
Detailed extends true ?
Detailed extends true ?
ExpectsMe extends true ? Packed<'MeDetailed'> :
ExpectsMe extends false ? Packed<'UserDetailedNotMe'> :
Packed<'UserDetailed'> :

View File

@ -0,0 +1,250 @@
import { URL } from 'node:url';
import promiseLimit from 'promise-limit';
import $, { Context } from 'cafy';
import config from '@/config/index.js';
import Resolver from '../resolver.js';
import { resolveImage } from './image.js';
import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type.js';
import { fromHtml } from '../../../mfm/from-html.js';
import { htmlToMfm } from '../misc/html-to-mfm.js';
import { resolveNote, extractEmojis } from './note.js';
import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js';
import { extractApHashtags } from './tag.js';
import { apLogger } from '../logger.js';
import { updateUsertags } from '@/services/update-hashtag.js';
import { Channels, Instances, DriveFiles, Followings } from '@/models/index.js';
import { Channel } from '@/models/entities/channel.js';
import { Emoji } from '@/models/entities/emoji.js';
import { UserNotePining } from '@/models/entities/user-note-pining.js';
import { genId } from '@/misc/gen-id.js';
import { instanceChart, usersChart } from '@/services/chart/index.js';
import { UserPublickey } from '@/models/entities/user-publickey.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { toPuny } from '@/misc/convert-host.js';
import { UserProfile } from '@/models/entities/user-profile.js';
import { toArray } from '@/prelude/array.js';
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { truncate } from '@/misc/truncate.js';
import { StatusError } from '@/misc/fetch.js';
import { uriPersonCache } from '@/services/user-cache.js';
import { publishInternalEvent } from '@/services/stream.js';
import { db } from '@/db/postgre.js';
const logger = apLogger;
const nameLength = 128;
const summaryLength = 2048;
/**
* Validate and convert to actor object
* @param x Fetched object
* @param uri Fetch target URI
*/
function validateActor(x: IObject, uri: string): IActor {
return x;
}
export async function fetchGroup(uri: string, resolver?: Resolver): Promise<Channel | null> {
if (typeof uri !== 'string') throw new Error('uri is not string');
const exist = await Channels.findOneBy({ uri });
if (exist) {
return exist;
}
return null;
}
export async function createGroup(uri: string, resolver?: Resolver): Promise<Channel> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (uri.startsWith(config.url)) {
throw new StatusError('cannot resolve local channel', 400, 'cannot resolve local channel');
}
if (resolver == null) resolver = new Resolver();
const object = await resolver.resolve(uri) as any;
const group = validateActor(object, uri);
logger.info(`Creating the Group: ${group.id}`);
const host = toPuny(new URL(group.id).hostname);
const tags = extractApHashtags(group.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
// Create channel
let channel: Channel;
try {
// Start transaction
await db.transaction(async transactionalEntityManager => {
channel = await transactionalEntityManager.save(new Channel({
id: genId(),
name: truncate(group.name, nameLength),
host,
inbox: group.inbox,
sharedInbox: group.sharedInbox || (group.endpoints ? group.endpoints.sharedInbox : undefined),
followersUri: group.followers ? getApId(group.followers) : undefined,
uri: group.id,
slug: group.preferredGroupname,
tags,
}));
// if (person.publicKey) {
// await transactionalEntityManager.save(new UserPublickey({
// userId: user.id,
// keyId: person.publicKey.id,
// keyPem: person.publicKey.publicKeyPem,
// }));
// }
});
} catch (e) {
// duplicate key error
if (isDuplicateKeyValueError(e)) {
throw new Error('already registered');
} else {
logger.error(e instanceof Error ? e : new Error(e as string));
throw e;
}
}
// Register host
registerOrFetchInstanceDoc(host).then(i => {
fetchInstanceMetadata(i);
});
//#region アバターとヘッダー画像をフェッチ
let banner;
if (banner) {
banner = await resolveImage(channel!, group.image).catch(() => null)
}
const bannerId = banner ? banner.id : null;
await Channels.update(channel!.id, {
bannerId,
});
channel!.bannerId = bannerId;
//#endregion
//#region カスタム絵文字取得
const emojis = await extractEmojis(group.tag || [], host).catch(e => {
logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
});
const emojiNames = emojis.map(emoji => emoji.name);
await Channels.update(channel!.id, {
emojis: emojiNames,
});
//#endregion
return channel!;
}
/**
* Personの情報を更新します
* Misskeyに対象のPersonが登録されていなければ無視します
* @param uri URI of Person
* @param resolver Resolver
* @param hint Hint of Person object (Personの場合Remote resolveをせずに更新に利用します)
*/
export async function updateGroup(uri: string, resolver?: Resolver | null, hint?: Record<string, unknown>): Promise<void> {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(config.url + '/')) {
return;
}
//#region このサーバーに既に登録されているか
const exist = await Channels.findOneBy({ uri });
if (exist == null) {
return;
}
//#endregion
if (resolver == null) resolver = new Resolver();
const object = hint || await resolver.resolve(uri) as any;
const group = validateActor(object, uri);
logger.info(`Updating the Group: ${group.id}`);
// アバターとヘッダー画像をフェッチ
let banner;
if (banner) {
banner = await resolveImage(channel!, group.image).catch(() => null)
}
// カスタム絵文字取得
const emojis = await extractEmojis(group.tag || [], exist.host).catch(e => {
logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
});
const emojiNames = emojis.map(emoji => emoji.name);
const tags = extractApHashtags(group.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const updates = {
inbox: group.inbox,
sharedInbox: group.sharedInbox || (group.endpoints ? group.endpoints.sharedInbox : undefined),
followersUri: group.followers ? getApId(group.followers) : undefined,
emojis: emojiNames,
name: truncate(group.name, nameLength),
tags,
} as Partial<Channel>;
if (banner) {
updates.bannerId = banner.id;
}
// Update user
await Channels.update(exist.id, updates);
// if (person.publicKey) {
// await UserPublickeys.update({ userId: exist.id }, {
// keyId: person.publicKey.id,
// keyPem: person.publicKey.publicKeyPem,
// });
// }
// TODO: investigate if this should happen for channels aswell
// publishInternalEvent('remoteUserUpdated', { id: exist.id });
await Followings.update({
followerId: exist.id,
}, {
followerSharedInbox: group.sharedInbox || (group.endpoints ? group.endpoints.sharedInbox : undefined),
});
}
/**
* Personを解決します
*
* Misskeyに対象のPersonが登録されていればそれを返し
* Misskeyに登録しそれを返します
*/
export async function resolveGroup(uri: string, resolver?: Resolver): Promise<Channel> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
const exist = await fetchGroup(uri);
if (exist) {
return exist;
}
//#endregion
// リモートサーバーからフェッチしてきて登録
if (resolver == null) resolver = new Resolver();
return await createGroup(uri, resolver);
}

View File

@ -5,6 +5,7 @@ import Resolver from '../resolver.js';
import post from '@/services/note/create.js';
import { resolvePerson, updatePerson } from './person.js';
import { resolveImage } from './image.js';
import { resolveGroup, updateGroup } from './group.js';
import { CacheableRemoteUser, IRemoteUser } from '@/models/entities/user.js';
import { htmlToMfm } from '../misc/html-to-mfm.js';
import { extractApHashtags } from './tag.js';
@ -17,7 +18,7 @@ import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
import { extractDbHost, toPuny } from '@/misc/convert-host.js';
import { Emojis, Polls, MessagingMessages, Users } from '@/models/index.js';
import { Note } from '@/models/entities/note.js';
import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type.js';
import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, isGroup, getApType } from '../type.js';
import { Emoji } from '@/models/entities/emoji.js';
import { genId } from '@/misc/gen-id.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
@ -226,6 +227,11 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s
return [] as Emoji[];
});
const channel = await extractChannel(note.tag || [], actor.host, resolver).catch(e => {
logger.info(`extractChannel: ${e}`);
return null;
});
const apEmojis = emojis.map(emoji => emoji.name);
const poll = await extractPollFromQuestion(note, resolver).catch(() => undefined);
@ -252,6 +258,7 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s
apHashtags,
apEmojis,
poll,
channel,
uri: note.id,
url: getOneApHrefNullable(note.url),
}, silent);
@ -350,3 +357,14 @@ export async function extractEmojis(tags: IObject | IObject[], host: string): Pr
} as Partial<Emoji>).then(x => Emojis.findOneByOrFail(x.identifiers[0]));
}));
}
export async function extractChannel(tags: IObject | IObject[], host:string, resolver?: Resolver): Promise<Channel | null> {
const apGroup = toArray(tags).find(isGroup);
if (!apGroup) {
return null;
}
return await resolveGroup(getOneApId(apGroup), resolver) as Channel;
}

View File

@ -11,7 +11,7 @@ import renderHashtag from './hashtag.js';
import { DriveFiles, UserProfiles } from '@/models/index.js';
import { getChannelKeypair } from '@/misc/keypair-store.js';
export async function renderGroup(channel: Channel) {
export default async function renderGroup(channel: Channel) {
const id = `${config.url}/channels/${channel.id}`;
const banner = await channel.bannerId ? DriveFiles.findOneBy({ id: channel.bannerId }) : Promise.resolve(undefined);
@ -38,8 +38,9 @@ export async function renderGroup(channel: Channel) {
following: `${id}/following`, // this is required by spec. cant be empty. sadge
sharedInbox: `${config.url}/inbox`,
endpoints: { sharedInbox: `${config.url}/inbox` },
url: id,
url: `${config.url}/+${channel.slug}`,
name: channel.name,
preferredGroupname: channel.slug,
summary: channel.description ? toHtml(mfm.parse(channel.description)) : null,
icon: null,
image: banner ? renderImage(banner) : null,

View File

@ -2,14 +2,16 @@ import renderDocument from './document.js';
import renderHashtag from './hashtag.js';
import renderMention from './mention.js';
import renderEmoji from './emoji.js';
import renderGroup from './group.js';
import config from '@/config/index.js';
import toHtml from '../misc/get-note-html.js';
import { In, IsNull } from 'typeorm';
import { Note, IMentionedRemoteUsers } from '@/models/entities/note.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { DriveFiles, Notes, Users, Emojis, Polls } from '@/models/index.js';
import { In, IsNull } from 'typeorm';
import { Emoji } from '@/models/entities/emoji.js';
import { Poll } from '@/models/entities/poll.js';
import { Channel } from '@/models/entities/channel.js';
import { DriveFiles, Notes, Users, Emojis, Polls, Channels } from '@/models/index.js';
export default async function renderNote(note: Note, dive = true, isTalk = false): Promise<Record<string, unknown>> {
const getPromisedFiles = async (ids: string[]) => {
@ -53,6 +55,12 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
}
}
let channel: Channel | null;
if (note.channelId) {
channel = await Channels.findOneBy({ id: note.channelId });
}
const attributedTo = `${config.url}/users/${note.userId}`;
const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
@ -60,9 +68,10 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
let to: string[] = [];
let cc: string[] = [];
if (note.channelId) {
const channel = `${config.url}/channels/${note.channelId}`;
to = [channel];
if (channel) {
const channelUrl = `${config.url}/channels/${channel.id}`;
// to = [`${channelUrl}/followers`];
to = [channelUrl];
cc = [`${attributedTo}/followers`, 'https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
} else {
if (note.visibility === 'public') {
@ -112,11 +121,15 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
const emojis = await getEmojis(note.emojis);
const apemojis = emojis.map(emoji => renderEmoji(emoji));
const tag = [
let tag = [
...hashtagTags,
...mentionTags,
...apemojis,
];
if (channel) {
const apGroup = await renderGroup(channel);
tag.push(apGroup);
}
const asPoll = poll ? {
type: 'Question',

View File

@ -216,6 +216,14 @@ export interface IApEmoji extends IObject {
export const isEmoji = (object: IObject): object is IApEmoji =>
getApType(object) === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null;
export interface IApGroup extends IObject {
type: 'Group';
id: String;
}
export const isGroup = (object: IObject): object is IApGroup =>
getApType(object) === 'Group' && typeof object.id === 'string';
export interface ICreate extends IActivity {
type: 'Create';
}

View File

@ -0,0 +1,68 @@
import { URL } from 'node:url';
import webFinger from './webfinger.js';
import config from '@/config/index.js';
import { createGroup, updateGroup } from './activitypub/models/group.js';
import { remoteLogger } from './logger.js';
import chalk from 'chalk';
import { Channel } from '@/models/entities/channel.js';
import { Channels } from '@/models/index.js';
import { toPuny } from '@/misc/convert-host.js';
import { IsNull } from 'typeorm';
const logger = remoteLogger.createSubLogger('resolve');
export async function resolveChannel(channelName: string, host: string | null): Promise<Channel> {
const nameLower = channelName.toLowerCase();
if (host == null) {
logger.info(`return local channel: ${nameLower}`);
return await Channels.findOneBy({ slug: nameLower, host: IsNull() }).then(u => {
if (u == null) {
throw new Error('channel not found');
} else {
return u;
}
});
}
host = toPuny(host);
if (config.host === host) {
logger.info(`return local channel: ${nameLower}`);
return await Channels.findOneBy({ slug: nameLower, host: IsNull() }).then(u => {
if (u == null) {
throw new Error('channel not found');
} else {
return u;
}
});
}
const channel = await Channels.findOneBy({ slug: nameLower, host }) || null;
const identifier = `${nameLower}@${host}`;
if (channel == null) {
const self = await resolveSelf(identifier);
logger.succ(`return new remote channel: ${chalk.magenta(nameLower)}`);
return await createGroup(self.href);
}
logger.info(`return existing remote channel: ${nameLower} (${channel.host})`);
return channel;
}
async function resolveSelf(nameLower: string) {
logger.info(`WebFinger for ${chalk.yellow(nameLower)}`);
const finger = await webFinger(nameLower, 'grp').catch(e => {
logger.error(`Failed to WebFinger for ${chalk.yellow(nameLower)}: ${ e.statusCode || e.message }`);
throw new Error(`Failed to WebFinger for ${nameLower}: ${ e.statusCode || e.message }`);
});
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
if (!self) {
logger.error(`Failed to WebFinger for ${chalk.yellow(nameLower)}: self link not found`);
throw new Error('self link not found');
}
return self;
}

View File

@ -9,7 +9,7 @@ import { Users } from '@/models/index.js';
import { toPuny } from '@/misc/convert-host.js';
import { IsNull } from 'typeorm';
const logger = remoteLogger.createSubLogger('resolve-user');
const logger = remoteLogger.createSubLogger('resolve');
export async function resolveUser(username: string, host: string | null): Promise<User> {
const usernameLower = username.toLowerCase();
@ -98,7 +98,7 @@ export async function resolveUser(username: string, host: string | null): Promis
async function resolveSelf(acctLower: string) {
logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
const finger = await webFinger(acctLower).catch(e => {
const finger = await webFinger(acctLower, 'acct').catch(e => {
logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`);
throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`);
});

View File

@ -12,13 +12,13 @@ type IWebFinger = {
subject: string;
};
export default async function(query: string): Promise<IWebFinger> {
const url = genUrl(query);
export default async function(query: string, schema: string): Promise<IWebFinger> {
const url = genUrl(query, schema);
return await getJson(url, 'application/jrd+json, application/json') as IWebFinger;
}
function genUrl(query: string) {
function genUrl(query: string, schema: string) {
if (query.match(/^https?:\/\//)) {
const u = new URL(query);
return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
@ -27,7 +27,7 @@ function genUrl(query: string) {
const m = query.match(/^([^@]+)@(.*)/);
if (m) {
const hostname = m[2];
return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` });
return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `${schema}:${query}` });
}
throw new Error(`Invalid query (${query})`);

View File

@ -5,7 +5,7 @@ import httpSignature from 'http-signature';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderNote from '@/remote/activitypub/renderer/note.js';
import renderKey from '@/remote/activitypub/renderer/key.js';
import { renderGroup } from '@/remote/activitypub/renderer/group.js';
import renderGroup from '@/remote/activitypub/renderer/group.js';
import { renderPerson } from '@/remote/activitypub/renderer/person.js';
import renderEmoji from '@/remote/activitypub/renderer/emoji.js';
import Outbox, { packActivity } from './activitypub/outbox.js';
@ -226,21 +226,38 @@ router.get('/likes/:like', async ctx => {
});
// channel
router.get('/channels/:channelId', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const channel = await Channels.findOneBy({
id: ctx.params.channelId,
});
async function channelInfo(ctx: Router.RouterContext, channel: Channel | null) {
if (channel == null) {
ctx.status = 404;
return;
}
ctx.body = renderActivity(await renderGroup(channel));
ctx.body = renderActivity(await renderGroup(channel as ILocalChannel));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}
router.get('/channels/:channelId', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const channel = await Channels.findOneBy({
id: ctx.params.channelId,
host: IsNull(),
});
await channelInfo(ctx, channel);
});
router.get('/\\+:channelSlug', async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const channel = await Channels.findOneBy({
slug: ctx.params.channelSlug.toLowerCase(),
host: IsNull(),
});
await channelInfo(ctx, channel);
});
//#endregion
export default router;

View File

@ -54,17 +54,18 @@ export default define(meta, paramDef, async (ps, user) => {
}
}
const keyPair = genRsaKeyPair(4096);
const keyPair = await genRsaKeyPair(4096);
let channel;
await db.transaction(async transactionalEntityManager => {
const channel = await transactionalEntityManager.insert(Channel, {
channel = await transactionalEntityManager.insert(Channel, {
id: genId(),
createdAt: new Date(),
userId: user.id,
name: ps.name,
description: ps.description || null,
bannerId: banner ? banner.id : null,
}).then(x => Channels.findOneByOrFail(x.identifiers[0]));
}).then(x => transactionalEntityManager.findOneByOrFail(Channel, x.identifiers[0]));
await transactionalEntityManager.insert(ChannelKeypair, {
publicKey: keyPair.publicKey,

View File

@ -1,6 +1,10 @@
import define from '../../define.js';
import { apiLogger } from '../../logger.js';
import { ApiError } from '../../error.js';
import { Channels } from '@/models/index.js';
import { Channel } from '@/models/entities/channel.js';
import { FindOptionsWhere, In, IsNull } from 'typeorm';
import { resolveChannel } from '@/remote/resolve-channel.js';
export const meta = {
tags: ['channels'],
@ -14,6 +18,13 @@ export const meta = {
},
errors: {
failedToResolveRemoteChannel: {
message: 'Failed to resolve remote channel.',
code: 'FAILED_TO_RESOLVE_REMOTE_CHANNEL',
id: '701c2582-fd94-11ec-a43c-3c7c3f10cc28',
kind: 'server',
},
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
@ -24,17 +35,43 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
channelId: { type: 'string', format: 'misskey:id' },
},
required: ['channelId'],
anyOf: [
{
properties: {
channelId: { type: 'string', format: 'misskey:id' },
},
required: ['channelId'],
},
{
properties: {
channelName: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['channelName'],
},
],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const channel = await Channels.findOneBy({
id: ps.channelId,
});
let channel: Channel | null;
if (typeof ps.host === 'string' && typeof ps.channelName === 'string') {
channel = await resolveChannel(ps.channelName, ps.host).catch(e => {
apiLogger.warn(`failed to resolve remote channel: ${e}`);
throw new ApiError(meta.errors.failedToResolveRemoteChannel);
});
} else {
const q: FindOptionsWhere<Channel> = ps.channelId != null
? { id: ps.channelId }
: { slug: ps.channelName!.toLowerCase(), host: IsNull() };
channel = await Channels.findOneBy(q);
}
apiLogger.warn(`helb: ${channel.id}`)
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);

View File

@ -19,6 +19,7 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
import config from '@/config/index.js';
import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '@/models/index.js';
import * as Acct from '@/misc/acct.js';
import * as Grp from '@/misc/grp.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import { queues } from '@/queue/queues.js';
import { genOpenapiSpec } from '../api/openapi/gen-spec.js';
@ -389,9 +390,26 @@ router.get('/gallery/:post', async (ctx, next) => {
});
// Channel
router.get('/channels/:channel', async (ctx, next) => {
router.get('/channels/:channel', async ctx => {
const channel = await Channels.findOneBy({
id: ctx.params.channel,
host: IsNull(),
});
if (channel == null) {
ctx.status = 404;
return;
}
ctx.redirect(`/+${channel.slug}${channel.host == null ? '' : '@' + channel.host}`);
});
router.get('/\\+:channel', async (ctx, next) => {
const { name, host } = Grp.parse(ctx.params.channel);
const channel = await Channels.findOneBy({
slug: name,
host: host ?? IsNull(),
});
if (channel) {

View File

@ -2,7 +2,7 @@ extends ./base
block vars
- const title = channel.name;
- const url = `${config.url}/channels/${channel.id}`;
- const url = `${config.url}/+${(channel.host ? `${channel.slug}@${channel.host}` : channel.slug)}`;
block title
= `${title} | ${instanceName}`

View File

@ -2,10 +2,12 @@ import Router from '@koa/router';
import config from '@/config/index.js';
import * as Acct from '@/misc/acct.js';
import * as Grp from '@/misc/grp.js';
import { links } from './nodeinfo.js';
import { escapeAttribute, escapeValue } from '@/prelude/xml.js';
import { Users } from '@/models/index.js';
import { Users, Channels } from '@/models/index.js';
import { User } from '@/models/entities/user.js';
import { Channel } from '@/models/entities/channel.js';
import { FindOptionsWhere, IsNull } from 'typeorm';
// Init router
@ -66,75 +68,132 @@ router.get('/.well-known/change-password', async ctx => {
});
*/
router.get(webFingerPath, async ctx => {
const fromId = (id: User['id']): FindOptionsWhere<User> => ({
const lookupUserById = (id: string) => {
const query = {
id,
host: IsNull(),
isSuspended: false,
});
} as FindOptionsWhere<User>;
return lookupUser(query);
};
const generateQuery = (resource: string): FindOptionsWhere<User> | number =>
resource.startsWith(`${config.url.toLowerCase()}/users/`) ?
fromId(resource.split('/').pop()!) :
fromAcct(Acct.parse(
resource.startsWith(`${config.url.toLowerCase()}/@`) ? resource.split('/').pop()! :
resource.startsWith('acct:') ? resource.slice('acct:'.length) :
resource));
const lookupUserByName = (resource: string) => {
const acct = Acct.parse(resource);
if (acct.host && acct.host !== config.host.toLowerCase()) {
return 422;
};
const query = {
usernameLower: acct.username,
host: IsNull(),
isSuspended: false,
} as FindOptionsWhere<User>;
return lookupUser(query);
};
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<User> | number =>
!acct.host || acct.host === config.host.toLowerCase() ? {
usernameLower: acct.username,
host: IsNull(),
isSuspended: false,
} : 422;
const lookupUser = async (query: FindOptionsWhere<User>) => {
const user = await Users.findOneBy(query);
if (user == null) {
return 404;
}
const subject = `acct:${user.username}@${config.host}`;
const self = {
rel: 'self',
type: 'application/activity+json',
href: `${config.url}/users/${user.id}`,
};
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/html',
href: `${config.url}/@${user.username}`,
};
const subscribe = {
rel: 'http://ostatus.org/schema/1.0/subscribe',
template: `${config.url}/authorize-follow?acct={uri}`,
};
return { subject, links: [self, profilePage, subscribe] };
};
const lookupChannelById = (id: string) => {
const query = {
id,
host: IsNull(),
} as FindOptionsWhere<Channel>;
return lookupChannel(query);
};
const lookupChannelByName = (resource: string) => {
const grp = Grp.parse(resource);
if (grp.host && grp.host !== config.host.toLowerCase()) {
return 422;
};
const query = {
slug: grp.name,
host: IsNull(),
} as FindOptionsWhere<Channel>;
return lookupChannel(query);
};
const lookupChannel = async (query: FindOptionsWhere<Channel>) => {
const channel = await Channels.findOneBy(query);
if (channel == null) {
return 404;
}
const subject = `grp:${channel.slug}@${config.host}`;
const self = {
rel: 'self',
type: 'application/activity+json',
href: `${config.url}/channel/${channel.id}`,
};
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/html',
href: `${config.url}/+${channel.slug}`,
};
return { subject, links: [self, profilePage] };
};
router.get(webFingerPath, async ctx => {
if (typeof ctx.query.resource !== 'string') {
ctx.status = 400;
return;
}
const query = generateQuery(ctx.query.resource.toLowerCase());
const resource = ctx.query.resource.toLowerCase();
if (typeof query === 'number') {
ctx.status = query;
return;
let elements: object | number;
if (resource.startsWith(`${config.url.toLowerCase()}/users/`) || resource.startsWith(`${config.url.toLowerCase()}/@`)) {
elements = await lookupUserById(resource.split('/').pop()!);
} else if (resource.startsWith('acct:')) {
elements = await lookupUserByName(resource.slice('acct:'.length));
} else if (resource.startsWith(`${config.url.toLowerCase()}/channel/`) || resource.startsWith(`${config.url.toLowerCase()}/+`)) {
elements = await lookupChannelById(resource.split('/').pop()!);
} else if (resource.startsWith('grp:')) {
elements = await lookupChannelByName(resource.slice('grp:'.length));
} else {
elements = 400;
}
const user = await Users.findOneBy(query);
if (user == null) {
ctx.status = 404;
if (typeof elements === 'number') {
ctx.status = elements;
return;
}
const subject = `acct:${user.username}@${config.host}`;
const self = {
rel: 'self',
type: 'application/activity+json',
href: `${config.url}/users/${user.id}`,
};
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/html',
href: `${config.url}/@${user.username}`,
};
const subscribe = {
rel: 'http://ostatus.org/schema/1.0/subscribe',
template: `${config.url}/authorize-follow?acct={uri}`,
};
if (ctx.accepts(jrd, xrd) === xrd) {
ctx.body = XRD(
{ element: 'Subject', value: subject },
{ element: 'Link', attributes: self },
{ element: 'Link', attributes: profilePage },
{ element: 'Link', attributes: subscribe });
{ element: 'Subject', value: elements.subject },
...elements.links.map((link) => ({
element: 'Link', attributes: link,
})));
ctx.type = xrd;
} else {
ctx.body = {
subject,
links: [self, profilePage, subscribe],
};
ctx.body = elements;
ctx.type = jrd;
}

View File

@ -70,7 +70,7 @@
<span>{{ i18n.ts.showMore }}</span>
</button>
</div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="channelPage(appearNote.channel)"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer class="footer">
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
@ -122,6 +122,7 @@ import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
import { channelPage } from '@/filters/channel';
import * as os from '@/os';
import { defaultStore, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker';

View File

@ -0,0 +1,16 @@
import * as misskey from 'misskey-js';
import { url } from '@/config';
const normalize = (channel: misskey.entities.Channel) => {
return channel.host == null ? channel.slug : `${channel.slug}@${channel.host}`;
};
export const channelPage = (channel: misskey.entities.Channel, path?, absolute = false) => {
return `${absolute ? url : ''}/+${normalize(channel)}${(path ? `/${path}` : '')}`;
};
export const parseChannel = (grpName: string) => {
if (grpName.startsWith('+')) grpName = grpName.substr(1);
const split = grpName.split('@', 2);
return { channelName: split[0], host: split[1] || null };
}

View File

@ -23,7 +23,7 @@
<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
<XTimeline :key="channel.id" class="_gap" src="channel" :channel="channel.id" @before="before" @after="after"/>
</div>
</MkSpacer>
</template>
@ -36,6 +36,7 @@ import XTimeline from '@/components/timeline.vue';
import XChannelFollowButton from '@/components/channel-follow-button.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { parseChannel } from '@/filters/channel';
export default defineComponent({
components: {
@ -46,7 +47,7 @@ export default defineComponent({
},
props: {
channelId: {
channelName: {
type: String,
required: true
}
@ -70,17 +71,18 @@ export default defineComponent({
endpoint: 'channels/timeline' as const,
limit: 10,
params: computed(() => ({
channelId: this.channelId,
channelId: this.channel.id,
}))
},
};
},
watch: {
channelId: {
channelName: {
async handler() {
const { channelName, host } = parseChannel(this.channelName);
this.channel = await os.api('channels/show', {
channelId: this.channelId,
channelName, host,
});
},
immediate: true
@ -117,12 +119,12 @@ export default defineComponent({
color: #fff;
background: rgba(0, 0, 0, 0.5);
border-radius: 100%;
> i {
vertical-align: middle;
}
}
> .banner {
position: relative;
height: 200px;

View File

@ -44,7 +44,7 @@ const defaultRoutes = [
{ path: '/channels', component: page('channels') },
{ path: '/channels/new', component: page('channel-editor') },
{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
{ path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) },
{ path: '/+:channel', component: page('channel'), props: route => ({ channelName: route.params.channel }) },
{ path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) },
{ path: '/timeline/list/:listId', component: page('user-list-timeline'), props: route => ({ listId: route.params.listId }) },
{ path: '/timeline/antenna/:antennaId', component: page('antenna-timeline'), props: route => ({ antennaId: route.params.antennaId }) },

View File

@ -1,6 +1,7 @@
#!/usr/bin/env fish
argparse "branch=" -- $argv
cd misskey
git fetch
git checkout $_flag_branch
git pull
npm run clean