Compare commits

...

49 commits

Author SHA1 Message Date
e4890de172 Merge branch 'main' into mk.absturztau.be 2023-02-05 09:03:59 +01:00
3489c8ac3a
fix: loading config 2023-02-04 23:24:05 +01:00
06ef752218
adjust readme 2023-02-04 23:00:34 +01:00
44f02fa3ec
update documents for new release 2023-02-04 22:22:00 +01:00
d655bda30c
add foundkey floofer 2023-02-04 22:15:28 +01:00
839daea887
remove mi-white.png asset 2023-02-04 18:08:19 +01:00
41c42f96f0
BREAKING server: disable deliver rate limit by default
The deliver rate limit seems to cause a lot of performance problems,
presumably because of the overhead the rate limit has. It also does
not really make sense to rate limit outgoing because we are requesting
from different servers anyway.

fixes FoundKeyGang/FoundKey#190

Changelog: Changed
2023-02-04 17:57:52 +01:00
9a6bb8be7d
server: default config items on load 2023-02-04 17:56:15 +01:00
1adf88b090
fixup: OpenGraph data generation
This is a fixup for commits 39fb7e5946 and be30e70344.
2023-02-04 16:44:30 +01:00
28c11ca7af
refactor isPureRenote to foundkey-js 2023-02-04 16:42:36 +01:00
9458045c8f
server: refactor note/renote rendering to separate file 2023-02-04 15:32:25 +01:00
a8c0e1f827
fix migration for note.url unique index
fixes FoundKeyGang/FoundKey#331

Co-authored-by: Johann150 <johann.galle@protonmail.com>
2023-02-04 11:03:29 +01:00
63665e8bd1
client: replace array concat with Array.prototype.flat 2023-02-04 00:33:23 +01:00
85a68a5eee
activitypub: properly render CW only quotes
Changelog: Fixed
2023-02-04 00:27:43 +01:00
0bb4a6af50
client: fix quotes with only a CW
Changelog: Fixed
2023-02-04 00:22:52 +01:00
a45908c1cb
client: check quoteId for canPost computation
fixes FoundKeyGang/FoundKey#334

Changelog: Fixed
2023-02-03 23:12:12 +01:00
ca257d7d0c
server: remove application level websocket ping
Changelog: Removed
2023-02-03 11:48:46 +01:00
30c26abde7
server: add websocket ping mechanism
fixes FoundKeyGang/FoundKey#336

Changelog: Fixed
2023-02-03 11:47:54 +01:00
17324e1e94
server: add unique constraint for registry items
fixes FoundKeyGang/FoundKey#335
2023-02-03 00:27:33 +01:00
8b98c9f2f4
server: remove unused 'domain' column 2023-02-02 23:29:24 +01:00
be30e70344
server: add more OpenGraph data, remove custom misskey meta tags
Changelog: Changed
2023-02-01 23:18:10 +01:00
39fb7e5946
server: improve OpenGraph data for note attachments
With this change, not all files will be proclaimed to be image files. Only
images, videos and audio files will be represented with OpenGraph data.

More properties for these files will also be represented, e.g. image alt text.

However, if the note has a CW or any of the files are marked sensitive, none
of the files will be used.

The users profile picture will not be used any more.

Changelog: Changed
2023-02-01 22:53:32 +01:00
75b14124f2
server: improve variable naming 2023-02-01 11:30:53 +01:00
7480e27c0c
server: remove twitter links from HTML templates
Since the twitter integration has been removed, this will never be true
and can therefore be removed.
2023-02-01 11:27:27 +01:00
953de3e4b2
adjust mailmap 2023-01-30 19:36:22 +01:00
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
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
6fd80816fa
client: remove unused property from MFM component 2023-01-29 14:29:58 +01:00
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
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
36031c083a
docs: adjust parameters for v2 methods other than POST 2023-01-26 13:34:13 +01:00
05f8172ce9
docs: describe /ap/ endpoints 2023-01-26 13:25:50 +01:00
151053897d
server: lower rate limit for deletion activities
Changelog: Changed
2023-01-26 13:25:50 +01:00
95a9027a66
docs: show rate limit information
Changelog: Added
2023-01-26 13:25:49 +01:00
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
9b76c805ec
fix: DriveFile folder & user undefined instead of null when unrequested 2023-01-25 22:14:53 +01:00
21b20920c2
docs: use endpoint stability to mark endpoints deprecated 2023-01-23 20:13:17 +01:00
e7644eb757
server: add index to human readable URL 2023-01-23 19:58:07 +01:00
66ec875624
server: also search human readable URL
Changelog: Fixed
2023-01-23 18:09:04 +01:00
78f5ca3792
server: fix empty array in quote detection 2023-01-22 21:47:02 +01:00
c792e4199c
server: add missing return in extractQuoteUrl 2023-01-22 21:42:49 +01:00
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
c4b5952788
migrate galleries to notes/clips 2023-01-22 19:44:39 +01:00
2bbb85b472
backend: remove galleries 2023-01-16 18:53:57 +01:00
70fb1e9a5c
foundkey-js: remove galleries 2023-01-16 18:47:29 +01:00
48163872ed
client: remove galleries 2023-01-16 18:39:50 +01:00
b245d39b6e
server: delete records of fully deleted users 2023-01-08 21:22:03 +01:00
80f72e21cd
server: track deletion completion 2023-01-08 21:22:03 +01:00
85e985d13f
server: change data structure to track deletion completion 2023-01-08 21:21:54 +01:00
119 changed files with 583 additions and 1939 deletions

View file

@ -6,10 +6,11 @@
#───┘ URL └───────────────────────────────────────────────────── #───┘ URL └─────────────────────────────────────────────────────
# Final accessible URL seen by a user. # Final accessible URL seen by a user.
url: https://example.tld/ # Only the host part will be used.
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT! # URL SETTINGS AFTER THAT!
url: https://example.tld/
# ┌───────────────────────┐ # ┌───────────────────────┐
#───┘ Port and TLS settings └─────────────────────────────────── #───┘ Port and TLS settings └───────────────────────────────────
@ -45,6 +46,7 @@ db:
pass: example-foundkey-pass pass: example-foundkey-pass
# Whether to disable query caching # Whether to disable query caching
# Default is to cache, i.e. false.
#disableCache: true #disableCache: true
# Extra connection options # Extra connection options
@ -57,7 +59,11 @@ db:
redis: redis:
host: localhost host: localhost
port: 6379 port: 6379
#family: dual # can be either a number or string (0/dual, 4/ipv4, 6/ipv6) # Address family to connect over.
# Can be either a number or string (0/dual, 4/ipv4, 6/ipv6)
# Default is "dual".
#family: dual
# The following properties are optional.
#pass: example-pass #pass: example-pass
#prefix: example-prefix #prefix: example-prefix
#db: 1 #db: 1
@ -65,6 +71,7 @@ redis:
# ┌─────────────────────────────┐ # ┌─────────────────────────────┐
#───┘ Elasticsearch configuration └───────────────────────────── #───┘ Elasticsearch configuration └─────────────────────────────
# Elasticsearch is optional.
#elasticsearch: #elasticsearch:
# host: localhost # host: localhost
# port: 9200 # port: 9200
@ -75,35 +82,41 @@ redis:
# ┌─────────────────────┐ # ┌─────────────────────┐
#───┘ Other configuration └───────────────────────────────────── #───┘ Other configuration └─────────────────────────────────────
# Whether disable HSTS # Whether to disable HSTS (not recommended)
# Default is to enable HSTS, i.e. false.
#disableHsts: true #disableHsts: true
# Number of worker processes by type. # Number of worker processes by type.
# The sum must not exceed the number of available cores. # The sum should not exceed the number of available cores.
#clusterLimits: #clusterLimits:
# web: 1 # web: 1
# queue: 1 # queue: 1
# Job concurrency per worker # Jobs each worker will try to work on at a time.
# deliverJobConcurrency: 128 #deliverJobConcurrency: 128
# inboxJobConcurrency: 16 #inboxJobConcurrency: 16
# Job rate limiter # Rate limit for each Worker.
# deliverJobPerSec: 128 # Use -1 to disable.
# inboxJobPerSec: 16 # A rate limit for deliver jobs is not recommended as it comes with
# a big performance penalty due to overhead of rate limiting.
#deliverJobPerSec: -1
#inboxJobPerSec: 16
# Job attempts # Number of times each job will be tried.
# deliverJobMaxAttempts: 12 # 1 means only try once and don't retry.
# inboxJobMaxAttempts: 8 #deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8
# Syslog option # Syslog option
#syslog: #syslog:
# host: localhost # host: localhost
# port: 514 # port: 514
# Proxy for HTTP/HTTPS # Proxy for HTTP/HTTPS outgoing connections
#proxy: http://127.0.0.1:3128 #proxy: http://127.0.0.1:3128
# Hosts that should not be connected to through the proxy specified above
#proxyBypassHosts: [ #proxyBypassHosts: [
# 'example.com', # 'example.com',
# '192.0.2.8' # '192.0.2.8'
@ -117,7 +130,8 @@ redis:
# Media Proxy # Media Proxy
#mediaProxy: https://example.com/proxy #mediaProxy: https://example.com/proxy
# Proxy remote files (default: false) # Proxy remote files
# Default is to not proxy remote files, i.e. false.
#proxyRemoteFiles: true #proxyRemoteFiles: true
# Storage path for files if stored locally (absolute path) # Storage path for files if stored locally (absolute path)
@ -125,11 +139,15 @@ redis:
#internalStoragePath: '/etc/foundkey/files' #internalStoragePath: '/etc/foundkey/files'
# Upload or download file size limits (bytes) # Upload or download file size limits (bytes)
# default is 262144000 = 250MiB
#maxFileSize: 262144000 #maxFileSize: 262144000
# Max note text length (in characters) # Max note text length (in characters)
#maxNoteTextLength: 3000 #maxNoteTextLength: 3000
# By default, Foundkey will fail when something tries to make it fetch something from private IPs.
# With the following setting you can explicitly allow some private CIDR subnets.
# Default is an empty list, i.e. none allowed.
#allowedPrivateNetworks: [ #allowedPrivateNetworks: [
# '127.0.0.1/32' # '127.0.0.1/32'
#] #]

View file

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

View file

@ -11,6 +11,108 @@ Unreleased changes should not be listed in this file.
Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from. Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from.
If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead. If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead.
## 13.0.0-preview4 - 2023-02-05
This release contains 6 breaking changes, including changes to the configuration file format.
### Added
- new Foundkey logo
- client: add button to unrenote/remove all own renotes
- client: add mod tracker
- client: add button to delete all files of a user for moderators
- server: implement OAuth 2.0 Authorization Code grant
- server: add config for error images
- server: expire notifications after 3 months
- server: start adding /api/v2 routes
- server: indicate Retry-After when rate limiting
- docs: show rate limit information
### Changed
- **BREAKING** server: implement separate web workers
The configuration file format has been changed: The `clusterLimit` item has been removed
and `clusterLimits` has been added instead. Check the example configuration file.
- **BREAKING** server: remove wildcard blocking and instead block subdomains (#269)
As an administrator you may need to check the list of blocked instances.
- **BREAKING** server: disable deliver rate limit by default
We found that the deliver rate limit causes a lot of load for no real benefit. Because of this,
it will be disabled by default. The default value of `deliverJobPerSec` is set to
disable this rate limit.
- server: adjust permissions for `/api/admin/accounts/delete`
The admin/accounts/delete endpoint now requries administrator privileges
instead of just moderator privileges.
- server: increase nodeinfo caching
- client: headlines in queue widget are links
- client: add tooltips to visibility icons
- server: improve error messages
- server: change default value for `/api/admin/show-users` origin param
- server: lower rate limit for deletion activities
Deleting things that result in federating a delete activity have a more strict rate limit.
This affects the following endpoints:
- `/api/notes/delete`
- `/api/notes/reactions/delete`
- `/api/notes/unrenote`
- server: improve OpenGraph data
- properly render note attachments as RDFa
- add more metadata about e.g. author
- proper OpenGraph data replaces custom `misskey:` RDFa tags
- activitypub: implement [FEP-e232](https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-e232.md) qoutes
- activitypub: use `quoteUri` instead of `quoteUrl`
### Fixed
- client: fix layout of app authorization page
- client: unify different error dialogs
- client: set display name limit same as server
- client: dont display instance banner tooltip if software name is unknown
- client: fix 500 error in notifications
- client: fix some tooltips not closing
- client: fix issue of search only working once
- client: check `quoteId` for canPost computation
- client: fix quotes with only a CW
- server: fix thread mutes not applying to renotes
- server: fix ReferenceError: meta is undefined
- server: fix TypeError in registerOrFetchInstanceDoc
- server: fix ratelimit in `/api/i/import-following`
- server: handle redirects in signed get
- server: remove reversi database tables
- server: set file permissions after copy
- server: also use human readable URL in search
- server: fix user deletion race condition
- server: add websocket ping mechanism
This should help keep websocket connections alive even if there are no events for
prolonged time periods. This should also fix issues where the "connection has been lost"
dialog appeared despite the connection being fine.
- activitypub: properly parse incoming hashtags
- activitypub: Do block checks more globally
- activitypub: properly render CW only quotes
### Removed:
- **BREAKING** server: remove Twitter, Github and Discord integrations
ff31b8b06 server: remove bios and cli
a673647fb server: remove avatarColor and bannerColor properties
- **BREAKING** server: remove `api/admin/delete-account`,
You should use the API endpoint `admin/accounts/delete` instead.
It has the same parameter and the same behaviour.
- **BREAKING** remove galleries
Galleries have been removed because low usage and duplication of other behaviour.
Existing gallery posts will be turned into ordinary notes.
If a user had any gallery posts, a new clip called "Gallery" will be created containing
all of the former gallery posts that are now notes.
This affects the following endpoints:
- `/api/gallery/featured`
- `/api/gallery/popular`
- `/api/gallery/posts`
- `/api/gallery/posts/create`
- `/api/gallery/posts/delete`
- `/api/gallery/posts/like`
- `/api/gallery/posts/show`
- `/api/gallery/posts/unlike`
- `/api/i/gallery/likes`
- `/api/i/gallery/posts`
- `/api/users/gallery/posts`
- server: remove application level websocket ping
This pinging mechanism was unused in `foundkey-js`, and we expect other usage to be low.
You can use the pinging mechanism built into the websocket protocol if you wish.
Note that the Server will now also send pings on its own (see *Fixed* section).
## 13.0.0-preview3 - 2022-12-02 ## 13.0.0-preview3 - 2022-12-02
This release contains 1 urgent security fix necessitated by `misskey-forkbomb`. This release contains 1 urgent security fix necessitated by `misskey-forkbomb`.
This release contains 1 breaking change. This release contains 1 breaking change.

View file

@ -21,3 +21,7 @@ https://github.com/deskjet/chiptune2.js#license
libopenmpt (as part of openmpt) by OpenMPT libopenmpt (as part of openmpt) by OpenMPT
License: BSD 3-Clause License: BSD 3-Clause
https://github.com/OpenMPT/openmpt/blob/master/LICENSE https://github.com/OpenMPT/openmpt/blob/master/LICENSE
The logo file (logo.svg) was created by Blinry
License: [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/)
https://blinry.org/

View file

@ -1,3 +1,5 @@
<div align="center"><img src="./logo.svg" height="200" alt="Foundkey logo, an owl holding a key"/></div>
# FoundKey # FoundKey
FoundKey is a free and open source microblogging server compatible with ActivityPub. Forked from Misskey, FoundKey improves on maintainability and behaviour, while also bringing in useful features. FoundKey is a free and open source microblogging server compatible with ActivityPub. Forked from Misskey, FoundKey improves on maintainability and behaviour, while also bringing in useful features.
@ -10,4 +12,5 @@ FoundKey's documentation is a work in progress. In the meantime, much of the doc
If you're interested in helping out with the project, please read the [contributing guide](./CONTRIBUTING.md). If you're interested in helping out with the project, please read the [contributing guide](./CONTRIBUTING.md).
## Sponsors ## Sponsors
FoundKey is not interested in sponsorships. FoundKey is not interested in finanical sponsorships.
We welcome contributions in the forms of code, testing and bug reporting (see also section *Contributing* above).

View file

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

BIN
logo.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,6 +1,6 @@
{ {
"name": "foundkey", "name": "foundkey",
"version": "13.0.0-preview3", "version": "13.0.0-preview4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://akkoma.dev/FoundKeyGang/FoundKey.git" "url": "https://akkoma.dev/FoundKeyGang/FoundKey.git"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 12 KiB

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,23 @@
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"`);
// remove human readable URL from notes where it is duplicated, so the index can be added
await queryRunner.query(`UPDATE "note" SET "url" = NULL WHERE "url" IN (SELECT "url" FROM "note" GROUP BY "url" HAVING COUNT("url") > 1)`);
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

@ -0,0 +1,19 @@
export class registryRemoveDomain1675375940759 {
name = 'registryRemoveDomain1675375940759'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_0a72bdfcdb97c0eca11fe7ecad"`);
await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "domain"`);
await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "key" TYPE text USING "key"::text`);
// delete existing duplicated entries, keeping the latest updated one
await queryRunner.query(`DELETE FROM "registry_item" AS "a" WHERE "updatedAt" != (SELECT MAX("updatedAt") OVER (PARTITION BY "userId", "key", "scope") FROM "registry_item" AS "b" WHERE "a"."userId" = "b"."userId" AND "a"."key" = "b"."key" AND "a"."scope" = "b"."scope")`);
await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "UQ_b8d6509f847331273ab99daccc7" UNIQUE ("userId", "key", "scope")`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "registry_item" DROP CONSTRAINT "UQ_b8d6509f847331273ab99daccc7"`);
await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "key" TYPE character varying(1024) USING "key"::varchar(1024)`);
await queryRunner.query(`ALTER TABLE "registry_item" ADD "domain" character varying(512)`);
await queryRunner.query(`CREATE INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad" ON "registry_item" ("domain") `);
}
}

View file

@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "13.0.0-preview3", "version": "13.0.0-preview4",
"main": "./index.js", "main": "./index.js",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -26,7 +26,7 @@ const path = process.env.NODE_ENV === 'test'
export default function load(): Config { export default function load(): Config {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8')); const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8')); const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8'));
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; let config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
if (config.id && config.id !== 'aid') throw new Error('Unsupported ID algorithm. Only "aid" is supported.'); if (config.id && config.id !== 'aid') throw new Error('Unsupported ID algorithm. Only "aid" is supported.');
@ -38,13 +38,30 @@ export default function load(): Config {
config.port = config.port || parseInt(process.env.PORT || '', 10); config.port = config.port || parseInt(process.env.PORT || '', 10);
// set default values
config.images = Object.assign({ config.images = Object.assign({
info: '/twemoji/1f440.svg', info: '/twemoji/1f440.svg',
notFound: '/twemoji/2049.svg', notFound: '/twemoji/2049.svg',
error: '/twemoji/1f480.svg', error: '/twemoji/1f480.svg',
}, config.images ?? {}); }, config.images ?? {});
if (!config.maxNoteTextLength) config.maxNoteTextLength = 3000; config.clusterLimits = Object.assign({
web: 1,
queue: 1,
}, config.clusterLimits ?? {});
config = Object.assign({
disableHsts: false,
deliverJobConcurrency: 128,
inboxJobConcurrency: 16,
deliverJobPerSec: -1,
inboxJobPerSec: 16,
deliverJobMaxAttempts: 12,
inboxJobMaxAttempts: 8,
proxyRemoteFiles: false,
maxFileSize: 262144000, // 250 MiB
maxNoteTextLength: 3000,
}, config);
mixin.version = meta.version; mixin.version = meta.version;
mixin.host = url.host; mixin.host = url.host;
@ -60,21 +77,8 @@ export default function load(): Config {
if (!config.redis.prefix) config.redis.prefix = mixin.host; if (!config.redis.prefix) config.redis.prefix = mixin.host;
if (!config.clusterLimits) { if (config.clusterLimits.web < 1 || config.clusterLimits.queue < 1) {
config.clusterLimits = { throw new Error('invalid cluster limits');
web: 1,
queue: 1,
};
} else {
config.clusterLimits = {
web: 1,
queue: 1,
...config.clusterLimits,
};
if (config.clusterLimits.web < 1 || config.clusterLimits.queue < 1) {
throw new Error('invalid cluster limits');
}
} }
return Object.assign(config, mixin); return Object.assign(config, mixin);

View file

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

View file

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

View file

@ -19,7 +19,6 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
const timeout = 30 * SECOND; const timeout = 30 * SECOND;
const operationTimeout = MINUTE; const operationTimeout = MINUTE;
const maxSize = config.maxFileSize || 262144000;
const req = got.stream(url, { const req = got.stream(url, {
headers: { headers: {
@ -53,14 +52,14 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
const contentLength = res.headers['content-length']; const contentLength = res.headers['content-length'];
if (contentLength != null) { if (contentLength != null) {
const size = Number(contentLength); const size = Number(contentLength);
if (size > maxSize) { if (size > config.maxFileSize) {
logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); logger.warn(`maxSize exceeded (${size} > ${config.maxFileSize}) on response`);
req.destroy(); req.destroy();
} }
} }
}).on('downloadProgress', (progress: Got.Progress) => { }).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) { if (progress.transferred > config.maxFileSize) {
logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); logger.warn(`maxSize exceeded (${progress.transferred} > ${config.maxFileSize}) on downloadProgress`);
req.destroy(); req.destroy();
} }
}); });

View file

@ -89,7 +89,7 @@ const _https = new https.Agent({
lookup: cache.lookup, lookup: cache.lookup,
} as https.AgentOptions); } as https.AgentOptions);
const maxSockets = Math.max(256, config.deliverJobConcurrency || 128); const maxSockets = Math.max(256, config.deliverJobConcurrency);
/** /**
* Get http proxy or non-proxy agent * Get http proxy or non-proxy agent

View file

@ -1,11 +0,0 @@
import { Note } from '@/models/entities/note.js';
export function isPureRenote(note: Note): note is Note & { renoteId: string, text: null, fileIds: null | never[], hasPoll: false } {
return note.renoteId != null
&& note.text == null
&& (
note.fileIds == null
|| note.fileIds.length === 0
)
&& !note.hasPoll;
}

View file

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

View file

@ -79,7 +79,6 @@ export class AccessToken {
@Column('varchar', { @Column('varchar', {
length: 64, array: true, length: 64, array: true,
default: '{}',
}) })
public permission: string[]; 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; public uri: string | null;
@Index({ unique: true })
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
comment: 'The human readable url of a note. it will be null when the note is local.', comment: 'The human readable url of a note. it will be null when the note is local.',

View file

@ -1,9 +1,9 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, Unique } from 'typeorm';
import { id } from '../id.js'; import { id } from '../id.js';
import { User } from './user.js'; import { User } from './user.js';
// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい
@Entity() @Entity()
@Unique(['userId', 'key', 'scope'])
export class RegistryItem { export class RegistryItem {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@ -31,8 +31,7 @@ export class RegistryItem {
@JoinColumn() @JoinColumn()
public user: User | null; public user: User | null;
@Column('varchar', { @Column('text', {
length: 1024,
comment: 'The key of the RegistryItem.', comment: 'The key of the RegistryItem.',
}) })
public key: string; public key: string;
@ -48,11 +47,4 @@ export class RegistryItem {
length: 1024, array: true, default: '{}', length: 1024, array: true, default: '{}',
}) })
public scope: string[]; public scope: string[];
// サードパーティアプリに開放するときのためのカラム
@Index()
@Column('varchar', {
length: 512, nullable: true,
})
public domain: string | null;
} }

View file

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

View file

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

@ -96,7 +96,7 @@ export function deliver(user: ThinUser, content: unknown, to: string | null) {
}; };
return deliverQueue.add(data, { return deliverQueue.add(data, {
attempts: config.deliverJobMaxAttempts || 12, attempts: config.deliverJobMaxAttempts,
timeout: MINUTE, timeout: MINUTE,
backoff: { backoff: {
type: 'apBackoff', type: 'apBackoff',
@ -113,7 +113,7 @@ export function inbox(activity: IActivity, signature: httpSignature.IParsedSigna
}; };
return inboxQueue.add(data, { return inboxQueue.add(data, {
attempts: config.inboxJobMaxAttempts || 8, attempts: config.inboxJobMaxAttempts,
timeout: 5 * MINUTE, timeout: 5 * MINUTE,
backoff: { backoff: {
type: 'apBackoff', type: 'apBackoff',
@ -291,8 +291,8 @@ export function webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[
export default function() { export default function() {
if (envOption.onlyServer) return; if (envOption.onlyServer) return;
deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); deliverQueue.process(config.deliverJobConcurrency, processDeliver);
inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); inboxQueue.process(config.inboxJobConcurrency, processInbox);
endedPollNotificationQueue.process(endedPollNotification); endedPollNotificationQueue.process(endedPollNotification);
webhookDeliverQueue.process(64, processWebhookDeliver); webhookDeliverQueue.process(64, processWebhookDeliver);
processDb(dbQueue); processDb(dbQueue);

View file

@ -4,8 +4,8 @@ import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPol
export const systemQueue = initializeQueue<Record<string, unknown>>('system'); export const systemQueue = initializeQueue<Record<string, unknown>>('system');
export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification'); export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification');
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128); export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec);
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16); export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec);
export const dbQueue = initializeQueue<DbJobData>('db'); export const dbQueue = initializeQueue<DbJobData>('db');
export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage'); export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage');
export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>('webhookDeliver', 64); export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>('webhookDeliver', 64);

View file

@ -62,9 +62,11 @@ export class DbResolver {
id: parsed.id, id: parsed.id,
}); });
} else { } else {
return await Notes.findOneBy({ return await Notes.findOneBy([{
uri: parsed.uri, 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', 'https://www.w3.org/ns/activitystreams#quoteUrl',
].includes(rel) ].includes(rel)
) )
); )
// Deduplicate by href.
.filter((x, i, arr) => arr.findIndex(y => x.href === y.href) === i);
if (quotes.length === 0) return null; if (quotes.length === 0) return null;
// Deduplicate by href.
// If there is more than one quote, we just pick the first/a random one. // 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

@ -0,0 +1,17 @@
import * as foundkey from 'foundkey-js';
import config from '@/config/index.js';
import { Notes } from '@/models/index.js';
import { Note } from '@/models/entities/note.js';
import { IActivity } from '@/remote/activitypub/types.js';
import renderNote from '@/remote/activitypub/renderer/note.js';
import renderCreate from '@/remote/activitypub/renderer/create.js';
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
export async function renderNoteOrRenoteActivity(note: Note): Promise<IActivity> {
if (foundkey.entities.isPureRenote(note)) {
const renote = await Notes.findOneByOrFail({ id: note.renoteId });
return renderAnnounce(renote.uri ?? `${config.url}/notes/${renote.id}`, note);
} else {
return renderCreate(await renderNote(note, false), note);
}
}

View file

@ -30,12 +30,21 @@ export async function renderPerson(user: ILocalUser) {
if (profile.fields) { if (profile.fields) {
for (const field of 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({ attachment.push({
type: 'PropertyValue', type: 'PropertyValue',
name: field.name, name: field.name,
value: (field.value != null && field.value.match(/^https?:/)) value,
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
: field.value,
}); });
} }
} }

View file

@ -15,7 +15,8 @@ import { ILocalUser, User } from '@/models/entities/user.js';
import { renderLike } from '@/remote/activitypub/renderer/like.js'; import { renderLike } from '@/remote/activitypub/renderer/like.js';
import { getUserKeypair } from '@/misc/keypair-store.js'; import { getUserKeypair } from '@/misc/keypair-store.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js';
import Outbox, { packActivity } from './activitypub/outbox.js'; import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
import Outbox from './activitypub/outbox.js';
import Followers from './activitypub/followers.js'; import Followers from './activitypub/followers.js';
import Following from './activitypub/following.js'; import Following from './activitypub/following.js';
import Featured from './activitypub/featured.js'; import Featured from './activitypub/featured.js';
@ -115,7 +116,7 @@ router.get('/notes/:note/activity', async ctx => {
return; return;
} }
ctx.body = renderActivity(await packActivity(note)); ctx.body = renderActivity(await renderNoteOrRenoteActivity(note));
ctx.set('Cache-Control', 'public, max-age=180'); ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx); setResponseType(ctx);
}); });

View file

@ -7,11 +7,11 @@ import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-c
import renderNote from '@/remote/activitypub/renderer/note.js'; import renderNote from '@/remote/activitypub/renderer/note.js';
import renderCreate from '@/remote/activitypub/renderer/create.js'; import renderCreate from '@/remote/activitypub/renderer/create.js';
import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
import { countIf } from '@/prelude/array.js'; import { countIf } from '@/prelude/array.js';
import * as url from '@/prelude/url.js'; import * as url from '@/prelude/url.js';
import { Users, Notes } from '@/models/index.js'; import { Users, Notes } from '@/models/index.js';
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
import { isPureRenote } from '@/misc/renote.js';
import { makePaginationQuery } from '../api/common/make-pagination-query.js'; import { makePaginationQuery } from '../api/common/make-pagination-query.js';
import { setResponseType } from '../activitypub.js'; import { setResponseType } from '../activitypub.js';
@ -63,7 +63,7 @@ export default async (ctx: Router.RouterContext) => {
if (sinceId) notes.reverse(); if (sinceId) notes.reverse();
const activities = await Promise.all(notes.map(note => packActivity(note))); const activities = await Promise.all(notes.map(note => renderNoteOrRenoteActivity(note)));
const rendered = renderOrderedCollectionPage( const rendered = renderOrderedCollectionPage(
`${partOf}?${url.query({ `${partOf}?${url.query({
page: 'true', page: 'true',
@ -94,16 +94,3 @@ export default async (ctx: Router.RouterContext) => {
setResponseType(ctx); setResponseType(ctx);
} }
}; };
/**
* Pack Create<Note> or Announce Activity
* @param note Note
*/
export async function packActivity(note: Note): Promise<any> {
if (isPureRenote(note)) {
const renote = await Notes.findOneByOrFail({ id: note.renoteId });
return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note);
} else {
return renderCreate(await renderNote(note, false), note);
}
}

View file

@ -35,10 +35,8 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
limit.key = ep.name; limit.key = ep.name;
} }
// Rate limit // Rate limit, may throw an ApiError
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(() => { await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor);
throw new ApiError('RATE_LIMIT_EXCEEDED');
});
} }
if (ep.meta.requireCredential && user == null) { 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_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.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___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___getOnlineUsersCount from './endpoints/get-online-users-count.js';
import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js';
import * as ep___hashtags_search from './endpoints/hashtags/search.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_exportNotes from './endpoints/i/export-notes.js';
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.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_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_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_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.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_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.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_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_create from './endpoints/users/groups/create.js';
import * as ep___users_groups_delete from './endpoints/users/groups/delete.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'; 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/cancel', ep___following_requests_cancel],
['following/requests/list', ep___following_requests_list], ['following/requests/list', ep___following_requests_list],
['following/requests/reject', ep___following_requests_reject], ['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], ['get-online-users-count', ep___getOnlineUsersCount],
['hashtags/list', ep___hashtags_list], ['hashtags/list', ep___hashtags_list],
['hashtags/search', ep___hashtags_search], ['hashtags/search', ep___hashtags_search],
@ -479,8 +458,6 @@ const eps = [
['i/export-notes', ep___i_exportNotes], ['i/export-notes', ep___i_exportNotes],
['i/export-user-lists', ep___i_exportUserLists], ['i/export-user-lists', ep___i_exportUserLists],
['i/favorites', ep___i_favorites], ['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/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
['i/import-blocking', ep___i_importBlocking], ['i/import-blocking', ep___i_importBlocking],
['i/import-following', ep___i_importFollowing], ['i/import-following', ep___i_importFollowing],
@ -584,7 +561,6 @@ const eps = [
['users/clips', ep___users_clips], ['users/clips', ep___users_clips],
['users/followers', ep___users_followers], ['users/followers', ep___users_followers],
['users/following', ep___users_following], ['users/following', ep___users_following],
['users/gallery/posts', ep___users_gallery_posts],
['users/groups/create', ep___users_groups_create], ['users/groups/create', ep___users_groups_create],
['users/groups/delete', ep___users_groups_delete], ['users/groups/delete', ep___users_groups_delete],
['users/groups/invitations/accept', ep___users_groups_invitations_accept], ['users/groups/invitations/accept', ep___users_groups_invitations_accept],
@ -717,6 +693,14 @@ export interface IEndpointMeta {
* @example (v0) /api/notes/create -> /api/v2/notes * @example (v0) /api/notes/create -> /api/v2/notes
*/ */
readonly alias?: string; 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, requireCredential: true,
description: 'Tries to fetch the given `uri` from the remote server.',
limit: { limit: {
duration: HOUR, duration: HOUR,
max: 30, max: 30,

View file

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

@ -20,7 +20,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });

View file

@ -24,7 +24,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.key = :key', { key: ps.key }) .andWhere('item.key = :key', { key: ps.key })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });

View file

@ -24,7 +24,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.key = :key', { key: ps.key }) .andWhere('item.key = :key', { key: ps.key })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });

View file

@ -20,7 +20,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });

View file

@ -21,7 +21,6 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.select('item.key') .select('item.key')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });

View file

@ -24,7 +24,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.key = :key', { key: ps.key }) .andWhere('item.key = :key', { key: ps.key })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });

View file

@ -17,7 +17,6 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.select('item.scope') .select('item.scope')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }); .andWhere('item.userId = :userId', { userId: user.id });
const items = await query.getMany(); const items = await query.getMany();

View file

@ -24,7 +24,6 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
const query = RegistryItems.createQueryBuilder('item') const query = RegistryItems.createQueryBuilder('item')
.where('item.domain IS NULL')
.andWhere('item.userId = :userId', { userId: user.id }) .andWhere('item.userId = :userId', { userId: user.id })
.andWhere('item.key = :key', { key: ps.key }) .andWhere('item.key = :key', { key: ps.key })
.andWhere('item.scope = :scope', { scope: ps.scope }); .andWhere('item.scope = :scope', { scope: ps.scope });
@ -42,7 +41,6 @@ export default define(meta, paramDef, async (ps, user) => {
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
userId: user.id, userId: user.id,
domain: null,
scope: ps.scope, scope: ps.scope,
key: ps.key, key: ps.key,
value: ps.value, value: ps.value,

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { In } from 'typeorm'; import { In } from 'typeorm';
import { noteVisibilities } from 'foundkey-js'; import { noteVisibilities, entities } from 'foundkey-js';
import create from '@/services/note/create.js'; import create from '@/services/note/create.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js'; import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js';
@ -7,7 +7,6 @@ import { DriveFile } from '@/models/entities/drive-file.js';
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
import { Channel } from '@/models/entities/channel.js'; import { Channel } from '@/models/entities/channel.js';
import { HOUR } from '@/const.js'; import { HOUR } from '@/const.js';
import { isPureRenote } from '@/misc/renote.js';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import define from '../../define.js'; import define from '../../define.js';
@ -160,7 +159,7 @@ export default define(meta, paramDef, async (ps, user) => {
throw e; throw e;
}); });
if (isPureRenote(renote)) throw new ApiError('PURE_RENOTE', 'Cannot renote a pure renote.'); if (entities.isPureRenote(renote)) throw new ApiError('PURE_RENOTE', 'Cannot renote a pure renote.');
// check that the visibility is not less restrictive // check that the visibility is not less restrictive
if (noteVisibilities.indexOf(renote.visibility) > noteVisibilities.indexOf(ps.visibility)) { if (noteVisibilities.indexOf(renote.visibility) > noteVisibilities.indexOf(ps.visibility)) {
@ -185,7 +184,7 @@ export default define(meta, paramDef, async (ps, user) => {
throw e; throw e;
}); });
if (isPureRenote(reply)) throw new ApiError('PURE_RENOTE', 'Cannot reply to a pure renote.'); if (entities.isPureRenote(reply)) throw new ApiError('PURE_RENOTE', 'Cannot reply to a pure renote.');
// check that the visibility is not less restrictive // check that the visibility is not less restrictive
if (noteVisibilities.indexOf(reply.visibility) > noteVisibilities.indexOf(ps.visibility)) { if (noteVisibilities.indexOf(reply.visibility) > noteVisibilities.indexOf(ps.visibility)) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,13 +14,15 @@ export const meta = {
limit: { limit: {
duration: HOUR, duration: HOUR,
max: 300, max: 30,
minInterval: SECOND, minInterval: 10 * SECOND,
key: 'delete',
}, },
v2: { v2: {
method: 'delete', method: 'delete',
alias: 'notes/:noteId/renotes', alias: 'notes/:noteId/renotes',
pathParameters: ['noteId'],
}, },
errors: ['NO_SUCH_NOTE'], 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 { public apply(ctx: Koa.Context, endpoint: string): void {
ctx.status = this.httpStatusCode; ctx.status = this.httpStatusCode;
if (ctx.status === 401) { // set additional headers
ctx.response.set('WWW-Authenticate', 'Bearer'); 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 = { ctx.body = {
error: { error: {
@ -73,7 +81,7 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
httpStatusCode: 409, httpStatusCode: 409,
}, },
ALREADY_LIKED: { ALREADY_LIKED: {
message: 'You already liked that page or gallery post.', message: 'You already liked that page.',
httpStatusCode: 409, httpStatusCode: 409,
}, },
ALREADY_MUTING: { ALREADY_MUTING: {
@ -292,10 +300,6 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
message: 'No such parent folder.', message: 'No such parent folder.',
httpStatusCode: 404, httpStatusCode: 404,
}, },
NO_SUCH_POST: {
message: 'No such gallery post.',
httpStatusCode: 404,
},
NO_SUCH_RESET_REQUEST: { NO_SUCH_RESET_REQUEST: {
message: 'No such password reset request.', message: 'No such password reset request.',
httpStatusCode: 404, httpStatusCode: 404,
@ -337,7 +341,7 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
httpStatusCode: 409, httpStatusCode: 409,
}, },
NOT_LIKED: { NOT_LIKED: {
message: 'You have not liked that page or gallery post.', message: 'You have not liked that page.',
httpStatusCode: 409, httpStatusCode: 409,
}, },
NOT_MUTING: { NOT_MUTING: {

View file

@ -2,11 +2,12 @@ import Limiter from 'ratelimiter';
import Logger from '@/services/logger.js'; import Logger from '@/services/logger.js';
import { redisClient } from '@/db/redis.js'; import { redisClient } from '@/db/redis.js';
import { IEndpointMeta } from './endpoints.js'; import { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
const logger = new Logger('limiter'); const logger = new Logger('limiter');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => { export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((resolve) => {
if (process.env.NODE_ENV === 'test') ok(); if (process.env.NODE_ENV === 'test') resolve();
const hasShortTermLimit = typeof limitation.minInterval === 'number'; const hasShortTermLimit = typeof limitation.minInterval === 'number';
@ -19,10 +20,10 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
} else if (hasLongTermLimit) { } else if (hasLongTermLimit) {
max(); max();
} else { } else {
ok(); resolve();
} }
// Short-term limit // Short-term limit, calls long term limit if appropriate.
function min(): void { function min(): void {
const minIntervalLimiter = new Limiter({ const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`, id: `${actor}:${limitation.key}:min`,
@ -33,18 +34,19 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
minIntervalLimiter.get((err, info) => { minIntervalLimiter.get((err, info) => {
if (err) { if (err) {
return reject('ERR'); logger.error(err);
throw new ApiError('INTERNAL_ERROR');
} }
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL'); throw new ApiError('RATE_LIMIT_EXCEEDED', info);
} else { } else {
if (hasLongTermLimit) { if (hasLongTermLimit) {
max(); max();
} else { } else {
ok(); resolve();
} }
} }
}); });
@ -61,15 +63,16 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
limiter.get((err, info) => { limiter.get((err, info) => {
if (err) { if (err) {
return reject('ERR'); logger.error(err);
throw new ApiError('INTERNAL_ERROR');
} }
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED'); throw new ApiError('RATE_LIMIT_EXCEEDED', info);
} else { } 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'}*`; desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
if (endpoint.meta.kind) { if (endpoint.meta.kind) {
const kind = endpoint.meta.kind; desc += '\n\n**Permission**: `' + endpoint.meta.kind + '`';
desc += ` / **Permission**: *${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'; const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
@ -183,6 +193,7 @@ export function genOpenapiSpec() {
}, },
}, },
responses, responses,
deprecated: endpoint.meta.stability === 'deprecated',
}; };
const path = { const path = {
@ -209,11 +220,37 @@ export function genOpenapiSpec() {
spec.paths['/' + endpoint.name] = path; spec.paths['/' + endpoint.name] = path;
if (endpoint.meta.v2) { 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 // we need a clone of the API endpoint info because otherwise we change it by reference
const infoClone = structuredClone(info); 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] = {
...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'); new ApiError(e, info).apply(ctx, 'signin');
} }
try { // not more than 1 attempt per second and not more than 10 attempts per hour
// 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));
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
} catch (err) {
error('RATE_LIMIT_EXCEEDED');
return;
}
if (typeof username !== 'string') { if (typeof username !== 'string') {
error('INVALID_PARAM', { param: 'username', reason: 'not a string' }); error('INVALID_PARAM', { param: 'username', reason: 'not a string' });

View file

@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
import * as http from 'node:http'; import * as http from 'node:http';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { MINUTE } from '@/const.js'; import { SECOND, MINUTE } from '@/const.js';
import { subscriber as redisClient } from '@/db/redis.js'; import { subscriber as redisClient } from '@/db/redis.js';
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { Connection } from './stream/index.js'; import { Connection } from './stream/index.js';
@ -43,6 +43,20 @@ export const initializeStreamingServer = (server: http.Server): void => {
const main = new Connection(socket, ev, user, app); const main = new Connection(socket, ev, user, app);
// ping/pong mechanism
let pingTimeout = null;
function startHeartbeat() {
if (pingTimeout) clearTimeout(pingTimeout);
socket.ping();
pingTimeout = setTimeout(() => {
socket.terminate();
}, 30 * SECOND);
}
startHeartbeat();
socket.on('ping', () => { startHeartbeat(); });
socket.on('pong', () => { startHeartbeat(); });
// keep user "online" while a stream is connected // keep user "online" while a stream is connected
const intervalId = user ? setInterval(() => { const intervalId = user ? setInterval(() => {
Users.update(user.id, { Users.update(user.id, {
@ -54,19 +68,13 @@ export const initializeStreamingServer = (server: http.Server): void => {
lastActiveDate: new Date(), lastActiveDate: new Date(),
}); });
} }
socket.once('close', () => { socket.once('close', () => {
ev.removeAllListeners(); ev.removeAllListeners();
main.dispose(); main.dispose();
redisClient.off('message', onRedisMessage); redisClient.off('message', onRedisMessage);
if (intervalId) clearInterval(intervalId); if (intervalId) clearInterval(intervalId);
}); if (pingTimeout) clearTimeout(pingTimeout);
// ping/pong mechanism
// TODO: the websocket protocol already specifies a ping/pong mechanism, why is this necessary?
socket.on('message', async (data) => {
if (data.type === 'utf8' && data.utf8Data === 'ping') {
socket.send('pong');
}
}); });
}); });
}); });

View file

@ -18,7 +18,7 @@ import { KoaAdapter } from '@bull-board/koa';
import { In, IsNull } from 'typeorm'; import { In, IsNull } from 'typeorm';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import config from '@/config/index.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, DriveFiles } from '@/models/index.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { getNoteSummary } from '@/misc/get-note-summary.js'; import { getNoteSummary } from '@/misc/get-note-summary.js';
import { queues } from '@/queue/queues.js'; import { queues } from '@/queue/queues.js';
@ -324,15 +324,75 @@ router.get('/notes/:note', async (ctx, next) => {
if (note) { if (note) {
try { try {
// FIXME: packing with detail may throw an error if the reply or renote is not visible (#8774) // FIXME: packing with detail may throw an error if the reply or renote is not visible (#8774)
const _note = await Notes.pack(note); const packedNote = await Notes.pack(note);
const profile = await UserProfiles.findOneByOrFail({ userId: note.userId }); const profile = await UserProfiles.findOneByOrFail({ userId: note.userId });
const meta = await fetchMeta(); const meta = await fetchMeta();
// If the note has a CW (is sensitive as a whole) or any of the files is sensitive or there are no
// files, they are not used for a preview.
let filesOpengraph = [];
if (!packedNote.cw || packedNote.files.length > 0 || packedNote.files.every(file => !file.isSensitive)) {
let limit = 4;
for (const file of packedNote.files) {
if (file.type.startsWith('image/')) {
filesOpengraph.push([
"og:image",
DriveFiles.getPublicUrl(file, true),
]);
filesOpengraph.push([
"og:image:type",
file.type,
]);
if (file.properties != null) {
filesOpengraph.push([
"og:image:width",
file.properties?.width,
]);
filesOpengraph.push([
"og:image:height",
file.properties?.height,
]);
}
if (file.comment) {
filesOpengraph.push([
"og:image:alt",
file.comment,
]);
}
} else if (file.type.startsWith('audio/')) {
filesOpengraph.push([
"og:audio",
DriveFiles.getPublicUrl(file),
]);
filesOpengraph.push([
"og:audio:type",
file.type,
]);
} else if (file.type.startsWith('video/')) {
filesOpengraph.push([
"og:video",
DriveFiles.getPublicUrl(file),
]);
filesOpengraph.push([
"og:video:type",
file.type,
]);
} else {
// doesn't count towards the limit
continue;
}
// limit the number of presented attachments
if (--limit < 0) break;
}
}
await ctx.render('note', { await ctx.render('note', {
note: _note, note: packedNote,
profile, profile,
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: note.userId })), filesOpengraph,
// TODO: Let locale changeable by instance setting // TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note), summary: getNoteSummary(packedNote),
instanceName: meta.name || 'FoundKey', instanceName: meta.name || 'FoundKey',
icon: meta.iconUrl, icon: meta.iconUrl,
themeColor: meta.themeColor, themeColor: meta.themeColor,
@ -421,31 +481,6 @@ router.get('/clips/:clip', async (ctx, next) => {
await 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 // Channel
router.get('/channels/:channel', async (ctx, next) => { router.get('/channels/:channel', async (ctx, next) => {
const channel = await Channels.findOneBy({ const channel = await Channels.findOneBy({

View file

@ -12,20 +12,12 @@ block desc
meta(name='description' content= clip.description) meta(name='description' content= clip.description)
block og block og
meta(property='og:type' content='article') meta(property='og:type' content='website')
meta(property='og:title' content= title) meta(property='og:title' content=title)
meta(property='og:description' content= clip.description) meta(property='og:description' content=clip.description)
meta(property='og:url' content= url) meta(property='og:url' content=url)
meta(property='og:image' content= avatarUrl) meta(property='og:image' content=avatarUrl)
block meta block meta
if profile.noCrawle if profile.noCrawle
meta(name='robots' content='noindex') meta(name='robots' content='noindex')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:clip-id' content=clip.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)

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

@ -13,24 +13,19 @@ block desc
meta(name='description' content= summary) meta(name='description' content= summary)
block og block og
meta(property='og:type' content='article') meta(property='og:type' content='article')
meta(property='og:title' content= title) meta(property='og:article:published_time' content=note.createdAt)
meta(property='og:article:author:username' content=user.username)
meta(property='og:title' content= title)
meta(property='og:description' content= summary) meta(property='og:description' content= summary)
meta(property='og:url' content= url) meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl) for opengraphTag in filesOpengraph
meta(property=opengraphTag[0] content=opengraphTag[1])
block meta block meta
if user.host || isRenote || profile.noCrawle if user.host || isRenote || profile.noCrawle
meta(name='robots' content='noindex') meta(name='robots' content='noindex')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:note-id' content=note.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)
if note.prev if note.prev
link(rel='prev' href=`${config.url}/notes/${note.prev}`) link(rel='prev' href=`${config.url}/notes/${note.prev}`)
if note.next if note.next

View file

@ -12,20 +12,13 @@ block desc
meta(name='description' content= page.summary) meta(name='description' content= page.summary)
block og block og
meta(property='og:type' content='article') meta(property='og:type' content='article')
meta(property='og:title' content= title) meta(property='og:article:author:username' content=user.username)
meta(property='og:description' content= page.summary) meta(property='og:title' content=title)
meta(property='og:url' content= url) meta(property='og:description' content=page.summary)
meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl) meta(property='og:url' content=url)
meta(property='og:image' content=page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl)
block meta block meta
if profile.noCrawle if profile.noCrawle
meta(name='robots' content='noindex') meta(name='robots' content='noindex')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:page-id' content=page.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)

View file

@ -11,19 +11,16 @@ block desc
meta(name='description' content= profile.description) meta(name='description' content= profile.description)
block og block og
meta(property='og:type' content='blog') meta(property='og:type' content='profile')
meta(property='og:title' content= title) meta(property='og:profile:username' content=user.username)
meta(property='og:description' content= profile.description) meta(property='og:description' content= profile.description)
meta(property='og:url' content= url) meta(property='og:url' content=url)
meta(property='og:image' content= avatarUrl) meta(property='og:image' content=avatarUrl)
block meta block meta
if user.host || profile.noCrawle if user.host || profile.noCrawle
meta(name='robots' content='noindex') meta(name='robots' content='noindex')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
if profile.twitter if profile.twitter
meta(name='twitter:creator' content=`@${profile.twitter.screenName}`) meta(name='twitter:creator' content=`@${profile.twitter.screenName}`)

View file

@ -36,6 +36,7 @@ import { Cache } from '@/misc/cache.js';
import { UserProfile } from '@/models/entities/user-profile.js'; import { UserProfile } from '@/models/entities/user-profile.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js'; import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { IActivity } from '@/remote/activitypub/type.js'; import { IActivity } from '@/remote/activitypub/type.js';
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
import { MINUTE } from '@/const.js'; import { MINUTE } from '@/const.js';
import { updateHashtags } from '../update-hashtag.js'; import { updateHashtags } from '../update-hashtag.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
@ -428,9 +429,9 @@ export default async (user: { id: User['id']; username: User['username']; host:
}); });
//#region AP deliver //#region AP deliver
if (Users.isLocalUser(user)) { if (Users.isLocalUser(user) && !data.localOnly) {
(async () => { (async () => {
const noteActivity = await renderNoteOrRenoteActivity(data, note); const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
const dm = new DeliverManager(user, noteActivity); const dm = new DeliverManager(user, noteActivity);
// Delivered to remote users who have been mentioned // Delivered to remote users who have been mentioned
@ -487,16 +488,6 @@ export default async (user: { id: User['id']; username: User['username']; host:
index(note); index(note);
}); });
async function renderNoteOrRenoteActivity(data: Option, note: Note): Promise<IActivity | null> {
if (data.localOnly) return null;
const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)
? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote.id}`, note)
: renderCreate(await renderNote(note, false), note);
return renderActivity(content);
}
function incRenoteCount(renote: Note): void { function incRenoteCount(renote: Note): void {
Notes.createQueryBuilder().update() Notes.createQueryBuilder().update()
.set({ .set({

View file

@ -1,4 +1,5 @@
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import * as foundkey from 'foundkey-js';
import { publishNoteStream } from '@/services/stream.js'; import { publishNoteStream } from '@/services/stream.js';
import renderDelete from '@/remote/activitypub/renderer/delete.js'; import renderDelete from '@/remote/activitypub/renderer/delete.js';
import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
@ -12,7 +13,6 @@ import { Notes, Users, Instances } from '@/models/index.js';
import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js'; import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js';
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js'; import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
import { countSameRenotes } from '@/misc/count-same-renotes.js'; import { countSameRenotes } from '@/misc/count-same-renotes.js';
import { isPureRenote } from '@/misc/renote.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import { deliverToRelays } from '../relay.js'; import { deliverToRelays } from '../relay.js';
@ -42,7 +42,7 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
let renote: Note | null = null; let renote: Note | null = null;
// if deleted note is renote // if deleted note is renote
if (isPureRenote(note)) { if (foundkey.entities.isPureRenote(note)) {
renote = await Notes.findOneBy({ id: note.renoteId }); renote = await Notes.findOneBy({ id: note.renoteId });
} }

View file

@ -8,7 +8,7 @@ export const logger = new Logger('email');
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<void> { export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<void> {
const meta = await fetchMeta(true); const meta = await fetchMeta(true);
const iconUrl = `${config.url}/static-assets/mi-white.png`; const iconUrl = `${config.url}/static-assets/icons/512.png`;
const emailSettingUrl = `${config.url}/settings/email`; const emailSettingUrl = `${config.url}/settings/email`;
const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';

View file

@ -1,6 +1,6 @@
{ {
"name": "client", "name": "client",
"version": "13.0.0-preview3", "version": "13.0.0-preview4",
"private": true, "private": true,
"scripts": { "scripts": {
"watch": "vite build --watch --mode development", "watch": "vite build --watch --mode development",

View file

@ -9,7 +9,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { length } from 'stringz'; import { length } from 'stringz';
import * as foundkey from 'foundkey-js'; import * as foundkey from 'foundkey-js';
import { concat } from '@/scripts/array';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
@ -22,11 +21,12 @@ const emit = defineEmits<{
}>(); }>();
const label = computed(() => { const label = computed(() => {
return concat([ return [
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [], props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length }) ] : [], props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length }) ] : [],
props.note.poll != null ? [i18n.ts.poll] : [], props.note.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / '); props.note.renoteId != null ? [i18n.ts.quote] : [],
].flat().join(' / ');
}); });
const toggle = () => { const toggle = () => {

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

@ -4,7 +4,6 @@ import MkUrl from '@/components/global/url.vue';
import MkLink from '@/components/link.vue'; import MkLink from '@/components/link.vue';
import MkMention from '@/components/mention.vue'; import MkMention from '@/components/mention.vue';
import MkEmoji from '@/components/global/emoji.vue'; import MkEmoji from '@/components/global/emoji.vue';
import { concat } from '@/scripts/array';
import MkFormula from '@/components/formula.vue'; import MkFormula from '@/components/formula.vue';
import MkCode from '@/components/code.vue'; import MkCode from '@/components/code.vue';
import MkSearch from '@/components/mfm-search.vue'; import MkSearch from '@/components/mfm-search.vue';
@ -31,10 +30,6 @@ export default defineComponent({
type: Object, type: Object,
default: null, default: null,
}, },
i: {
type: Object,
default: null,
},
customEmojis: { customEmojis: {
required: false, required: false,
}, },
@ -54,7 +49,7 @@ export default defineComponent({
return t.match(/^[0-9.]+s$/) ? t : null; return t.match(/^[0-9.]+s$/) ? t : null;
}; };
const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => { const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode[] => {
switch (token.type) { switch (token.type) {
case 'text': { case 'text': {
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
@ -318,7 +313,7 @@ export default defineComponent({
return []; return [];
} }
} }
})); }).flat();
// Parse ast to DOM // Parse ast to DOM
return h('span', genEl(ast)); return h('span', genEl(ast));

Some files were not shown because too many files have changed in this diff Show more