mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Implement indexing toggle and followers/following privacy settings
This commit is contained in:
parent
666eef063c
commit
9d1d56bd08
|
|
@ -24,6 +24,7 @@ Please see [Database Changes](#database-changes) and [New Configuration](#new-co
|
||||||
- [x] ✏️ `<div>` and `<span>` tags are now allowed in Markdown.
|
- [x] ✏️ `<div>` and `<span>` tags are now allowed in Markdown.
|
||||||
- [x] 🔥 Removed nonstandard `/api/v1/accounts/id` endpoint (the same functionality was already possible with other endpoints).
|
- [x] 🔥 Removed nonstandard `/api/v1/accounts/id` endpoint (the same functionality was already possible with other endpoints).
|
||||||
- [x] ✨️ Implemented rate limiting support for API endpoints.
|
- [x] ✨️ Implemented rate limiting support for API endpoints.
|
||||||
|
- [x] 🔒 Implemented `is_indexable` and `is_hiding_collections` fields to the [**Accounts API**](https://docs.joinmastodon.org/methods/accounts/#update_credentials).
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,33 @@ describe("/api/v1/accounts/:id/followers", () => {
|
||||||
expect(data).toBeInstanceOf(Array);
|
expect(data).toBeInstanceOf(Array);
|
||||||
expect(data.length).toBe(0);
|
expect(data.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should return no followers if account is hiding collections", async () => {
|
||||||
|
await using client0 = await generateClient(users[0]);
|
||||||
|
await using client1 = await generateClient(users[1]);
|
||||||
|
|
||||||
|
const { ok: ok0 } = await client0.followAccount(users[1].id);
|
||||||
|
|
||||||
|
expect(ok0).toBe(true);
|
||||||
|
|
||||||
|
const { ok: ok1, data: data1 } = await client0.getAccountFollowers(
|
||||||
|
users[1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok1).toBe(true);
|
||||||
|
expect(data1).toBeArrayOfSize(1);
|
||||||
|
|
||||||
|
const { ok: ok2 } = await client1.updateCredentials({
|
||||||
|
hide_collections: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ok2).toBe(true);
|
||||||
|
|
||||||
|
const { ok: ok3, data: data3 } = await client0.getAccountFollowers(
|
||||||
|
users[1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok3).toBe(true);
|
||||||
|
expect(data3).toBeArrayOfSize(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,18 @@ export default apiRoute((app) =>
|
||||||
handleZodError,
|
handleZodError,
|
||||||
),
|
),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
|
const { user: self } = context.get("auth");
|
||||||
const { max_id, since_id, min_id, limit } =
|
const { max_id, since_id, min_id, limit } =
|
||||||
context.req.valid("query");
|
context.req.valid("query");
|
||||||
const otherUser = context.get("user");
|
const otherUser = context.get("user");
|
||||||
|
|
||||||
// TODO: Add follower/following privacy settings
|
if (
|
||||||
|
self?.id !== otherUser.id &&
|
||||||
|
otherUser.data.isHidingCollections
|
||||||
|
) {
|
||||||
|
return context.json([], 200, { Link: "" });
|
||||||
|
}
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
const { objects, link } = await Timeline.getUserTimeline(
|
||||||
and(
|
and(
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
max_id ? lt(Users.id, max_id) : undefined,
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,33 @@ describe("/api/v1/accounts/:id/following", () => {
|
||||||
expect(data).toBeInstanceOf(Array);
|
expect(data).toBeInstanceOf(Array);
|
||||||
expect(data.length).toBe(0);
|
expect(data.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should return no following if account is hiding collections", async () => {
|
||||||
|
await using client0 = await generateClient(users[0]);
|
||||||
|
await using client1 = await generateClient(users[1]);
|
||||||
|
|
||||||
|
const { ok: ok0 } = await client1.followAccount(users[0].id);
|
||||||
|
|
||||||
|
expect(ok0).toBe(true);
|
||||||
|
|
||||||
|
const { ok: ok1, data: data1 } = await client0.getAccountFollowing(
|
||||||
|
users[1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok1).toBe(true);
|
||||||
|
expect(data1).toBeArrayOfSize(1);
|
||||||
|
|
||||||
|
const { ok: ok2 } = await client1.updateCredentials({
|
||||||
|
hide_collections: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ok2).toBe(true);
|
||||||
|
|
||||||
|
const { ok: ok3, data: data3 } = await client0.getAccountFollowing(
|
||||||
|
users[1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok3).toBe(true);
|
||||||
|
expect(data3).toBeArrayOfSize(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,16 @@ export default apiRoute((app) =>
|
||||||
handleZodError,
|
handleZodError,
|
||||||
),
|
),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
|
const { user: self } = context.get("auth");
|
||||||
const { max_id, since_id, min_id } = context.req.valid("query");
|
const { max_id, since_id, min_id } = context.req.valid("query");
|
||||||
const otherUser = context.get("user");
|
const otherUser = context.get("user");
|
||||||
|
|
||||||
// TODO: Add follower/following privacy settings
|
if (
|
||||||
|
self?.id !== otherUser.id &&
|
||||||
|
otherUser.data.isHidingCollections
|
||||||
|
) {
|
||||||
|
return context.json([], 200, { Link: "" });
|
||||||
|
}
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
const { objects, link } = await Timeline.getUserTimeline(
|
||||||
and(
|
and(
|
||||||
|
|
|
||||||
|
|
@ -110,16 +110,16 @@ export default apiRoute((app) =>
|
||||||
bot: AccountSchema.shape.bot.openapi({
|
bot: AccountSchema.shape.bot.openapi({
|
||||||
description: "Whether the account has a bot flag.",
|
description: "Whether the account has a bot flag.",
|
||||||
}),
|
}),
|
||||||
discoverable: AccountSchema.shape.discoverable.openapi({
|
discoverable: AccountSchema.shape.discoverable
|
||||||
description:
|
.unwrap()
|
||||||
"Whether the account should be shown in the profile directory.",
|
.openapi({
|
||||||
}),
|
description:
|
||||||
// TODO: Implement :(
|
"Whether the account should be shown in the profile directory.",
|
||||||
|
}),
|
||||||
hide_collections: zBoolean.openapi({
|
hide_collections: zBoolean.openapi({
|
||||||
description:
|
description:
|
||||||
"Whether to hide followers and followed accounts.",
|
"Whether to hide followers and followed accounts.",
|
||||||
}),
|
}),
|
||||||
// TODO: Implement :(
|
|
||||||
indexable: zBoolean.openapi({
|
indexable: zBoolean.openapi({
|
||||||
description:
|
description:
|
||||||
"Whether public posts should be searchable to anyone.",
|
"Whether public posts should be searchable to anyone.",
|
||||||
|
|
@ -168,6 +168,8 @@ export default apiRoute((app) =>
|
||||||
locked,
|
locked,
|
||||||
bot,
|
bot,
|
||||||
discoverable,
|
discoverable,
|
||||||
|
hide_collections,
|
||||||
|
indexable,
|
||||||
source,
|
source,
|
||||||
fields_attributes,
|
fields_attributes,
|
||||||
} = context.req.valid("json");
|
} = context.req.valid("json");
|
||||||
|
|
@ -249,14 +251,22 @@ export default apiRoute((app) =>
|
||||||
self.isLocked = locked;
|
self.isLocked = locked;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bot) {
|
if (bot !== undefined) {
|
||||||
self.isBot = bot;
|
self.isBot = bot;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discoverable) {
|
if (discoverable !== undefined) {
|
||||||
self.isDiscoverable = discoverable;
|
self.isDiscoverable = discoverable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hide_collections !== undefined) {
|
||||||
|
self.isHidingCollections = hide_collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexable !== undefined) {
|
||||||
|
self.isIndexable = indexable;
|
||||||
|
}
|
||||||
|
|
||||||
const fieldEmojis: Emoji[] = [];
|
const fieldEmojis: Emoji[] = [];
|
||||||
|
|
||||||
if (fields_attributes) {
|
if (fields_attributes) {
|
||||||
|
|
@ -342,6 +352,8 @@ export default apiRoute((app) =>
|
||||||
isLocked: self.isLocked,
|
isLocked: self.isLocked,
|
||||||
isBot: self.isBot,
|
isBot: self.isBot,
|
||||||
isDiscoverable: self.isDiscoverable,
|
isDiscoverable: self.isDiscoverable,
|
||||||
|
isHidingCollections: self.isHidingCollections,
|
||||||
|
isIndexable: self.isIndexable,
|
||||||
source: self.source || undefined,
|
source: self.source || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ describe("/api/v1/accounts/verify_credentials", () => {
|
||||||
expect(data.bot).toBe(users[0].data.isBot);
|
expect(data.bot).toBe(users[0].data.isBot);
|
||||||
expect(data.group).toBe(false);
|
expect(data.group).toBe(false);
|
||||||
expect(data.discoverable).toBe(users[0].data.isDiscoverable);
|
expect(data.discoverable).toBe(users[0].data.isDiscoverable);
|
||||||
expect(data.noindex).toBe(false);
|
expect(data.noindex).toBe(!users[0].data.isIndexable);
|
||||||
expect(data.moved).toBeNull();
|
expect(data.moved).toBeNull();
|
||||||
expect(data.suspended).toBe(false);
|
expect(data.suspended).toBe(false);
|
||||||
expect(data.limited).toBe(false);
|
expect(data.limited).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -1126,8 +1126,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
: "",
|
: "",
|
||||||
locked: user.isLocked,
|
locked: user.isLocked,
|
||||||
created_at: new Date(user.createdAt).toISOString(),
|
created_at: new Date(user.createdAt).toISOString(),
|
||||||
followers_count: user.followerCount,
|
followers_count:
|
||||||
following_count: user.followingCount,
|
user.isHidingCollections && !isOwnAccount
|
||||||
|
? 0
|
||||||
|
: user.followerCount,
|
||||||
|
following_count:
|
||||||
|
user.isHidingCollections && !isOwnAccount
|
||||||
|
? 0
|
||||||
|
: user.followingCount,
|
||||||
statuses_count: user.statusCount,
|
statuses_count: user.statusCount,
|
||||||
emojis: user.emojis.map((emoji) => new Emoji(emoji).toApi()),
|
emojis: user.emojis.map((emoji) => new Emoji(emoji).toApi()),
|
||||||
fields: user.fields.map((field) => ({
|
fields: user.fields.map((field) => ({
|
||||||
|
|
@ -1146,7 +1152,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
// TODO: Add these fields
|
// TODO: Add these fields
|
||||||
limited: false,
|
limited: false,
|
||||||
moved: null,
|
moved: null,
|
||||||
noindex: false,
|
noindex: !user.isIndexable,
|
||||||
suspended: false,
|
suspended: false,
|
||||||
discoverable: user.isDiscoverable,
|
discoverable: user.isDiscoverable,
|
||||||
mute_expires_at: null,
|
mute_expires_at: null,
|
||||||
|
|
@ -1238,7 +1244,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
`/users/${user.id}/inbox`,
|
`/users/${user.id}/inbox`,
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).toString(),
|
).toString(),
|
||||||
indexable: false,
|
indexable: this.data.isIndexable,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
manually_approves_followers: this.data.isLocked,
|
manually_approves_followers: this.data.isLocked,
|
||||||
avatar: this.avatar?.toVersia(),
|
avatar: this.avatar?.toVersia(),
|
||||||
|
|
|
||||||
2
drizzle/migrations/0046_wooden_electro.sql
Normal file
2
drizzle/migrations/0046_wooden_electro.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "Users" ADD COLUMN "is_hiding_collections" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "Users" ADD COLUMN "is_indexable" boolean DEFAULT false NOT NULL;
|
||||||
1
drizzle/migrations/0047_black_vermin.sql
Normal file
1
drizzle/migrations/0047_black_vermin.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "Users" ALTER COLUMN "is_indexable" SET DEFAULT true;
|
||||||
2363
drizzle/migrations/meta/0046_snapshot.json
Normal file
2363
drizzle/migrations/meta/0046_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2363
drizzle/migrations/meta/0047_snapshot.json
Normal file
2363
drizzle/migrations/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -323,6 +323,20 @@
|
||||||
"when": 1738087527661,
|
"when": 1738087527661,
|
||||||
"tag": "0045_polite_mikhail_rasputin",
|
"tag": "0045_polite_mikhail_rasputin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1743358605315,
|
||||||
|
"tag": "0046_wooden_electro",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1743359397906,
|
||||||
|
"tag": "0047_black_vermin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -577,6 +577,10 @@ export const Users = pgTable(
|
||||||
isBot: boolean("is_bot").default(false).notNull(),
|
isBot: boolean("is_bot").default(false).notNull(),
|
||||||
isLocked: boolean("is_locked").default(false).notNull(),
|
isLocked: boolean("is_locked").default(false).notNull(),
|
||||||
isDiscoverable: boolean("is_discoverable").default(false).notNull(),
|
isDiscoverable: boolean("is_discoverable").default(false).notNull(),
|
||||||
|
isHidingCollections: boolean("is_hiding_collections")
|
||||||
|
.default(false)
|
||||||
|
.notNull(),
|
||||||
|
isIndexable: boolean("is_indexable").default(true).notNull(),
|
||||||
sanctions: text("sanctions").array(),
|
sanctions: text("sanctions").array(),
|
||||||
publicKey: text("public_key").notNull(),
|
publicKey: text("public_key").notNull(),
|
||||||
privateKey: text("private_key"),
|
privateKey: text("private_key"),
|
||||||
|
|
|
||||||
|
|
@ -3061,6 +3061,8 @@ export class Client extends BaseClient {
|
||||||
}[];
|
}[];
|
||||||
header: File | URL;
|
header: File | URL;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
hide_collections: boolean;
|
||||||
|
indexable: boolean;
|
||||||
note: string;
|
note: string;
|
||||||
source: Partial<{
|
source: Partial<{
|
||||||
language: string;
|
language: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue