diff --git a/locales/index.d.ts b/locales/index.d.ts index 46bd9d04ed..cee973a0a2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13157,6 +13157,18 @@ export interface Locale extends ILocale { * Timeout in milliseconds for translation API requests. */ "translationTimeoutCaption": string; + /** + * Attribution Domains + */ + "attributionDomains": string; + /** + * A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage: + */ + "attributionDomainsDescription": string; + /** + * Written by {user} + */ + "writtenBy": ParameterizedString<"user">; /** * Following (Pub) */ diff --git a/packages/backend/migration/1748096357260-AddAttributionDomains.js b/packages/backend/migration/1748096357260-AddAttributionDomains.js new file mode 100644 index 0000000000..0a9679bccd --- /dev/null +++ b/packages/backend/migration/1748096357260-AddAttributionDomains.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: piuvas and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export 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/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index afd011c410..8c1508df24 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial): MiUser { mandatoryCW: null, rejectQuotes: false, allowUnsignedFetch: 'staff', + attributionDomains: [], ...override, }; } 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 2772f47781..dde5762f53 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -445,6 +445,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, emojis, + attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [], })) as MiRemoteUser; let _description: string | null = null; @@ -628,6 +629,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: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [], ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), } as Partial & Pick; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index ae9fe118bc..362c3af1e7 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -265,6 +265,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 feddb8fa94..f6aeb0ef8b 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 55b8f4f4f0..3ef5817672 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -389,6 +389,12 @@ export class MiUser { }) public allowUnsignedFetch: UserUnsignedFetchOption; + @Column('varchar', { + name: 'attributionDomains', + length: 128, array: true, default: '{}', + }) + public attributionDomains: string[]; + constructor(data: Partial) { 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..96369c3ad9 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -236,6 +236,14 @@ export const packedUserLiteSchema = { }, }, }, + attributionDomains: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, }, } 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 { // 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 { // eslint- // these two methods need to be kept in sync with // `ApRendererService.renderPerson` private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial): 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..2a300782c6 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -20,6 +20,7 @@ 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 { 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'; @@ -30,10 +31,14 @@ import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit- import type { MiLocalUser } from '@/models/User.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import { isRetryableError } from '@/misc/is-retryable-error.js'; +import * as Acct from '@/misc/acct.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; export type LocalSummalyResult = SummalyResult & { haveNoteLocally?: boolean; + linkAttribution?: { + userId: string, + } }; // Increment this to invalidate cached previews after a major change. @@ -82,6 +87,7 @@ export class UrlPreviewService { 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 +212,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 +434,30 @@ export class UrlPreviewService { } } + private async validateLinkAttribution(summary: LocalSummalyResult) { + if (!summary.fediverseCreator) return; + if (!URL.canParse(summary.url)) return; + + const url = URL.parse(summary.url); + + const acct = Acct.parse(summary.fediverseCreator); + if (acct.host?.toLowerCase() === this.config.host) { + acct.host = null; + } + try { + const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host); + + const attributionDomains = user.attributionDomains; + if (attributionDomains.some(x => `.${url?.host.toLowerCase()}`.endsWith(`.${x}`))) { + summary.linkAttribution = { + userId: user.id, + }; + } + } catch { + this.logger.debug('User not found: ' + summary.fediverseCreator); + } + } + // Adapted from ApiCallService private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise { const [user, app] = auth; diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index a14c2ecef9..69a1540600 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -65,6 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +

+ diff --git a/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue b/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue new file mode 100644 index 0000000000..c77870f9d3 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ee4dd1b65a..21bc74326a 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -163,6 +163,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + @@ -172,6 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only