Compare commits

...

25 Commits

Author SHA1 Message Date
Johann150 953de3e4b2
adjust mailmap 2023-01-30 19:36:22 +01:00
Johann150 2d32bc33d7
server: fix error for invalid URLs in profile fields
Co-authored-by: Chloe Kudryavtsev <code@code.bunkerlabs.net>
2023-01-30 19:24:15 +01:00
Chloe Kudryavtsev bb3ec8bafe Revert "server: fix user deletion race condition"
This reverts commit cc83cbe523, reversing
changes made to 8abd3ebec7.

This changeset contains:
* multiple type errors
* a foreign key incompatibility
* breaks outgoing note federation (in at least two ways)
2023-01-30 14:59:24 +01:00
Johann150 6fd80816fa
client: remove unused property from MFM component 2023-01-29 14:29:58 +01:00
Johann150 cc83cbe523
server: fix user deletion race condition
Changelog: Fixed
Ref: https://github.com/misskey-dev/misskey/issues/7506
2023-01-29 12:53:29 +01:00
Johann150 8abd3ebec7
client: remove notification forwarding to service worker
This was an interim measure, but now that push notifications are always enabled,
this should not be necessary any more and the service worker should receive
all notifications automatically.
2023-01-29 12:39:26 +01:00
Johann150 36031c083a
docs: adjust parameters for v2 methods other than POST 2023-01-26 13:34:13 +01:00
Johann150 05f8172ce9
docs: describe /ap/ endpoints 2023-01-26 13:25:50 +01:00
Johann150 151053897d
server: lower rate limit for deletion activities
Changelog: Changed
2023-01-26 13:25:50 +01:00
Johann150 95a9027a66
docs: show rate limit information
Changelog: Added
2023-01-26 13:25:49 +01:00
Johann150 57cf6c7163
server: indicate Retry-After when rate limiting
This refactors the rate limiting code to throw an ApiError directly.

Changelog: Added
2023-01-26 08:37:07 +01:00
Johann150 9b76c805ec
fix: DriveFile folder & user undefined instead of null when unrequested 2023-01-25 22:14:53 +01:00
Johann150 21b20920c2
docs: use endpoint stability to mark endpoints deprecated 2023-01-23 20:13:17 +01:00
Johann150 e7644eb757
server: add index to human readable URL 2023-01-23 19:58:07 +01:00
Johann150 66ec875624
server: also search human readable URL
Changelog: Fixed
2023-01-23 18:09:04 +01:00
Johann150 78f5ca3792
server: fix empty array in quote detection 2023-01-22 21:47:02 +01:00
Johann150 c792e4199c
server: add missing return in extractQuoteUrl 2023-01-22 21:42:49 +01:00
Johann150 afa4094050
BREAKING: Remove galleries
Existing gallery posts will be made into normal notes.
If a user has gallery posts, a clip with all gallery posts will be created.

Changelog: Removed
2023-01-22 20:18:57 +01:00
Johann150 c4b5952788
migrate galleries to notes/clips 2023-01-22 19:44:39 +01:00
Johann150 2bbb85b472
backend: remove galleries 2023-01-16 18:53:57 +01:00
Johann150 70fb1e9a5c
foundkey-js: remove galleries 2023-01-16 18:47:29 +01:00
Johann150 48163872ed
client: remove galleries 2023-01-16 18:39:50 +01:00
Johann150 b245d39b6e
server: delete records of fully deleted users 2023-01-08 21:22:03 +01:00
Johann150 80f72e21cd
server: track deletion completion 2023-01-08 21:22:03 +01:00
Johann150 85e985d13f
server: change data structure to track deletion completion 2023-01-08 21:21:54 +01:00
67 changed files with 216 additions and 1727 deletions

View File

@ -1,9 +1,9 @@
Andreas Nedbal <git@pixelde.su> <andreas.nedbal@in2code.de>
Andreas Nedbal <git@pixelde.su> <github-bf215181b5140522137b3d4f6b73544a@desu.email>
Balazs Nadasdi <balazs@weave.works> <yitsushi@gmail.com>
Chloe Kudryavtsev <code@code.bunkerlabs.net> <code@toast.bunkerlabs.net>
Chloe Kudryavtsev <code@code.bunkerlabs.net> <toast+git@toast.cafe>
Chloe Kudryavtsev <code@code.bunkerlabs.net> <toast@toast.cafe>
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <code@code.bunkerlabs.net>
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <toast+git@toast.cafe>
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <toast@toast.cafe>
Dr. Gutfuck LLC <40531868+gutfuckllc@users.noreply.github.com>
Ehsan Javadynia <31900907+ehsanjavadynia@users.noreply.github.com> <ehsan.javadynia@gmail.com>
Francis Dinh <normandy@biribiri.dev>

View File

@ -679,7 +679,6 @@ editCode: "Edit code"
apply: "Apply"
receiveAnnouncementFromInstance: "Receive notifications from this instance"
emailNotification: "Email notifications"
publish: "Publish"
useReactionPickerForContextMenu: "Open reaction picker on right-click"
typingUsers: "{users} is/are typing..."
jumpToSpecifiedDate: "Jump to specific date"
@ -720,11 +719,7 @@ switch: "Switch"
noMaintainerInformationWarning: "Maintainer information is not configured."
noBotProtectionWarning: "Bot protection is not configured."
configure: "Configure"
postToGallery: "Create new gallery post"
attachmentRequired: "At least 1 attachment is required."
gallery: "Gallery"
recentPosts: "Recent posts"
popularPosts: "Popular posts"
shareWithNote: "Share with note"
emailNotConfiguredWarning: "Email address not set."
ratio: "Ratio"
@ -863,11 +858,6 @@ _forgotPassword:
\ instance administrator instead."
contactAdmin: "This instance does not support using email addresses, please contact\
\ the instance administrator to reset your password instead."
_gallery:
my: "My Gallery"
liked: "Liked Posts"
like: "Like"
unlike: "Remove like"
_email:
_follow:
title: "You've got a new follower"
@ -1109,10 +1099,6 @@ _permissions:
"write:user-groups": "Create, modify, delete, transfer, join and leave groups. Invite and ban others from groups. Accept and reject group invitations."
"read:channels": "List and read followed and joined channels"
"write:channels": "Create, modify, follow and unfollow channels"
"read:gallery": "List and read gallery posts"
"write:gallery": "Create, modify and delete gallery posts"
"read:gallery-likes": "List and read gallery post likes"
"write:gallery-likes": "Like and unlike gallery posts"
_auth:
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
shareAccessAsk: "Are you sure you want to authorize this application to access your\

View File

@ -0,0 +1,65 @@
import { genId } from '../built/misc/gen-id.js';
export class removeGroups1673892262930 {
name = 'removeGroups1673892262930';
async up(queryRunner) {
// migrate gallery posts into notes, keeping the ids
await queryRunner.query(`
INSERT INTO "note" (
"id", "createdAt", "text", "cw", "userId", "visibility", "fileIds", "attachedFileTypes", "tags"
)
WITH "file_types" ("id", "types") AS (
SELECT "gallery_post"."id", ARRAY_AGG("drive_file"."type")
FROM "gallery_post"
JOIN "drive_file" ON "drive_file"."id" = ANY("gallery_post"."fileIds")
GROUP BY "gallery_post"."id"
)
SELECT "gallery_post"."id", "gallery_post"."createdAt",
CASE
WHEN "gallery_post"."title" IS NULL THEN "gallery_post"."description"
ELSE '<b>' || "gallery_post"."title" || E'</b>\\n\\n' || "gallery_post"."description"
END,
CASE
WHEN "gallery_post"."isSensitive" THEN 'NSFW'
ELSE NULL
END,
"gallery_post"."userId", 'home', "gallery_post"."fileIds", "file_types"."types", "gallery_post"."tags"
FROM "gallery_post"
JOIN "file_types" ON "gallery_post"."id" = "file_types"."id"
`);
// make a clip for each users gallery
await queryRunner.query(`SELECT DISTINCT "userId" FROM "gallery_post"`).then(userIds =>
Promise.all(userIds.map(({ userId }) => {
const clipId = genId();
// generate the clip itself
return queryRunner.query(`INSERT INTO "clip" ("id", "createdAt", "userId", "name", "isPublic") VALUES ($1, now(), $2, 'Gallery', true)`, [clipId, userId])
// and add all the previous gallery posts to it
// to not have to use genId for each gallery post, we just prepend a zero, something that could never be generated by genId
.then(() => queryRunner.query(`INSERT INTO "clip_note" ("id", "noteId", "clipId") SELECT '0' || "id", "id", $1 FROM "gallery_post" WHERE "userId" = $2`, [clipId, userId]));
}))
);
await queryRunner.query(`DROP TABLE "gallery_like"`);
await queryRunner.query(`DROP TABLE "gallery_post"`);
}
async down(queryRunner) {
// can only restore the table structure
await queryRunner.query(`CREATE TABLE "gallery_post" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "description" character varying(2048), "userId" character varying(32) NOT NULL, "fileIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "isSensitive" boolean NOT NULL DEFAULT false, "likedCount" integer NOT NULL DEFAULT '0', "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_8e90d7b6015f2c4518881b14753" PRIMARY KEY ("id")); COMMENT ON COLUMN "gallery_post"."createdAt" IS 'The created date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."updatedAt" IS 'The updated date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "gallery_post"."isSensitive" IS 'Whether the post is sensitive.'`);
await queryRunner.query(`CREATE INDEX "IDX_8f1a239bd077c8864a20c62c2c" ON "gallery_post" ("createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_f631d37835adb04792e361807c" ON "gallery_post" ("updatedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_985b836dddd8615e432d7043dd" ON "gallery_post" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_3ca50563facd913c425e7a89ee" ON "gallery_post" ("fileIds") `);
await queryRunner.query(`CREATE INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5" ON "gallery_post" ("isSensitive") `);
await queryRunner.query(`CREATE INDEX "IDX_1a165c68a49d08f11caffbd206" ON "gallery_post" ("likedCount") `);
await queryRunner.query(`CREATE INDEX "IDX_05cca34b985d1b8edc1d1e28df" ON "gallery_post" ("tags") `);
await queryRunner.query(`CREATE TABLE "gallery_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "postId" character varying(32) NOT NULL, CONSTRAINT "PK_853ab02be39b8de45cd720cc15f" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_8fd5215095473061855ceb948c" ON "gallery_like" ("userId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_df1b5f4099e99fb0bc5eae53b6" ON "gallery_like" ("userId", "postId") `);
await queryRunner.query(`ALTER TABLE "gallery_post" ADD CONSTRAINT "FK_985b836dddd8615e432d7043ddb" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_8fd5215095473061855ceb948cf" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8" FOREIGN KEY ("postId") REFERENCES "gallery_post"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View File

@ -0,0 +1,17 @@
export class syncOrm1674499888924 {
name = 'syncOrm1674499888924'
async up(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "user"."token" IS 'The native access token of local users, or null.'`);
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_71d35fceee0d0fa62b2fa8f3b2" ON "note" ("url") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_d9ecaed8c6dc43f3592c229282"`);
await queryRunner.query(`DROP INDEX "public"."IDX_71d35fceee0d0fa62b2fa8f3b2"`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66" UNIQUE ("accessTokenId")`);
await queryRunner.query(`COMMENT ON COLUMN "user"."token" IS 'The native access token of the User. It will be null if the origin of the user is local.'`);
}
}

View File

@ -50,8 +50,6 @@ import { UserSecurityKey } from '@/models/entities/user-security-key.js';
import { AttestationChallenge } from '@/models/entities/attestation-challenge.js';
import { Page } from '@/models/entities/page.js';
import { PageLike } from '@/models/entities/page-like.js';
import { GalleryPost } from '@/models/entities/gallery-post.js';
import { GalleryLike } from '@/models/entities/gallery-like.js';
import { ModerationLog } from '@/models/entities/moderation-log.js';
import { UsedUsername } from '@/models/entities/used-username.js';
import { Announcement } from '@/models/entities/announcement.js';
@ -143,8 +141,6 @@ export const entities = [
NoteUnread,
Page,
PageLike,
GalleryPost,
GalleryLike,
DriveFile,
DriveFolder,
Poll,

View File

@ -27,9 +27,5 @@ export const kinds = [
'write:user-groups',
'read:channels',
'write:channels',
'read:gallery',
'write:gallery',
'read:gallery-likes',
'write:gallery-likes',
];
// IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions).

View File

@ -28,7 +28,6 @@ import { packedAntennaSchema } from '@/models/schema/antenna.js';
import { packedClipSchema } from '@/models/schema/clip.js';
import { packedFederationInstanceSchema } from '@/models/schema/federation-instance.js';
import { packedQueueCountSchema } from '@/models/schema/queue.js';
import { packedGalleryPostSchema } from '@/models/schema/gallery-post.js';
import { packedEmojiSchema } from '@/models/schema/emoji.js';
export const refs = {
@ -61,7 +60,6 @@ export const refs = {
Antenna: packedAntennaSchema,
Clip: packedClipSchema,
FederationInstance: packedFederationInstanceSchema,
GalleryPost: packedGalleryPostSchema,
Emoji: packedEmojiSchema,
};

View File

@ -79,7 +79,6 @@ export class AccessToken {
@Column('varchar', {
length: 64, array: true,
default: '{}',
})
public permission: string[];

View File

@ -1,33 +0,0 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './user.js';
import { GalleryPost } from './gallery-post.js';
@Entity()
@Index(['userId', 'postId'], { unique: true })
export class GalleryLike {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index()
@Column(id())
public userId: User['id'];
@ManyToOne(() => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Column(id())
public postId: GalleryPost['id'];
@ManyToOne(() => GalleryPost, {
onDelete: 'CASCADE',
})
@JoinColumn()
public post: GalleryPost | null;
}

View File

@ -1,79 +0,0 @@
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './user.js';
import { DriveFile } from './drive-file.js';
@Entity()
export class GalleryPost {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the GalleryPost.',
})
public createdAt: Date;
@Index()
@Column('timestamp with time zone', {
comment: 'The updated date of the GalleryPost.',
})
public updatedAt: Date;
@Column('varchar', {
length: 256,
})
public title: string;
@Column('varchar', {
length: 2048, nullable: true,
})
public description: string | null;
@Index()
@Column({
...id(),
comment: 'The ID of author.',
})
public userId: User['id'];
@ManyToOne(() => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Index()
@Column({
...id(),
array: true, default: '{}',
})
public fileIds: DriveFile['id'][];
@Index()
@Column('boolean', {
default: false,
comment: 'Whether the post is sensitive.',
})
public isSensitive: boolean;
@Index()
@Column('integer', {
default: 0,
})
public likedCount: number;
@Index()
@Column('varchar', {
length: 128, array: true, default: '{}',
})
public tags: string[];
constructor(data: Partial<GalleryPost>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}

View File

@ -117,6 +117,7 @@ export class Note {
})
public uri: string | null;
@Index({ unique: true })
@Column('varchar', {
length: 512, nullable: true,
comment: 'The human readable url of a note. it will be null when the note is local.',

View File

@ -42,8 +42,6 @@ import { UserSecurityKey } from './entities/user-security-key.js';
import { HashtagRepository } from './repositories/hashtag.js';
import { PageRepository } from './repositories/page.js';
import { PageLikeRepository } from './repositories/page-like.js';
import { GalleryPostRepository } from './repositories/gallery-post.js';
import { GalleryLikeRepository } from './repositories/gallery-like.js';
import { ModerationLogRepository } from './repositories/moderation-logs.js';
import { UsedUsername } from './entities/used-username.js';
import { ClipRepository } from './repositories/clip.js';
@ -108,8 +106,6 @@ export const Signins = (SigninRepository);
export const MessagingMessages = (MessagingMessageRepository);
export const Pages = (PageRepository);
export const PageLikes = (PageLikeRepository);
export const GalleryPosts = (GalleryPostRepository);
export const GalleryLikes = (GalleryLikeRepository);
export const ModerationLogs = (ModerationLogRepository);
export const Clips = (ClipRepository);
export const ClipNotes = db.getRepository(ClipNote);

View File

@ -108,9 +108,9 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
folderId: file.folderId,
folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, {
detail: true,
}) : null,
userId: opts.withUser ? file.userId : null,
user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null,
}) : undefined,
userId: file.userId,
user: (opts.withUser && file.userId) ? Users.pack(file.userId) : undefined,
});
},

View File

@ -1,24 +0,0 @@
import { db } from '@/db/postgre.js';
import { GalleryLike } from '@/models/entities/gallery-like.js';
import { GalleryPosts } from '../index.js';
export const GalleryLikeRepository = db.getRepository(GalleryLike).extend({
async pack(
src: GalleryLike['id'] | GalleryLike,
me?: any,
) {
const like = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src });
return {
id: like.id,
post: await GalleryPosts.pack(like.post || like.postId, me),
};
},
packMany(
likes: any[],
me: any,
) {
return Promise.all(likes.map(x => this.pack(x, me)));
},
});

View File

@ -1,39 +0,0 @@
import { db } from '@/db/postgre.js';
import { Packed } from '@/misc/schema.js';
import { GalleryPost } from '@/models/entities/gallery-post.js';
import { User } from '@/models/entities/user.js';
import { awaitAll } from '@/prelude/await-all.js';
import { Users, DriveFiles, GalleryLikes } from '../index.js';
export const GalleryPostRepository = db.getRepository(GalleryPost).extend({
async pack(
src: GalleryPost['id'] | GalleryPost,
me?: { id: User['id'] } | null | undefined,
): Promise<Packed<'GalleryPost'>> {
const meId = me ? me.id : null;
const post = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src });
return await awaitAll({
id: post.id,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
userId: post.userId,
user: Users.pack(post.user || post.userId, me),
title: post.title,
description: post.description,
fileIds: post.fileIds,
files: DriveFiles.packMany(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive,
likedCount: post.likedCount,
isLiked: meId ? await GalleryLikes.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined,
});
},
packMany(
posts: GalleryPost[],
me?: { id: User['id'] } | null | undefined,
) {
return Promise.all(posts.map(x => this.pack(x, me)));
},
});

View File

@ -1,69 +0,0 @@
export const packedGalleryPostSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
title: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: true,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
fileIds: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
files: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'DriveFile',
},
},
tags: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
isSensitive: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View File

@ -62,9 +62,11 @@ export class DbResolver {
id: parsed.id,
});
} else {
return await Notes.findOneBy({
return await Notes.findOneBy([{
uri: parsed.uri,
});
}, {
url: parsed.uri,
}]);
}
}

View File

@ -39,11 +39,11 @@ export function extractQuoteUrl(tags: IObject | IObject[] | null | undefined): s
'https://www.w3.org/ns/activitystreams#quoteUrl',
].includes(rel)
)
);
)
// Deduplicate by href.
.filter((x, i, arr) => arr.findIndex(y => x.href === y.href) === i);
if (quotes.length === 0) return null;
// Deduplicate by href.
// If there is more than one quote, we just pick the first/a random one.
quotes.filter((x, i, arr) => arr.findIndex(y => x.href === y.href) === i)[0].href;
else return quotes[0].href;
}

View File

@ -30,12 +30,21 @@ export async function renderPerson(user: ILocalUser) {
if (profile.fields) {
for (const field of profile.fields) {
let value = field.value;
// try to parse it as a url
try {
if (field.value?.match(/^https?:/)) {
const url = new URL(field.value);
value = `<a href="${url.href}" rel="me nofollow noopener" target="_blank">${url.href}</a>`;
}
} catch {
// guess it wasn't a url after all...
}
attachment.push({
type: 'PropertyValue',
name: field.name,
value: (field.value != null && field.value.match(/^https?:/))
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
: field.value,
value,
});
}
}

View File

@ -35,10 +35,8 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
limit.key = ep.name;
}
// Rate limit
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(() => {
throw new ApiError('RATE_LIMIT_EXCEEDED');
});
// Rate limit, may throw an ApiError
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor);
}
if (ep.meta.requireCredential && user == null) {

View File

@ -138,15 +138,6 @@ import * as ep___following_requests_accept from './endpoints/following/requests/
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
import * as ep___gallery_posts from './endpoints/gallery/posts.js';
import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js';
import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js';
import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js';
import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js';
import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js';
import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js';
import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js';
import * as ep___hashtags_list from './endpoints/hashtags/list.js';
import * as ep___hashtags_search from './endpoints/hashtags/search.js';
@ -171,8 +162,6 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
@ -276,7 +265,6 @@ import * as ep___users from './endpoints/users.js';
import * as ep___users_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_groups_create from './endpoints/users/groups/create.js';
import * as ep___users_groups_delete from './endpoints/users/groups/delete.js';
import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js';
@ -446,15 +434,6 @@ const eps = [
['following/requests/cancel', ep___following_requests_cancel],
['following/requests/list', ep___following_requests_list],
['following/requests/reject', ep___following_requests_reject],
['gallery/featured', ep___gallery_featured],
['gallery/popular', ep___gallery_popular],
['gallery/posts', ep___gallery_posts],
['gallery/posts/create', ep___gallery_posts_create],
['gallery/posts/delete', ep___gallery_posts_delete],
['gallery/posts/like', ep___gallery_posts_like],
['gallery/posts/show', ep___gallery_posts_show],
['gallery/posts/unlike', ep___gallery_posts_unlike],
['gallery/posts/update', ep___gallery_posts_update],
['get-online-users-count', ep___getOnlineUsersCount],
['hashtags/list', ep___hashtags_list],
['hashtags/search', ep___hashtags_search],
@ -479,8 +458,6 @@ const eps = [
['i/export-notes', ep___i_exportNotes],
['i/export-user-lists', ep___i_exportUserLists],
['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts],
['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
['i/import-blocking', ep___i_importBlocking],
['i/import-following', ep___i_importFollowing],
@ -584,7 +561,6 @@ const eps = [
['users/clips', ep___users_clips],
['users/followers', ep___users_followers],
['users/following', ep___users_following],
['users/gallery/posts', ep___users_gallery_posts],
['users/groups/create', ep___users_groups_create],
['users/groups/delete', ep___users_groups_delete],
['users/groups/invitations/accept', ep___users_groups_invitations_accept],
@ -717,6 +693,14 @@ export interface IEndpointMeta {
* @example (v0) /api/notes/create -> /api/v2/notes
*/
readonly alias?: string;
/**
* If any path parameters were used, they have to be listed here.
* Otherwise they will show up as query parameters in the documentation.
*
* Note: Path parameters cannot be optional.
*/
readonly pathParamers?: string[];
};
}

View File

@ -7,6 +7,8 @@ export const meta = {
requireCredential: true,
description: 'Tries to fetch the given `uri` from the remote server.',
limit: {
duration: HOUR,
max: 30,

View File

@ -18,6 +18,8 @@ export const meta = {
requireCredential: true,
description: 'Shows the requested object. If necessary, fetches the object from the remote server.',
limit: {
duration: HOUR,
max: 30,

View File

@ -1,37 +0,0 @@
import { DAY } from '@/const.js';
import { GalleryPosts } from '@/models/index.js';
import define from '../../define.js';
export const meta = {
tags: ['gallery'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const query = GalleryPosts.createQueryBuilder('post')
.andWhere('post.createdAt > :date', { date: new Date(Date.now() - 3 * DAY) })
.andWhere('post.likedCount > 0')
.orderBy('post.likedCount', 'DESC');
const posts = await query.take(10).getMany();
return await GalleryPosts.packMany(posts, me);
});

View File

@ -1,35 +0,0 @@
import { GalleryPosts } from '@/models/index.js';
import define from '../../define.js';
export const meta = {
tags: ['gallery'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const query = GalleryPosts.createQueryBuilder('post')
.andWhere('post.likedCount > 0')
.orderBy('post.likedCount', 'DESC');
const posts = await query.take(10).getMany();
return await GalleryPosts.packMany(posts, me);
});

View File

@ -1,37 +0,0 @@
import { GalleryPosts } from '@/models/index.js';
import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
export const meta = {
tags: ['gallery'],
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
.innerJoinAndSelect('post.user', 'user');
const posts = await query.take(ps.limit).getMany();
return await GalleryPosts.packMany(posts, me);
});

View File

@ -1,72 +0,0 @@
import { DriveFiles, GalleryPosts } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { GalleryPost } from '@/models/entities/gallery-post.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { HOUR } from '@/const.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js';
export const meta = {
tags: ['gallery'],
requireCredential: true,
kind: 'write:gallery',
limit: {
duration: HOUR,
max: 300,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
} as const;
export const paramDef = {
type: 'object',
properties: {
title: { type: 'string', minLength: 1 },
description: { type: 'string', nullable: true },
fileIds: { type: 'array', uniqueItems: true, minItems: 1, maxItems: 32, items: {
type: 'string', format: 'misskey:id',
} },
isSensitive: { type: 'boolean', default: false },
},
required: ['title', 'fileIds'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const files = (await Promise.all(ps.fileIds.map(fileId =>
DriveFiles.findOneBy({
id: fileId,
userId: user.id,
}),
))).filter((file): file is DriveFile => file != null);
if (files.length !== ps.fileIds.length) {
throw new ApiError(
'INVALID_PARAM',
{
param: '#/properties/fileIds/items',
reason: 'contains invalid file IDs',
},
);
}
const post = await GalleryPosts.insert(new GalleryPost({
id: genId(),
createdAt: new Date(),
updatedAt: new Date(),
title: ps.title,
description: ps.description,
userId: user.id,
isSensitive: ps.isSensitive,
fileIds: files.map(file => file.id),
})).then(x => GalleryPosts.findOneByOrFail(x.identifiers[0]));
return await GalleryPosts.pack(post, user);
});

View File

@ -1,33 +0,0 @@
import { GalleryPosts } from '@/models/index.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['gallery'],
requireCredential: true,
kind: 'write:gallery',
errors: ['NO_SUCH_POST'],
} as const;
export const paramDef = {
type: 'object',
properties: {
postId: { type: 'string', format: 'misskey:id' },
},
required: ['postId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const post = await GalleryPosts.findOneBy({
id: ps.postId,
userId: user.id,
});
if (post == null) throw new ApiError('NO_SUCH_POST');
await GalleryPosts.delete(post.id);
});

View File

@ -1,46 +0,0 @@
import { GalleryPosts, GalleryLikes } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['gallery'],
requireCredential: true,
kind: 'write:gallery-likes',
errors: ['NO_SUCH_POST', 'ALREADY_LIKED'],
} as const;
export const paramDef = {
type: 'object',
properties: {
postId: { type: 'string', format: 'misskey:id' },
},
required: ['postId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const post = await GalleryPosts.findOneBy({ id: ps.postId });
if (post == null) throw new ApiError('NO_SUCH_POST');
// if already liked
const exist = await GalleryLikes.countBy({
postId: post.id,
userId: user.id,
});
if (exist) throw new ApiError('ALREADY_LIKED');
// Create like
await GalleryLikes.insert({
id: genId(),
createdAt: new Date(),
postId: post.id,
userId: user.id,
});
GalleryPosts.increment({ id: post.id }, 'likedCount', 1);
});

View File

@ -1,36 +0,0 @@
import { GalleryPosts } from '@/models/index.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['gallery'],
requireCredential: false,
errors: ['NO_SUCH_POST'],
res: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
} as const;
export const paramDef = {
type: 'object',
properties: {
postId: { type: 'string', format: 'misskey:id' },
},
required: ['postId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const post = await GalleryPosts.findOneBy({
id: ps.postId,
});
if (post == null) throw new ApiError('NO_SUCH_POST');
return await GalleryPosts.pack(post, me);
});

View File

@ -1,39 +0,0 @@
import { GalleryPosts, GalleryLikes } from '@/models/index.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['gallery'],
requireCredential: true,
kind: 'write:gallery-likes',
errors: ['NO_SUCH_POST', 'NOT_LIKED'],
} as const;
export const paramDef = {
type: 'object',
properties: {
postId: { type: 'string', format: 'misskey:id' },
},
required: ['postId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const post = await GalleryPosts.findOneBy({ id: ps.postId });
if (post == null) throw new ApiError('NO_SUCH_POST');
const exist = await GalleryLikes.findOneBy({
postId: post.id,
userId: user.id,
});
if (exist == null) throw new ApiError('NOT_LIKED');
// Delete like
await GalleryLikes.delete(exist.id);
GalleryPosts.decrement({ id: post.id }, 'likedCount', 1);
});

View File

@ -1,75 +0,0 @@
import { DriveFiles, GalleryPosts } from '@/models/index.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { HOUR } from '@/const.js';
import { ApiError } from '@/server/api/error.js';
import define from '../../../define.js';
export const meta = {
tags: ['gallery'],
requireCredential: true,
kind: 'write:gallery',
limit: {
duration: HOUR,
max: 300,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
errors: ['INVALID_PARAM'],
} as const;
export const paramDef = {
type: 'object',
properties: {
postId: { type: 'string', format: 'misskey:id' },
title: { type: 'string', minLength: 1 },
description: { type: 'string', nullable: true },
fileIds: { type: 'array', uniqueItems: true, minItems: 1, maxItems: 32, items: {
type: 'string', format: 'misskey:id',
} },
isSensitive: { type: 'boolean', default: false },
},
required: ['postId', 'title', 'fileIds'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const files = (await Promise.all(ps.fileIds.map(fileId =>
DriveFiles.findOneBy({
id: fileId,
userId: user.id,
}),
))).filter((file): file is DriveFile => file != null);
if (files.length !== ps.fileIds.length) {
throw new ApiError(
'INVALID_PARAM',
{
param: '#/properties/fileIds/items',
reason: 'contains invalid file IDs',
},
);
}
await GalleryPosts.update({
id: ps.postId,
userId: user.id,
}, {
updatedAt: new Date(),
title: ps.title,
description: ps.description,
isSensitive: ps.isSensitive,
fileIds: files.map(file => file.id),
});
const post = await GalleryPosts.findOneByOrFail({ id: ps.postId });
return await GalleryPosts.pack(post, user);
});

View File

@ -1,55 +0,0 @@
import { GalleryLikes } from '@/models/index.js';
import define from '../../../define.js';
import { makePaginationQuery } from '../../../common/make-pagination-query.js';
export const meta = {
tags: ['account', 'gallery'],
requireCredential: true,
kind: 'read:gallery-likes',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
post: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId)
.andWhere('like.userId = :meId', { meId: user.id })
.leftJoinAndSelect('like.post', 'post');
const likes = await query
.take(ps.limit)
.getMany();
return await GalleryLikes.packMany(likes, user);
});

View File

@ -1,43 +0,0 @@
import { GalleryPosts } from '@/models/index.js';
import define from '../../../define.js';
import { makePaginationQuery } from '../../../common/make-pagination-query.js';
export const meta = {
tags: ['account', 'gallery'],
requireCredential: true,
kind: 'read:gallery',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
.andWhere('post.userId = :meId', { meId: user.id });
const posts = await query
.take(ps.limit)
.getMany();
return await GalleryPosts.packMany(posts, user);
});

View File

@ -25,6 +25,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/children',
pathParameters: ['noteId'],
},
} as const;

View File

@ -22,6 +22,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/clips',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -22,6 +22,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/conversation',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -14,13 +14,15 @@ export const meta = {
limit: {
duration: HOUR,
max: 300,
minInterval: SECOND,
max: 30,
minInterval: 10 * SECOND,
key: 'delete',
},
v2: {
method: 'delete',
alias: 'notes/:noteId',
pathParameters: ['noteId'],
},
errors: ['ACCESS_DENIED', 'NO_SUCH_NOTE'],

View File

@ -25,7 +25,8 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/reactions/:type?',
alias: 'notes/:noteId/reactions',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -13,8 +13,9 @@ export const meta = {
limit: {
duration: HOUR,
max: 60,
minInterval: 3 * SECOND,
max: 30,
minInterval: 10 * SECOND,
key: 'delete',
},
errors: ['NO_SUCH_NOTE', 'NOT_REACTED'],

View File

@ -25,6 +25,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/renotes',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -25,6 +25,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/replies',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -17,6 +17,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -30,6 +30,7 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/status',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -57,7 +57,8 @@ export const meta = {
v2: {
method: 'get',
alias: 'notes/:noteId/translate/:targetLang/:sourceLang?',
alias: 'notes/:noteId/translate/:targetLang',
pathParameters: ['noteId', 'targetLang'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -14,13 +14,15 @@ export const meta = {
limit: {
duration: HOUR,
max: 300,
minInterval: SECOND,
max: 30,
minInterval: 10 * SECOND,
key: 'delete',
},
v2: {
method: 'delete',
alias: 'notes/:noteId/renotes',
pathParameters: ['noteId'],
},
errors: ['NO_SUCH_NOTE'],

View File

@ -1,42 +0,0 @@
import { GalleryPosts } from '@/models/index.js';
import define from '../../../define.js';
import { makePaginationQuery } from '../../../common/make-pagination-query.js';
export const meta = {
tags: ['users', 'gallery'],
description: 'Show all gallery posts by the given user.',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'GalleryPost',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
.andWhere('post.userId = :userId', { userId: ps.userId });
const posts = await query
.take(ps.limit)
.getMany();
return await GalleryPosts.packMany(posts, user);
});

View File

@ -29,8 +29,16 @@ export class ApiError extends Error {
*/
public apply(ctx: Koa.Context, endpoint: string): void {
ctx.status = this.httpStatusCode;
if (ctx.status === 401) {
ctx.response.set('WWW-Authenticate', 'Bearer');
// set additional headers
switch (ctx.status) {
case 401:
ctx.response.set('WWW-Authenticate', 'Bearer');
break;
case 429:
if (typeof this.info === 'object' && typeof this.info.reset === 'number') {
ctx.respose.set('Retry-After', Math.floor(this.info.reset - (Date.now() / 1000)));
}
break;
}
ctx.body = {
error: {
@ -73,7 +81,7 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
httpStatusCode: 409,
},
ALREADY_LIKED: {
message: 'You already liked that page or gallery post.',
message: 'You already liked that page.',
httpStatusCode: 409,
},
ALREADY_MUTING: {
@ -292,10 +300,6 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
message: 'No such parent folder.',
httpStatusCode: 404,
},
NO_SUCH_POST: {
message: 'No such gallery post.',
httpStatusCode: 404,
},
NO_SUCH_RESET_REQUEST: {
message: 'No such password reset request.',
httpStatusCode: 404,
@ -337,7 +341,7 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
httpStatusCode: 409,
},
NOT_LIKED: {
message: 'You have not liked that page or gallery post.',
message: 'You have not liked that page.',
httpStatusCode: 409,
},
NOT_MUTING: {

View File

@ -2,11 +2,12 @@ import Limiter from 'ratelimiter';
import Logger from '@/services/logger.js';
import { redisClient } from '@/db/redis.js';
import { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
const logger = new Logger('limiter');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
if (process.env.NODE_ENV === 'test') ok();
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((resolve) => {
if (process.env.NODE_ENV === 'test') resolve();
const hasShortTermLimit = typeof limitation.minInterval === 'number';
@ -19,10 +20,10 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
} else if (hasLongTermLimit) {
max();
} else {
ok();
resolve();
}
// Short-term limit
// Short-term limit, calls long term limit if appropriate.
function min(): void {
const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`,
@ -33,18 +34,19 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
minIntervalLimiter.get((err, info) => {
if (err) {
return reject('ERR');
logger.error(err);
throw new ApiError('INTERNAL_ERROR');
}
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL');
throw new ApiError('RATE_LIMIT_EXCEEDED', info);
} else {
if (hasLongTermLimit) {
max();
} else {
ok();
resolve();
}
}
});
@ -61,15 +63,16 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
limiter.get((err, info) => {
if (err) {
return reject('ERR');
logger.error(err);
throw new ApiError('INTERNAL_ERROR');
}
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED');
throw new ApiError('RATE_LIMIT_EXCEEDED', info);
} else {
ok();
resolve();
}
});
}

View File

@ -126,11 +126,21 @@ export function genOpenapiSpec() {
};
}
let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n';
let desc = endpoint.meta.description ?? 'No description provided.';
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
if (endpoint.meta.kind) {
const kind = endpoint.meta.kind;
desc += ` / **Permission**: *${kind}*`;
desc += '\n\n**Permission**: `' + endpoint.meta.kind + '`';
}
if (endpoint.meta.limit) {
const limit = endpoint.meta.limit;
desc += '\n### Rate limit\nRate limiting group: `' + (limit.key ?? endpoint.name) + '`';
if (limit.duration && limit.max) {
desc += ` \nNo more than ${limit.max} requests every ${limit.duration} ms.`;
}
if (limit.minInterval) {
desc += ` \nMinimum delay between each request: ${endpoint.meta.limit.minInterval} ms.`;
}
}
const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
@ -183,6 +193,7 @@ export function genOpenapiSpec() {
},
},
responses,
deprecated: endpoint.meta.stability === 'deprecated',
};
const path = {
@ -209,11 +220,37 @@ export function genOpenapiSpec() {
spec.paths['/' + endpoint.name] = path;
if (endpoint.meta.v2) {
const route = `/v2/${endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_')}`;
// we need a clone of the API endpoint info because otherwise we change it by reference
const infoClone = structuredClone(info);
const route = `/v2/${endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_')}`;
// fix the way parameters are passed
const hasBody = !(endpoint.meta.v2.method === 'get' || endpoint.meta.v2.method === 'delete');
if (!hasBody) {
// these methods do not (usually) have a body
delete infoClone.requestBody;
infoClone.parameters = [];
for (const name in schema.properties) {
infoClone.parameters.push({
name,
in: endpoint.meta.v2?.pathParameters?.includes(name) ? 'path' : 'query',
schema: schema.properties[name],
required: endpoint.meta.v2?.pathParameters?.includes(name) || schema.required?.includes(name) || false,
});
}
} else if (endpoint.meta.v2.pathParameters) {
for (const name in endpoint.meta.v2.pathParameters) {
delete infoClone.requestBody.content[requestType].schema.properties[name];
infoClone.parameters.push({
name,
in: 'path',
schema: schema.properties[name],
required: true,
});
}
}
infoClone['operationId'] = infoClone['summary'] = route;
infoClone['operationId'] = endpoint.meta.v2.method.toUpperCase() + '/' + route;
infoClone['summary'] = endpoint.meta.v2.method.toUpperCase() + ' ' + route;
spec.paths[route] = {
...spec.paths[route],

View File

@ -25,13 +25,8 @@ export default async (ctx: Koa.Context) => {
new ApiError(e, info).apply(ctx, 'signin');
}
try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
} catch (err) {
error('RATE_LIMIT_EXCEEDED');
return;
}
// not more than 1 attempt per second and not more than 10 attempts per hour
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
if (typeof username !== 'string') {
error('INVALID_PARAM', { param: 'username', reason: 'not a string' });

View File

@ -18,7 +18,7 @@ import { KoaAdapter } from '@bull-board/koa';
import { In, IsNull } from 'typeorm';
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 { Users, Notes, UserProfiles, Pages, Channels, Clips } from '@/models/index.js';
import * as Acct from '@/misc/acct.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import { queues } from '@/queue/queues.js';
@ -421,31 +421,6 @@ router.get('/clips/:clip', async (ctx, next) => {
await next();
});
// Gallery post
router.get('/gallery/:post', async (ctx, next) => {
const post = await GalleryPosts.findOneBy({ id: ctx.params.post });
if (post) {
const _post = await GalleryPosts.pack(post);
const profile = await UserProfiles.findOneByOrFail({ userId: post.userId });
const meta = await fetchMeta();
await ctx.render('gallery-post', {
post: _post,
profile,
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: post.userId })),
instanceName: meta.name || 'FoundKey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
});
ctx.set('Cache-Control', 'public, max-age=15');
return;
}
await next();
});
// Channel
router.get('/channels/:channel', async (ctx, next) => {
const channel = await Channels.findOneBy({

View File

@ -1,33 +0,0 @@
extends ./base
block vars
- const user = post.user;
- const title = post.title;
- const url = `${config.url}/gallery/${post.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= post.description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= post.description)
meta(property='og:url' content= url)
meta(property='og:image' content= post.files[0].thumbnailUrl)
block meta
if user.host || profile.noCrawle
meta(name='robots' content='noindex')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)
if !user.host
link(rel='alternate' href=url type='application/activity+json')

View File

@ -1,113 +0,0 @@
<template>
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
<div class="thumbnail">
<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
</div>
<article>
<header>
<MkAvatar :user="post.user" class="avatar"/>
</header>
<footer>
<span class="title">{{ post.title }}</span>
</footer>
</article>
</MkA>
</template>
<script lang="ts" setup>
import * as foundkey from 'foundkey-js';
import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
defineProps<{
post: foundkey.entities.GalleryPost;
}>();
</script>
<style lang="scss" scoped>
.ttasepnz {
display: block;
position: relative;
height: 200px;
&:hover {
text-decoration: none;
color: var(--accent);
> .thumbnail {
transform: scale(1.1);
}
> article {
> footer {
&:before {
opacity: 1;
}
}
}
}
> .thumbnail {
width: 100%;
height: 100%;
position: absolute;
transition: all 0.5s ease;
> .img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
> article {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
> header {
position: absolute;
top: 0;
width: 100%;
padding: 12px;
box-sizing: border-box;
display: flex;
> .avatar {
margin-left: auto;
width: 32px;
height: 32px;
}
}
> footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 16px;
box-sizing: border-box;
color: #fff;
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
&:before {
content: "";
display: block;
position: absolute;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
opacity: 0;
transition: opacity 0.5s ease;
}
> .title {
font-weight: bold;
}
}
}
}
</style>

View File

@ -31,10 +31,6 @@ export default defineComponent({
type: Object,
default: null,
},
i: {
type: Object,
default: null,
},
customEmojis: {
required: false,
},

View File

@ -131,11 +131,6 @@ export const menuDef = reactive({
icon: 'fas fa-file-alt',
to: '/pages',
},
gallery: {
title: 'gallery',
icon: 'fas fa-icons',
to: '/gallery',
},
clips: {
title: 'clip',
icon: 'fas fa-paperclip',

View File

@ -1,153 +0,0 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<FormInput v-model="title">
<template #label>{{ i18n.ts.title }}</template>
</FormInput>
<FormTextarea v-model="description" :max="500">
<template #label>{{ i18n.ts.description }}</template>
</FormTextarea>
<div class="">
<div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
<div class="name">{{ file.name }}</div>
<button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button>
</div>
<MkButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ i18n.ts.attachFile }}</MkButton>
</div>
<FormSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</FormSwitch>
<MkButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-else primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.publish }}</MkButton>
<MkButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</MkButton>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, inject, watch } from 'vue';
import MkButton from '@/components/ui/button.vue';
import FormInput from '@/components/form/input.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormSuspense from '@/components/form/suspense.vue';
import { selectFiles } from '@/scripts/select-file';
import * as os from '@/os';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
const router = useRouter();
const props = defineProps<{
postId?: string;
}>();
let init = $ref(null);
let files = $ref([]);
let description = $ref(null);
let title = $ref(null);
let isSensitive = $ref(false);
function selectFile(evt) {
selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
files = files.concat(selected);
});
}
function remove(file) {
files = files.filter(f => f.id !== file.id);
}
async function save() {
if (files.length === 0) {
os.alert({
type: 'error',
text: i18n.ts.attachmentRequired,
});
return;
}
if (props.postId) {
await os.apiWithDialog('gallery/posts/update', {
postId: props.postId,
title,
description,
fileIds: files.map(file => file.id),
isSensitive,
});
router.push(`/gallery/${props.postId}`);
} else {
const created = await os.apiWithDialog('gallery/posts/create', {
title,
description,
fileIds: files.map(file => file.id),
isSensitive,
});
router.push(`/gallery/${created.id}`);
}
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
});
if (canceled) return;
await os.apiWithDialog('gallery/posts/delete', {
postId: props.postId,
});
router.push('/gallery');
}
watch(() => props.postId, () => {
init = () => props.postId ? os.api('gallery/posts/show', {
postId: props.postId,
}).then(post => {
files = post.files;
title = post.title;
description = post.description;
isSensitive = post.isSensitive;
}) : Promise.resolve(null);
}, { immediate: true });
definePageMetadata(computed(() => props.postId ? {
title: i18n.ts.edit,
icon: 'fas fa-pencil-alt',
} : {
title: i18n.ts.postToGallery,
icon: 'fas fa-pencil-alt',
}));
</script>
<style lang="scss" scoped>
.wqugxsfx {
height: 200px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
position: relative;
> .name {
position: absolute;
top: 8px;
left: 9px;
padding: 8px;
background: var(--panel);
}
> .remove {
position: absolute;
top: 8px;
right: 9px;
padding: 8px;
background: var(--panel);
}
}
</style>

View File

@ -1,137 +0,0 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="1400">
<div class="_root">
<div v-if="tab === 'explore'">
<MkFolder class="_gap">
<template #header><i class="fas fa-clock"></i>{{ i18n.ts.recentPosts }}</template>
<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
<div class="vfpdbgtk">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
</MkFolder>
<MkFolder class="_gap">
<template #header><i class="fas fa-fire-alt"></i>{{ i18n.ts.popularPosts }}</template>
<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
<div class="vfpdbgtk">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
</MkFolder>
</div>
<div v-else-if="tab === 'liked'">
<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
<div class="vfpdbgtk">
<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
</div>
</MkPagination>
</div>
<div v-else-if="tab === 'my'">
<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ i18n.ts.postToGallery }}</MkA>
<MkPagination v-slot="{items}" :pagination="myPostsPagination">
<div class="vfpdbgtk">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, defineComponent, watch } from 'vue';
import XUserList from '@/components/user-list.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkButton from '@/components/ui/button.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
import number from '@/filters/number';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import { useRouter } from '@/router';
const router = useRouter();
const props = defineProps<{
tag?: string;
}>();
let tab = $ref('explore');
let tags = $ref([]);
let tagsRef = $ref();
const recentPostsPagination = {
endpoint: 'gallery/posts' as const,
limit: 6,
};
const popularPostsPagination = {
endpoint: 'gallery/featured' as const,
limit: 5,
};
const myPostsPagination = {
endpoint: 'i/gallery/posts' as const,
limit: 5,
};
const likedPostsPagination = {
endpoint: 'i/gallery/likes' as const,
limit: 5,
};
const tagUsersPagination = $computed(() => ({
endpoint: 'hashtags/users' as const,
limit: 30,
params: {
tag: this.tag,
origin: 'combined',
sort: '+follower',
},
}));
watch(() => props.tag, () => {
if (tagsRef) tagsRef.tags.toggleContent(props.tag == null);
});
const headerActions = $computed(() => [{
icon: 'fas fa-plus',
text: i18n.ts.create,
handler: () => {
router.push('/gallery/new');
},
}]);
const headerTabs = $computed(() => [{
key: 'explore',
title: i18n.ts.gallery,
icon: 'fas fa-icons',
}, {
key: 'liked',
title: i18n.ts._gallery.liked,
icon: 'fas fa-heart',
}, {
key: 'my',
title: i18n.ts._gallery.my,
icon: 'fas fa-edit',
}]);
definePageMetadata({
title: i18n.ts.gallery,
icon: 'fas fa-icons',
});
</script>
<style lang="scss" scoped>
.vfpdbgtk {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: 12px;
margin: 0 var(--margin);
> .post {
}
}
</style>

View File

@ -1,264 +0,0 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions"/></template>
<MkSpacer :content-max="1000" :margin-min="16" :margin-max="32">
<div class="_root">
<transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="post" class="rkxwuolj">
<div class="files">
<div v-for="file in post.files" :key="file.id" class="file">
<img :src="file.url"/>
</div>
</div>
<div class="body _block">
<div class="title">{{ post.title }}</div>
<div class="description"><Mfm :text="post.description"/></div>
<div class="info">
<i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
</div>
<div class="actions">
<div class="like">
<MkButton v-if="post.isLiked" v-tooltip="i18n.ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
</div>
<div class="other">
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button>
<button v-tooltip="i18n.ts.shareWithNote" class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button>
<button v-tooltip="i18n.ts.share" class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button>
</div>
</div>
<div class="user">
<MkAvatar :user="post.user" class="avatar"/>
<div class="name">
<MkUserName :user="post.user" style="display: block;"/>
<MkAcct :user="post.user"/>
</div>
<MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
</div>
</div>
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
<MkContainer :max-height="300" :foldable="true" class="other">
<template #header><i class="fas fa-clock"></i> {{ i18n.ts.recentPosts }}</template>
<MkPagination v-slot="{items}" :pagination="otherPostsPagination">
<div class="sdrarzaf">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
</MkContainer>
</div>
<MkError v-else-if="error" @retry="fetch()"/>
<MkLoading v-else/>
</transition>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
import MkContainer from '@/components/ui/container.vue';
import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
import MkFollowButton from '@/components/follow-button.vue';
import { url } from '@/config';
import { useRouter } from '@/router';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { defaultStore } from '@/store';
const router = useRouter();
const props = defineProps<{
postId: string;
}>();
let post = $ref(null);
let error = $ref(null);
const otherPostsPagination = {
endpoint: 'users/gallery/posts' as const,
limit: 6,
params: computed(() => ({
userId: post.user.id,
})),
};
function fetchPost() {
post = null;
os.api('gallery/posts/show', {
postId: props.postId,
}).then(_post => {
post = _post;
}).catch(_error => {
error = _error;
});
}
function share() {
navigator.share({
title: post.title,
text: post.description,
url: `${url}/gallery/${post.id}`,
});
}
function shareWithNote() {
os.post({
initialText: `${post.title} ${url}/gallery/${post.id}`,
});
}
function like() {
os.apiWithDialog('gallery/posts/like', {
postId: props.postId,
}).then(() => {
post.isLiked = true;
post.likedCount++;
});
}
async function unlike() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unlikeConfirm,
});
if (confirm.canceled) return;
os.apiWithDialog('gallery/posts/unlike', {
postId: props.postId,
}).then(() => {
post.isLiked = false;
post.likedCount--;
});
}
function edit() {
router.push(`/gallery/${post.id}/edit`);
}
watch(() => props.postId, fetchPost, { immediate: true });
const headerActions = $computed(() => [{
icon: 'fas fa-pencil-alt',
text: i18n.ts.edit,
handler: edit,
}]);
definePageMetadata(computed(() => post ? {
title: post.title,
avatar: post.user,
} : null));
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.rkxwuolj {
> .files {
> .file {
> img {
display: block;
max-width: 100%;
max-height: 500px;
margin: 0 auto;
}
& + .file {
margin-top: 16px;
}
}
}
> .body {
padding: 32px;
> .title {
font-weight: bold;
font-size: 1.2em;
margin-bottom: 16px;
}
> .info {
margin-top: 16px;
font-size: 90%;
opacity: 0.7;
}
> .actions {
display: flex;
align-items: center;
margin-top: 16px;
padding: 16px 0 0 0;
border-top: solid 0.5px var(--divider);
> .like {
> .button {
--accent: rgb(241 97 132);
--X8: rgb(241 92 128);
--buttonBg: rgb(216 71 106 / 5%);
--buttonHoverBg: rgb(216 71 106 / 10%);
color: #ff002f;
::v-deep(.count) {
margin-left: 0.5em;
}
}
}
> .other {
margin-left: auto;
> button {
padding: 8px;
margin: 0 8px;
&:hover {
color: var(--fgHighlighted);
}
}
}
}
> .user {
margin-top: 16px;
padding: 16px 0 0 0;
border-top: solid 0.5px var(--divider);
display: flex;
align-items: center;
> .avatar {
width: 52px;
height: 52px;
}
> .name {
margin: 0 0 0 12px;
font-size: 90%;
}
> .koudoku {
margin-left: auto;
}
}
}
}
.sdrarzaf {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: 12px;
margin: var(--margin);
> .post {
}
}
</style>

View File

@ -1,38 +0,0 @@
<template>
<div>
<MkPagination v-slot="{items}" :pagination="pagination">
<div class="jrnovfpt">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import * as foundkey from 'foundkey-js';
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
import MkPagination from '@/components/ui/pagination.vue';
const props = withDefaults(defineProps<{
user: foundkey.entities.User;
}>(), {
});
const pagination = {
endpoint: 'users/gallery/posts' as const,
limit: 6,
params: computed(() => ({
userId: props.user.id,
})),
};
</script>
<style lang="scss" scoped>
.jrnovfpt {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: 12px;
margin: var(--margin);
}
</style>

View File

@ -8,7 +8,6 @@
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/>
<XPages v-else-if="tab === 'pages'" :user="user"/>
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
</div>
<MkError v-else-if="error" @retry="fetchUser()"/>
<MkLoading v-else/>
@ -33,7 +32,6 @@ const XHome = defineAsyncComponent(() => import('./home.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
const XClips = defineAsyncComponent(() => import('./clips.vue'));
const XPages = defineAsyncComponent(() => import('./pages.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
const props = withDefaults(defineProps<{
acct: string;
@ -82,10 +80,6 @@ const headerTabs = $computed(() => [{
key: 'pages',
title: i18n.ts.pages,
icon: 'fas fa-file-alt',
}, {
key: 'gallery',
title: i18n.ts.gallery,
icon: 'fas fa-icons',
}]);
definePageMetadata(computed(() => user ? {

View File

@ -123,20 +123,6 @@ export const routes = [{
}, {
path: '/pages',
component: page(() => import('./pages/pages.vue')),
}, {
path: '/gallery/:postId/edit',
component: page(() => import('./pages/gallery/edit.vue')),
loginRequired: true,
}, {
path: '/gallery/new',
component: page(() => import('./pages/gallery/edit.vue')),
loginRequired: true,
}, {
path: '/gallery/:postId',
component: page(() => import('./pages/gallery/post.vue')),
}, {
path: '/gallery',
component: page(() => import('./pages/gallery/index.vue')),
}, {
path: '/channels/:channelId/edit',
component: page(() => import('./pages/channel-editor.vue')),

View File

@ -33,14 +33,6 @@ const dev: Ref<boolean> = ref(_DEV_);
const onNotification = (notification: { type: string; id: any; }): void => {
if ($i?.mutingNotificationTypes.includes(notification.type)) return;
// if push notifications are enabled there is no need to pass the notification along
if (!instance.enableServiceWorker) {
// service worker is not enabled or set up on the server, pass the notification along
navigator.serviceWorker.ready.then(registration => {
registration.active.postMessage({ type: 'notification', body: notification });
});
}
sound.play('notification');
};

View File

@ -1,5 +1,5 @@
import {
Ad, Announcement, Antenna, App, AuthSession, Blocking, Channel, Clip, DateString, DetailedInstanceMetadata, DriveFile, DriveFolder, Following, FollowingFolloweePopulated, FollowingFollowerPopulated, FollowRequest, GalleryPost, Instance, InstanceMetadata,
Ad, Announcement, Antenna, App, AuthSession, Blocking, Channel, Clip, DateString, DetailedInstanceMetadata, DriveFile, DriveFolder, Following, FollowingFolloweePopulated, FollowingFollowerPopulated, FollowRequest, Instance, InstanceMetadata,
LiteInstanceMetadata,
MeDetailed,
Note, NoteFavorite, OriginType, Page, ServerInfo, Stats, User, UserDetailed, UserGroup, UserList, UserSorting, Notification, NoteReaction, Signin, MessagingMessage,
@ -282,15 +282,6 @@ export type Endpoints = {
'following/requests/cancel': { req: { userId: User['id'] }; res: User; };
'following/requests/list': { req: NoParams; res: FollowRequest[]; };
'following/requests/reject': { req: { userId: User['id'] }; res: null; };
'gallery/featured': { req: TODO; res: TODO; };
'gallery/popular': { req: TODO; res: TODO; };
'gallery/posts': { req: TODO; res: TODO; };
'gallery/posts/create': { req: TODO; res: TODO; };
'gallery/posts/delete': { req: { postId: GalleryPost['id'] }; res: null; };
'gallery/posts/like': { req: TODO; res: TODO; };
'gallery/posts/show': { req: TODO; res: TODO; };
'gallery/posts/unlike': { req: TODO; res: TODO; };
'gallery/posts/update': { req: TODO; res: TODO; };
'get-online-users-count': { req: TODO; res: TODO; };
'hashtags/list': { req: TODO; res: TODO; };
'hashtags/search': { req: TODO; res: TODO; };
@ -315,8 +306,6 @@ export type Endpoints = {
'i/export-notes': { req: TODO; res: TODO; };
'i/export-user-lists': { req: TODO; res: TODO; };
'i/favorites': { req: { limit?: number; sinceId?: NoteFavorite['id']; untilId?: NoteFavorite['id']; }; res: NoteFavorite[]; };
'i/gallery/likes': { req: TODO; res: TODO; };
'i/gallery/posts': { req: TODO; res: TODO; };
'i/get-word-muted-notes-count': { req: TODO; res: TODO; };
'i/import-blocking': { req: TODO; res: TODO; };
'i/import-following': { req: TODO; res: TODO; };
@ -488,7 +477,6 @@ export type Endpoints = {
'users/clips': { req: TODO; res: TODO; };
'users/followers': { req: { userId?: User['id']; username?: User['username']; host?: User['host'] | null; limit?: number; sinceId?: Following['id']; untilId?: Following['id']; }; res: FollowingFollowerPopulated[]; };
'users/following': { req: { userId?: User['id']; username?: User['username']; host?: User['host'] | null; limit?: number; sinceId?: Following['id']; untilId?: Following['id']; }; res: FollowingFolloweePopulated[]; };
'users/gallery/posts': { req: TODO; res: TODO; };
'users/groups/create': { req: TODO; res: TODO; };
'users/groups/delete': { req: { groupId: UserGroup['id'] }; res: null; };
'users/groups/invitations/accept': { req: TODO; res: TODO; };

View File

@ -35,8 +35,4 @@ export const permissions = [
'write:user-groups',
'read:channels',
'write:channels',
'read:gallery',
'write:gallery',
'read:gallery-likes',
'write:gallery-likes',
];

View File

@ -126,8 +126,6 @@ export type DriveFile = {
export type DriveFolder = TODO;
export type GalleryPost = TODO;
export type Note = {
id: ID;
createdAt: DateString;

View File

@ -164,9 +164,6 @@ self.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message'
case 'initialize':
swLang.setLang(ev.data.lang);
break;
case 'notification':
createNotification(ev.data);
break;
}
}
})());