diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index 78d601e97b1..4584fddef10 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -14,6 +14,7 @@ DriveFile.createIndex('metadata.uri'); DriveFile.createIndex('metadata.userId'); DriveFile.createIndex('metadata.folderId'); DriveFile.createIndex('metadata._user.host'); +DriveFile.createIndex('metadata.apId', { sparse: true, unique: true }); export default DriveFile; export const DriveFileChunk = db.get('driveFiles.chunks'); @@ -48,6 +49,9 @@ export type IMetadata = { folderId: mongo.ObjectID; comment: string; + /** ActivityPub object ID */ + apId?: string; + /** * リモートインスタンスから取得した場合の元URL */ diff --git a/src/remote/activitypub/kernel/update/index.ts b/src/remote/activitypub/kernel/update/index.ts index 0128a54cc4f..29551b6f72d 100644 --- a/src/remote/activitypub/kernel/update/index.ts +++ b/src/remote/activitypub/kernel/update/index.ts @@ -1,10 +1,11 @@ import { IRemoteUser } from '../../../../models/user'; -import { IUpdate, isActor, getApType, isPost } from '../../type'; +import { IUpdate, isActor, getApType, isPost, isDocument } from '../../type'; import { apLogger } from '../../logger'; import { updateQuestion } from '../../models/question'; import Resolver from '../../resolver'; import { updatePerson } from '../../models/person'; import updateNote from './note'; +import { updateImage } from '../../models/image'; /** * Updateアクティビティを捌きます @@ -31,6 +32,8 @@ export default async (actor: IRemoteUser, activity: IUpdate): Promise => return `ok: Question updated`; } else if (isPost(object)) { return await updateNote(actor, object); + } else if (isDocument(object)) { + return await updateImage(actor, object); } else { return `skip: Unknown type: ${getApType(object)}`; } diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts index 20c25e3decd..c2c5c2052eb 100644 --- a/src/remote/activitypub/models/image.ts +++ b/src/remote/activitypub/models/image.ts @@ -4,8 +4,10 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file'; import Resolver from '../resolver'; import fetchMeta from '../../../misc/fetch-meta'; import { apLogger } from '../logger'; -import { IObject, isDocument } from '../type'; +import { IApDocument, IObject, isDocument } from '../type'; import { StatusError } from '../../../misc/fetch'; +import { oidEquals } from '../../../prelude/oid'; +import { toApHost } from '../../../misc/convert-host'; const logger = apLogger; @@ -13,53 +15,30 @@ const logger = apLogger; * Imageを作成します。 */ export async function createImage(actor: IRemoteUser, value: IObject): Promise { - // 投稿者が凍結か削除されていたらスキップ - if (actor.isSuspended || actor.isDeleted) { - return null; - } - - const image = await new Resolver().resolve(value); + const { document, apId } = await getApDocument(actor, value); - if (!isDocument(image)) return null; + if (!document) return null; // not Document - if (typeof image.url !== 'string') { - return null; - } - - logger.info(`Creating the Image: ${image.url}`); + logger.info(`Creating the Image: ${document.url} apId=${apId}`); const instance = await fetchMeta(); const cache = instance.cacheRemoteFiles; let file; try { - file = await uploadFromUrl({ url: image.url, user: actor, uri: image.url, sensitive: !!image.sensitive, isLink: !cache }); + file = await uploadFromUrl({ url: document.url as string, user: actor, uri: document.url as string, sensitive: !!document.sensitive, isLink: !cache, apId }); } catch (e) { // 4xxの場合は添付されてなかったことにする if (e instanceof StatusError && e.isPermanentError) { - logger.warn(`Ignored image: ${image.url} - ${e.statusCode}`); + logger.warn(`Ignored image: ${document.url} - ${e.statusCode}`); return null; } throw e; } - if (file.metadata?.isRemote) { - // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 - // URLを更新する - if (file.metadata.url !== image.url) { - file = await DriveFile.findOneAndUpdate({ _id: file._id }, { - $set: { - 'metadata.url': image.url, - 'metadata.uri': image.url - } - }, { - returnNewDocument: true - }); - } - } - - return file; + const fresh = await detectChangeAndUpdate(file, document); + return fresh; } /** @@ -69,8 +48,89 @@ export async function createImage(actor: IRemoteUser, value: IObject): Promise { - // TODO + const { document, apId } = await getApDocument(actor, value); + + if (!document) return null; // not Document + + if (apId) { + const exists = await DriveFile.findOne({ apId: document.id }); + + if (exists) { + const fresh = await detectChangeAndUpdate(exists, document); + return fresh; + } + } // リモートサーバーからフェッチしてきて登録 return await createImage(actor, value); } + +export async function updateImage(actor: IRemoteUser, value: IObject): Promise { + const { document, apId } = await getApDocument(actor, value); + + if (!document) return `skip: invalid Document`; + if (!apId) return `skip: invalid apId=${apId}`; + + const exists = await DriveFile.findOne({ apId }); + if (!exists) return `skip: not existant apId=${apId}`; + + // check owner + if (!oidEquals(exists.metadata?.userId, actor._id)) return `skip: invalid owner`; + + await detectChangeAndUpdate(exists, document); + + return `ok: updated apId=${apId}`; +} + +/** + * Detect DriveFile changes and update + * @param exists Current DB entity + * @param document Incomming AP Document + * @returns Fresh DriveFile + */ +async function detectChangeAndUpdate(exists: IDriveFile, document: IApDocument) { + const set = { } as any; + + // detect general metadata changes + if (typeof document.sensitive === 'boolean') set.isSensitive = document.sensitive; + if (typeof document.name === 'string') set.name = document.name; + + // detect src url change + if (exists.metadata?.isRemote && exists.metadata?.url !== document.url) { + set['metadata.url'] = document.url; + set['metadata.uri'] = document.url; + } + + // TODO: no peform no needd + + const fresh = await DriveFile.findOneAndUpdate({ _id: exists._id }, { + $set: set + }, { + returnNewDocument: true + }); + + if (!fresh) throw 'unex 23414r2t'; + return fresh; +} + +/** + * Get validated Document and apId (if available) + */ +async function getApDocument(actor: IRemoteUser, value: IObject): Promise<{ document?: IApDocument, apId?: string }> { + // check actor available + if (actor.isSuspended || actor.isDeleted) return { }; + + const document = await new Resolver().resolve(value); + + // check valid Document + if (!isDocument(document)) return { }; + if (typeof document.url !== 'string') return { }; + + // provide apId if valid + let apId: string | undefined = undefined; + try { + if (typeof document.id === 'string' && toApHost(document.id) === toApHost(actor.uri)) apId = document.id; + } catch { } + + return { document, apId }; +} diff --git a/src/remote/activitypub/renderer/document.ts b/src/remote/activitypub/renderer/document.ts index 7ddb062390f..270d9c42a88 100644 --- a/src/remote/activitypub/renderer/document.ts +++ b/src/remote/activitypub/renderer/document.ts @@ -1,7 +1,9 @@ import { IDriveFile } from '../../../models/drive-file'; import getDriveFileUrl from '../../../misc/get-drive-file-url'; +import config from '../../../config'; export default (file: IDriveFile) => ({ + id: `${config.url}/activitypub/documents/${file._id}`, type: 'Document', mediaType: file.contentType, sensitive: !!file.metadata?.isSensitive, diff --git a/src/remote/activitypub/renderer/image.ts b/src/remote/activitypub/renderer/image.ts index 29aff6027b3..495e15fc11a 100644 --- a/src/remote/activitypub/renderer/image.ts +++ b/src/remote/activitypub/renderer/image.ts @@ -1,8 +1,11 @@ import { IDriveFile } from '../../../models/drive-file'; import getDriveFileUrl from '../../../misc/get-drive-file-url'; +import config from '../../../config'; export default (file: IDriveFile) => ({ + id: `${config.url}/activitypub/documents/${file._id}`, type: 'Image', - url: getDriveFileUrl(file), - sensitive: !!file.metadata?.isSensitive + mediaType: file.contentType, + sensitive: !!file.metadata?.isSensitive, + url: getDriveFileUrl(file) }); diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index f1b005c8297..b4de8fdc1f7 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -6,6 +6,10 @@ import { publishDriveStream } from '../../../../../services/stream'; import define from '../../../define'; import Note from '../../../../../models/note'; import { ApiError } from '../../../error'; +import { renderActivity } from '../../../../../remote/activitypub/renderer'; +import renderUpdate from '../../../../../remote/activitypub/renderer/update'; +import renderDocument from '../../../../../remote/activitypub/renderer/document'; +import { deliverToFollowers } from '../../../../../remote/activitypub/deliver-manager'; export const meta = { desc: { @@ -84,7 +88,7 @@ export default define(meta, async (ps, user) => { _id: ps.fileId }); - if (file === null) { + if (file == null) { throw new ApiError(meta.errors.noSuchFile); } @@ -142,5 +146,22 @@ export default define(meta, async (ps, user) => { // Publish fileUpdated event publishDriveStream(user._id, 'fileUpdated', fileObj); + if (!user.noFederation && file.metadata?.attachedNoteIds) { + const n = await Note.findOne({ + _id: { $in: file.metadata?.attachedNoteIds }, // + deletedAt: { $exists: false }, + userId: user._id, + visibility: { $in: ['public', 'home'] }, + localOnly: { $ne: true }, + copyOnce: { $ne: true } + }); + + if (n) { + const content = renderActivity(renderUpdate(await renderDocument(file), user)); + deliverToFollowers(user, content); + } + } + + return fileObj; }); diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 603d6a5a1a2..5b186a9e427 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -388,6 +388,7 @@ type AddFileArgs = { uri?: string | null; /** CommMark file as sensitiveent */ sensitive?: boolean; + apId?: string; } /** @@ -405,6 +406,7 @@ export async function addFile({ url = null, uri = null, sensitive = false, + apId = undefined, }: AddFileArgs): Promise { const info = await getFileInfo(path); logger.info(`${JSON.stringify(info)}`); @@ -512,9 +514,11 @@ export async function addFile({ properties: properties, withoutChunks: isLink, isRemote: isLink, - isSensitive: (isLocalUser(user) && user.settings?.alwaysMarkNsfw) || sensitive + isSensitive: (isLocalUser(user) && user.settings?.alwaysMarkNsfw) || sensitive, } as IMetadata; + if (apId != null) metadata.apId = apId; + if (url !== null) { metadata.src = url; @@ -558,7 +562,7 @@ export async function addFile({ driveFile = await (save(path, detectedName, info, metadata, drive)); } - if (!driveFile) throw 'Failed to create drivefile ${e}'; + if (!driveFile) throw `Failed to create drivefile`; logger.succ(`drive file has been created ${driveFile._id}`); diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts index a838951df74..360bfd53440 100644 --- a/src/services/drive/upload-from-url.ts +++ b/src/services/drive/upload-from-url.ts @@ -16,6 +16,7 @@ type Args = { sensitive?: boolean; force?: boolean; isLink?: boolean; + apId?: string; } export async function uploadFromUrl({ @@ -25,7 +26,8 @@ export async function uploadFromUrl({ uri = null, sensitive = false, force = false, - isLink = false + isLink = false, + apId, }: Args): Promise { // Create temp file const [path, cleanup] = await createTemp(); @@ -41,11 +43,11 @@ export async function uploadFromUrl({ name = null; } - let driveFile: IDriveFile; + let driveFile: IDriveFile | null = null; let error; try { - driveFile = await addFile({ user, path, name, folderId, force, isLink, url, uri, sensitive }); + driveFile = await addFile({ user, path, name, folderId, force, isLink, url, uri, sensitive, apId }); logger.succ(`Got: ${driveFile._id}`); } catch (e) { error = e; @@ -58,7 +60,7 @@ export async function uploadFromUrl({ // clean-up cleanup(); - if (error) { + if (error || !driveFile) { throw error; } else { return driveFile;