diff options
Diffstat (limited to 'packages')
13 files changed, 202 insertions, 3 deletions
diff --git a/packages/backend/migration/1748096357260-AddAttributionDomains.js b/packages/backend/migration/1748096357260-AddAttributionDomains.js new file mode 100644 index 0000000000..90c0d0c655 --- /dev/null +++ b/packages/backend/migration/1748096357260-AddAttributionDomains.js @@ -0,0 +1,19 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddAttributionDomains1748096357260 { + name = 'AddAttributionDomains1748096357260' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "attributionDomains" text array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "attributionDomains"`); + } +} diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index f41eeba39f..46a78687f3 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -613,6 +613,7 @@ export class ApRendererService { enableRss: user.enableRss, speakAsCat: user.speakAsCat, attachment: attachment.length ? attachment : undefined, + attributionDomains: user.attributionDomains, }; if (user.movedToUri) { diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 5c0b8ffcbb..cedd1d8dd5 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -546,6 +546,10 @@ const extension_context_definition = { featured: 'toot:featured', discoverable: 'toot:discoverable', indexable: 'toot:indexable', + attributionDomains: { + '@id': 'toot:attributionDomains', + '@type': '@id', + }, // schema schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 4b685f7e1b..744b339254 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -433,6 +433,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, emojis, + attributionDomains: (person as any).attributionDomains, })) as MiRemoteUser; let _description: string | null = null; @@ -616,6 +617,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { // We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic. hideOnlineStatus: person.hideOnlineStatus !== false, isExplorable: person.discoverable !== false, + attributionDomains: person.attributionDomains, ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), } as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 281733d484..0122697f2a 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -255,6 +255,7 @@ export interface IActor extends IObject { enableRss?: boolean; listenbrainz?: string; backgroundUrl?: string; + attributionDomains?: string[]; } export const isCollection = (object: IObject): object is ICollection => diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 56506a5fa4..f66a36336d 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -603,6 +603,7 @@ export class UserEntityService implements OnModuleInit { enableRss: user.enableRss, mandatoryCW: user.mandatoryCW, rejectQuotes: user.rejectQuotes, + attributionDomains: user.attributionDomains, isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? false, approved: user.approved, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 46f8e84a94..d5f572a879 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -378,6 +378,12 @@ export class MiUser { }) public allowUnsignedFetch: UserUnsignedFetchOption; + @Column('varchar', { + name: 'attributionDomains', + length: 128, array: true, default: '{}', + }) + public attributionDomains: string[]; + constructor(data: Partial<MiUser>) { if (data == null) return; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 964a179244..9bfb57fc66 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -236,6 +236,13 @@ export const packedUserLiteSchema = { }, }, }, + attributionDomains: { + type: 'object', + nullable: false, optional: false, + additionalProperties: { + type: 'string', + }, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index f35e395841..dad605f151 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -263,6 +263,9 @@ export const paramDef = { enum: userUnsignedFetchOptions, nullable: false, }, + attributionDomains: { type: 'array', items: { + type: 'string', + } }, }, } as const; @@ -373,6 +376,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; + if (ps.attributionDomains !== undefined) updates.attributionDomains = ps.attributionDomains; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; @@ -663,7 +667,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // these two methods need to be kept in sync with // `ApRendererService.renderPerson` private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean { - const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore']; + const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore', 'attributionDomains']; for (const field of basicFields) { if ((field in newUser) && oldUser[field] !== newUser[field]) { return true; diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 203bc908a8..d5d62c67c0 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -19,7 +19,8 @@ import { MiMeta } from '@/models/Meta.js'; import { RedisKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import type { MiAccessToken, NotesRepository } from '@/models/_.js'; +import type { MiAccessToken, NotesRepository, UsersRepository } from '@/models/_.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; @@ -34,6 +35,12 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; export type LocalSummalyResult = SummalyResult & { haveNoteLocally?: boolean; + linkAttribution?: { + name: string, + username: string, + avatarUrl: string, + avatarBlurhash: string, + } }; // Increment this to invalidate cached previews after a major change. @@ -77,11 +84,15 @@ export class UrlPreviewService { @Inject(DI.notesRepository) private readonly notesRepository: NotesRepository, + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + private httpRequestService: HttpRequestService, private loggerService: LoggerService, private readonly utilityService: UtilityService, private readonly apUtilityService: ApUtilityService, private readonly apDbResolverService: ApDbResolverService, + private readonly remoteUserResolveService: RemoteUserResolveService, private readonly apRequestService: ApRequestService, private readonly systemAccountService: SystemAccountService, private readonly apNoteService: ApNoteService, @@ -206,6 +217,8 @@ export class UrlPreviewService { } } + await this.validateLinkAttribution(summary); + // Await this to avoid hammering redis when a bunch of URLs are fetched at once await this.previewCache.set(cacheKey, summary); @@ -426,6 +439,41 @@ export class UrlPreviewService { } } + private async validateLinkAttribution(summary: LocalSummalyResult) { + if (!summary.fediverseCreator) return; + + const url = URL.parse(summary.url); + if (!url) return; + let fediverseCreator = summary.fediverseCreator; + // expecting either '@username@host' or 'username@host' + if (fediverseCreator.startsWith('@')) { + fediverseCreator = fediverseCreator.substring(1); + } + + // + const array = fediverseCreator.split('@'); + const username = array[0].toLowerCase(); + let host: string | null = array[1]; + if (host.toLowerCase() === this.config.host) { + host = null; + } + try { + const user = await this.remoteUserResolveService.resolveUser(username, host) + + const attributionDomains = user.attributionDomains; + if (attributionDomains.some(x => `.${url.host.toLowerCase()}`.endsWith(`.${x}`))) { + summary.linkAttribution = { + name: user.name ?? user.username, + username: fediverseCreator, + avatarUrl: user.avatarUrl ?? '', + avatarBlurhash: user.avatarBlurhash ?? '', + } + } + } catch { + console.warn('user not found: ' + fediverseCreator) + } + } + // Adapted from ApiCallService private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> { const [user, app] = auth; diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index a14c2ecef9..029d2212eb 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -65,6 +65,13 @@ SPDX-License-Identifier: AGPL-3.0-only </footer> </article> </component> + <footer v-if="linkAttribution" :class="$style.footer" style="float: right"> + <a :href="'/@' + linkAttribution.username"> + <p :class="$style.linkAttribution">{{i18n.ts.writtenBy}}</p> + <MkImgWithBlurhash :class="$style.linkAttributionIcon" :src="linkAttribution.avatarUrl" :hash="linkAttribution.avatarBlurhash" :cover="true" :onlyAvgColor="true"/> + <b :class="$style.linkAttribution" style="color: var(--MI_THEME-accent)">{{ linkAttribution.name }}</b> + </a> + </footer> <template v-if="showActions"> <div v-if="tweetId" :class="$style.action"> <MkButton :small="true" inline @click="tweetExpanded = true"> @@ -99,6 +106,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { deviceKind } from '@/utility/device-kind.js'; import MkButton from '@/components/MkButton.vue'; +import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { transformPlayerUrl } from '@/utility/player-url-transform.js'; import { store } from '@/store.js'; import { prefer } from '@/preferences.js'; @@ -146,6 +154,12 @@ const player = ref<SummalyResult['player']>({ height: null, allow: [], }); +const linkAttribution = ref<{ + name: string, + username: string, + avatarUrl: string, + avatarBlurhash: string, +} | null>(null); const playerEnabled = ref(false); const tweetId = ref<string | null>(null); const tweetExpanded = ref(props.detail); @@ -221,7 +235,15 @@ function refresh(withFetch = false) { return res.json(); }) - .then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => { + .then(async (info: SummalyResult & { + haveNoteLocally?: boolean, + linkAttribution?: { + name: string, + username: string, + avatarUrl: string, + avatarBlurhash: string, + } + } | null) => { unknownUrl.value = info == null; title.value = info?.title ?? null; description.value = info?.description ?? null; @@ -236,6 +258,7 @@ function refresh(withFetch = false) { }; sensitive.value = info?.sensitive ?? false; activityPub.value = info?.activityPub ?? null; + linkAttribution.value = info?.linkAttribution ?? null; theNote.value = null; if (info?.haveNoteLocally) { @@ -395,6 +418,27 @@ refresh(); vertical-align: top; } +.linkAttributionIcon { + display: inline-block; + width: 1em; + height: 1em; + margin-left: 0.5em; + margin-right: 0.25em; + vertical-align: top; + border-radius: 50%; + * { + border-radius: 4px; + } +} + +.linkAttribution { + font-size: 0.8em; + display: inline-block; + margin: 0; + line-height: 16px; + vertical-align: top; +} + .action { display: flex; gap: 6px; diff --git a/packages/frontend/src/pages/settings/attribution-domains-setting.vue b/packages/frontend/src/pages/settings/attribution-domains-setting.vue new file mode 100644 index 0000000000..3090276f4f --- /dev/null +++ b/packages/frontend/src/pages/settings/attribution-domains-setting.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps_m"> + <SearchMarker :keywords="['attribution', 'domains', 'preview', 'url']"> + <MkTextarea v-model="attributionDomains"> + <template #label>{{ i18n.ts.attributionDomains }}</template> + <template #caption>{{ i18n.ts.attributionDomainsDescription }} + <br/> + <Mfm :text="i18n.tsx.attributionDomainsTutorial({ user: $i.username, host: host})"/> + </template> + </MkTextarea> + </SearchMarker> + <MkButton primary :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import { host as hostRaw } from '@@/js/config.js' +import { toUnicode } from 'punycode.js'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; + +const $i = ensureSignin(); + +const attributionDomains = ref($i.attributionDomains.join('\n')); +const changed = ref(false); +const host = toUnicode(hostRaw); + +async function save() { + let domains = attributionDomains.value + .trim().split('\n') + .map(el => el.trim()) + .filter(el => el); + + await misskeyApi('i/update', { + attributionDomains: domains, + }); + + changed.value = false; + + // Refresh filtered list to signal to the user how they've been saved + attributionDomains.value = domains.join('\n'); +} + +watch(attributionDomains, () => { + changed.value = true; +}); +</script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ee26a8911e..9dc7398062 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -161,6 +161,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.flagAsBotDescription }}</template> </MkSwitch> </SearchMarker> + + <AttributionDomainsSettings/> </div> </MkFolder> </SearchMarker> @@ -170,6 +172,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; +import AttributionDomainsSettings from './attribution-domains-setting.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -217,6 +220,7 @@ const profile = reactive({ isBot: $i.isBot ?? false, isCat: $i.isCat ?? false, speakAsCat: $i.speakAsCat ?? false, + attributionDomains: $i.attributionDomains, }); watch(() => profile, () => { @@ -276,6 +280,7 @@ function save() { isBot: !!profile.isBot, isCat: !!profile.isCat, speakAsCat: !!profile.speakAsCat, + attributionDomains: !!profile.attributionDomains, }, undefined, { '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': { title: i18n.ts.yourNameContainsProhibitedWords, |