Compare commits
60 commits
80e2851378
...
3489c8ac3a
Author | SHA1 | Date | |
---|---|---|---|
Johann150 | 3489c8ac3a | ||
Johann150 | 06ef752218 | ||
Johann150 | 44f02fa3ec | ||
Johann150 | d655bda30c | ||
Johann150 | 839daea887 | ||
Johann150 | 41c42f96f0 | ||
Johann150 | 9a6bb8be7d | ||
Johann150 | 1adf88b090 | ||
Johann150 | 28c11ca7af | ||
Johann150 | 9458045c8f | ||
Mia Herkt | a8c0e1f827 | ||
Johann150 | 63665e8bd1 | ||
Johann150 | 85a68a5eee | ||
Johann150 | 0bb4a6af50 | ||
Johann150 | a45908c1cb | ||
Johann150 | ca257d7d0c | ||
Johann150 | 30c26abde7 | ||
Johann150 | 17324e1e94 | ||
Johann150 | 8b98c9f2f4 | ||
Johann150 | be30e70344 | ||
Johann150 | 39fb7e5946 | ||
Johann150 | 75b14124f2 | ||
Johann150 | 7480e27c0c | ||
Johann150 | 953de3e4b2 | ||
Johann150 | 2d32bc33d7 | ||
Chloe Kudryavtsev | bb3ec8bafe | ||
Johann150 | 6fd80816fa | ||
Johann150 | cc83cbe523 | ||
Johann150 | 8abd3ebec7 | ||
Johann150 | 36031c083a | ||
Johann150 | 05f8172ce9 | ||
Johann150 | 151053897d | ||
Johann150 | 95a9027a66 | ||
Johann150 | 57cf6c7163 | ||
Johann150 | 9b76c805ec | ||
Johann150 | 21b20920c2 | ||
Johann150 | e7644eb757 | ||
Johann150 | 66ec875624 | ||
Johann150 | 78f5ca3792 | ||
Johann150 | c792e4199c | ||
Johann150 | afa4094050 | ||
Johann150 | c4b5952788 | ||
Johann150 | e3fd371f4a | ||
Johann150 | 5893a44ff5 | ||
Johann150 | 9bdf24d3a5 | ||
Johann150 | 2bbb85b472 | ||
Johann150 | 70fb1e9a5c | ||
Johann150 | 48163872ed | ||
Johann150 | 7170b86724 | ||
Johann150 | 96e6187e83 | ||
Johann150 | 3d2cfc075a | ||
Norm | 83373e0c51 | ||
Johann150 | 11518d2f26 | ||
Johann150 | f9c360d59f | ||
Johann150 | 2a8792fe07 | ||
Johann150 | b8963a0119 | ||
Johann150 | 1319dc93d9 | ||
Johann150 | b245d39b6e | ||
Johann150 | 80f72e21cd | ||
Johann150 | 85e985d13f |
|
@ -6,10 +6,11 @@
|
|||
#───┘ URL └─────────────────────────────────────────────────────
|
||||
|
||||
# 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
|
||||
# URL SETTINGS AFTER THAT!
|
||||
url: https://example.tld/
|
||||
|
||||
|
||||
# ┌───────────────────────┐
|
||||
#───┘ Port and TLS settings └───────────────────────────────────
|
||||
|
@ -45,6 +46,7 @@ db:
|
|||
pass: example-foundkey-pass
|
||||
|
||||
# Whether to disable query caching
|
||||
# Default is to cache, i.e. false.
|
||||
#disableCache: true
|
||||
|
||||
# Extra connection options
|
||||
|
@ -57,7 +59,11 @@ db:
|
|||
redis:
|
||||
host: localhost
|
||||
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
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
@ -65,6 +71,7 @@ redis:
|
|||
# ┌─────────────────────────────┐
|
||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||
|
||||
# Elasticsearch is optional.
|
||||
#elasticsearch:
|
||||
# host: localhost
|
||||
# port: 9200
|
||||
|
@ -75,35 +82,41 @@ redis:
|
|||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
||||
# Whether disable HSTS
|
||||
# Whether to disable HSTS (not recommended)
|
||||
# Default is to enable HSTS, i.e. false.
|
||||
#disableHsts: true
|
||||
|
||||
# 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:
|
||||
# web: 1
|
||||
# queue: 1
|
||||
|
||||
# Job concurrency per worker
|
||||
# deliverJobConcurrency: 128
|
||||
# inboxJobConcurrency: 16
|
||||
# Jobs each worker will try to work on at a time.
|
||||
#deliverJobConcurrency: 128
|
||||
#inboxJobConcurrency: 16
|
||||
|
||||
# Job rate limiter
|
||||
# deliverJobPerSec: 128
|
||||
# inboxJobPerSec: 16
|
||||
# Rate limit for each Worker.
|
||||
# Use -1 to disable.
|
||||
# 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
|
||||
# deliverJobMaxAttempts: 12
|
||||
# inboxJobMaxAttempts: 8
|
||||
# Number of times each job will be tried.
|
||||
# 1 means only try once and don't retry.
|
||||
#deliverJobMaxAttempts: 12
|
||||
#inboxJobMaxAttempts: 8
|
||||
|
||||
# Syslog option
|
||||
#syslog:
|
||||
# host: localhost
|
||||
# port: 514
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
# Proxy for HTTP/HTTPS outgoing connections
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
# Hosts that should not be connected to through the proxy specified above
|
||||
#proxyBypassHosts: [
|
||||
# 'example.com',
|
||||
# '192.0.2.8'
|
||||
|
@ -117,7 +130,8 @@ redis:
|
|||
# Media 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
|
||||
|
||||
# Storage path for files if stored locally (absolute path)
|
||||
|
@ -125,11 +139,15 @@ redis:
|
|||
#internalStoragePath: '/etc/foundkey/files'
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
# default is 262144000 = 250MiB
|
||||
#maxFileSize: 262144000
|
||||
|
||||
# Max note text length (in characters)
|
||||
#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: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
|
6
.mailmap
|
@ -1,9 +1,9 @@
|
|||
Andreas Nedbal <git@pixelde.su> <andreas.nedbal@in2code.de>
|
||||
Andreas Nedbal <git@pixelde.su> <github-bf215181b5140522137b3d4f6b73544a@desu.email>
|
||||
Balazs Nadasdi <balazs@weave.works> <yitsushi@gmail.com>
|
||||
Chloe Kudryavtsev <code@code.bunkerlabs.net> <code@toast.bunkerlabs.net>
|
||||
Chloe Kudryavtsev <code@code.bunkerlabs.net> <toast+git@toast.cafe>
|
||||
Chloe Kudryavtsev <code@code.bunkerlabs.net> <toast@toast.cafe>
|
||||
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <code@code.bunkerlabs.net>
|
||||
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <toast+git@toast.cafe>
|
||||
Chloe Kudryavtsev <code@toast.bunkerlabs.net> <toast@toast.cafe>
|
||||
Dr. Gutfuck LLC <40531868+gutfuckllc@users.noreply.github.com>
|
||||
Ehsan Javadynia <31900907+ehsanjavadynia@users.noreply.github.com> <ehsan.javadynia@gmail.com>
|
||||
Francis Dinh <normandy@biribiri.dev>
|
||||
|
|
102
CHANGELOG.md
|
@ -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.
|
||||
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
|
||||
This release contains 1 urgent security fix necessitated by `misskey-forkbomb`.
|
||||
This release contains 1 breaking change.
|
||||
|
|
4
COPYING
|
@ -21,3 +21,7 @@ https://github.com/deskjet/chiptune2.js#license
|
|||
libopenmpt (as part of openmpt) by OpenMPT
|
||||
License: BSD 3-Clause
|
||||
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/
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
<div align="center"><img src="./logo.svg" height="200" alt="Foundkey logo, an owl holding a key"/></div>
|
||||
|
||||
# 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.
|
||||
|
||||
|
@ -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).
|
||||
|
||||
## 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).
|
||||
|
|
|
@ -70,6 +70,8 @@ Build foundkey with the following:
|
|||
|
||||
`NODE_ENV=production yarn build`
|
||||
|
||||
If your system has at least 4GB of RAM, run `NODE_ENV=production yarn build-parallel` to speed up build times.
|
||||
|
||||
If you're still encountering errors about some modules, use node-gyp:
|
||||
|
||||
1. `npx node-gyp configure`
|
||||
|
@ -182,6 +184,7 @@ Use git to pull in the latest changes and rerun the build and migration commands
|
|||
git pull
|
||||
git submodule update --init
|
||||
yarn install
|
||||
# Use build-parallel if your system has 4GB or more RAM and want faster builds
|
||||
NODE_ENV=production yarn build
|
||||
yarn migrate
|
||||
```
|
||||
|
|
103
docs/moderation.md
Normal file
|
@ -0,0 +1,103 @@
|
|||
# User moderation
|
||||
|
||||
A lot of the user moderation activities can be found on the `user-info` page. You can reach this page by going to a users profile page, open the three dot menu, select "About" and navigating to the "Moderation" section of the page that opens.
|
||||
With the necessary privileges, this page will allow you to:
|
||||
- Toggle whether a user is a moderator (administrators on local users only)
|
||||
- Reset the users password (local users only)
|
||||
- Delete a user (administrators only)
|
||||
- Delete all files of a user
|
||||
For remote users, cached files (if any) will be deleted.
|
||||
- Silence a user
|
||||
This disallows a user from making a note with `public` visibility.
|
||||
If necessary the visibility of incoming notes or locally created notes will be lowered.
|
||||
- Suspend a user
|
||||
This will drop any incoming activities of this actor and hide them from public view on this instance.
|
||||
|
||||
# Administrator
|
||||
|
||||
When an instance is first set up, the initial user to be created will be made an administrator by default.
|
||||
This means that typically the instance owner is the administrator.
|
||||
It is also possible to have multiple administrators, however making a user an administrator is not implemented in the client.
|
||||
To make a user an administrator, you will need access to the database.
|
||||
This is intended for security reasons of
|
||||
1. not exposing this very dangerous functionality via the API
|
||||
2. making sure someone that has shell access to the server anyway "approves" this.
|
||||
|
||||
To make a user an administrator, you will first need the user's ID.
|
||||
To get it you can go to the user's profile page, open the three dot menu, select "About" and copy the ID displayed there.
|
||||
Then, go to the database and run the following query, replacing `<ID>` with the ID gotten above.
|
||||
```sql
|
||||
UPDATE "user" SET "isAdmin" = true WHERE "id" = '<ID>';
|
||||
```
|
||||
|
||||
The user that was made administrator may need to reload their client to see the changes take effect.
|
||||
|
||||
To demote a user, you can do a similar operation, but instead with `... SET "isAdmin" = false ...`.
|
||||
|
||||
## Immunity
|
||||
|
||||
- Cannot be reported by local users.
|
||||
- Cannot have their password reset.
|
||||
To see how you can reset an administrator password, see below.
|
||||
- Cannot have their account deleted.
|
||||
- Cannot be suspended.
|
||||
- Cannot be silenced.
|
||||
- Cannot have their account details viewed by moderators.
|
||||
- Cannot be made moderators.
|
||||
|
||||
## Abilities
|
||||
|
||||
- Create or delete user accounts.
|
||||
- Add or remove moderators.
|
||||
- View and change instance configuration (e.g. Translation API keys).
|
||||
- View all followers and followees.
|
||||
|
||||
Administrators also have the same ability as moderators.
|
||||
Note of course that people with access to the server and/or database access can do basically anything without restrictions (including breaking the instance).
|
||||
|
||||
## Resetting an administrators password
|
||||
|
||||
Administrators are blocked from the paths of resetting the password by moderators or administrators.
|
||||
However, if your server has email configured you should be able to use the "Forgot password" link on the normal signin dialog.
|
||||
|
||||
If you did not set up email, you will need to kick of this process instead through modifying the database yourself.
|
||||
You will need the user ID whose password should be reset, indicated in the following as `<USERID>`;
|
||||
as well as a random string (a UUID would be recommended) indicated as `<TOKEN>`.
|
||||
|
||||
Replacing the two terms above, run the following SQL query:
|
||||
```sql
|
||||
INSERT INTO "password_reset_request" VALUES ('0000000000', now(), '<TOKEN>', '<USERID>');
|
||||
```
|
||||
|
||||
After that, navigate to `/reset-password/<TOKEN>` on your instance to finish the password reset process.
|
||||
After that you should be able to sign in with the new password you just set.
|
||||
|
||||
# Moderator
|
||||
|
||||
A moderator has fewer privileges than an administrator.
|
||||
They can also be more easily added or removed by an adminstrator.
|
||||
Having moderators may be a good idea to help with user moderation.
|
||||
|
||||
## Immunity
|
||||
|
||||
- Cannot be reported by local users.
|
||||
- Cannot be suspended.
|
||||
|
||||
## Abilities
|
||||
|
||||
- Suspend users.
|
||||
- Add, list and remove relays.
|
||||
- View queue, database and server information.
|
||||
- Create, edit, delete, export and import local custom emoji.
|
||||
- View global, social and local timelines even if disabled by administrators.
|
||||
- Show, update and delete any users files and file metadata.
|
||||
Managing emoji is described in [a separate file](emoji.md).
|
||||
- Delete any users notes.
|
||||
- Create an invitation.
|
||||
This allows users to register an account even if (public) registrations are closed using an invite code.
|
||||
- View users' account details.
|
||||
- Suspend and unsuspend users.
|
||||
- Silence and unsilence users.
|
||||
- Handle reports.
|
||||
- Create, update and delete announcements.
|
||||
- View the moderation log.
|
|
@ -501,6 +501,7 @@ scratchpadDescription: "The Scratchpad provides an environment for AiScript expe
|
|||
\ in it."
|
||||
output: "Output"
|
||||
updateRemoteUser: "Update remote user information"
|
||||
deleteAllFiles: "Delete all files"
|
||||
deleteAllFilesConfirm: "Are you sure that you want to delete all files?"
|
||||
removeAllFollowing: "Unfollow all followed users"
|
||||
removeAllFollowingDescription: "Executing this unfollows all accounts from {host}.\
|
||||
|
@ -678,7 +679,6 @@ editCode: "Edit code"
|
|||
apply: "Apply"
|
||||
receiveAnnouncementFromInstance: "Receive notifications from this instance"
|
||||
emailNotification: "Email notifications"
|
||||
publish: "Publish"
|
||||
useReactionPickerForContextMenu: "Open reaction picker on right-click"
|
||||
typingUsers: "{users} is/are typing..."
|
||||
jumpToSpecifiedDate: "Jump to specific date"
|
||||
|
@ -719,11 +719,7 @@ switch: "Switch"
|
|||
noMaintainerInformationWarning: "Maintainer information is not configured."
|
||||
noBotProtectionWarning: "Bot protection is not configured."
|
||||
configure: "Configure"
|
||||
postToGallery: "Create new gallery post"
|
||||
attachmentRequired: "At least 1 attachment is required."
|
||||
gallery: "Gallery"
|
||||
recentPosts: "Recent posts"
|
||||
popularPosts: "Popular posts"
|
||||
shareWithNote: "Share with note"
|
||||
emailNotConfiguredWarning: "Email address not set."
|
||||
ratio: "Ratio"
|
||||
|
@ -862,11 +858,6 @@ _forgotPassword:
|
|||
\ instance administrator instead."
|
||||
contactAdmin: "This instance does not support using email addresses, please contact\
|
||||
\ the instance administrator to reset your password instead."
|
||||
_gallery:
|
||||
my: "My Gallery"
|
||||
liked: "Liked Posts"
|
||||
like: "Like"
|
||||
unlike: "Remove like"
|
||||
_email:
|
||||
_follow:
|
||||
title: "You've got a new follower"
|
||||
|
@ -1108,10 +1099,6 @@ _permissions:
|
|||
"write:user-groups": "Create, modify, delete, transfer, join and leave groups. Invite and ban others from groups. Accept and reject group invitations."
|
||||
"read:channels": "List and read followed and joined channels"
|
||||
"write:channels": "Create, modify, follow and unfollow channels"
|
||||
"read:gallery": "List and read gallery posts"
|
||||
"write:gallery": "Create, modify and delete gallery posts"
|
||||
"read:gallery-likes": "List and read gallery post likes"
|
||||
"write:gallery-likes": "Like and unlike gallery posts"
|
||||
_auth:
|
||||
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
||||
shareAccessAsk: "Are you sure you want to authorize this application to access your\
|
||||
|
|
1
logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="1000" height="1000" viewBox="0 0 264.583 264.583" xml:space="preserve" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop style="stop-color:#92191c;stop-opacity:1" offset="0"/><stop style="stop-color:#a11c38;stop-opacity:1" offset="1"/></linearGradient><linearGradient xlink:href="#a" id="b" gradientUnits="userSpaceOnUse" x1="100.048" y1="229.172" x2="97.548" y2="233.865"/></defs><path style="opacity:1;fill:url(#b);fill-opacity:1;stroke-width:1.47155;stroke-linecap:round;stroke-linejoin:round" d="M98.99 228.83a7.578 7.578 0 0 1-1.807-.246c.031.156.115.316.2.451.07.11.186.195.284.28a2 2 0 0 0-.185 1.383.853.853 0 0 0-.49.283.93.93 0 0 0-.214.532c-.014.194.029.39.113.566.087.181.22.342.388.452.168.11.372.167.572.149a.829.829 0 0 0 .424-.165c.425.21.942.225 1.378.039.402-.172.729-.51.887-.917l.478-.203a.626.626 0 0 0 .13-.069.276.276 0 0 0 .092-.114.267.267 0 0 0 .014-.146.562.562 0 0 0-.049-.14l-.1-.22a.664.664 0 0 0 .066-.035.405.405 0 0 0 .092-.07.25.25 0 0 0 .058-.1.24.24 0 0 0 .004-.114.467.467 0 0 0-.04-.108l-.171-.374a.545.545 0 0 0-.069-.119.257.257 0 0 0-.11-.08.263.263 0 0 0-.135-.01.624.624 0 0 0-.13.043l-.203.085c-.024-.121-.041-.24-.091-.353-.044-.098-.055-.124-.117-.212a.928.928 0 0 0 .341-.27.974.974 0 0 0 .194-.438c-.633.195-1.264.243-1.805.24z" transform="translate(-5365.976 -12670.019) scale(55.51197)"/><path style="opacity:1;fill:#fff;fill-opacity:1;stroke-width:1.47155;stroke-linecap:round;stroke-linejoin:round" d="M100.778 230.08v.001c-.008 0-.013.002-.02.003a.29.29 0 0 0-.038.014l-1.987.872a.364.364 0 0 1-.108.36c-.046.04-.1.07-.16.087a.597.597 0 0 1-.18.024.795.795 0 0 1-.385-.105.83.83 0 0 1-.317-.325.517.517 0 0 0-.35.15.599.599 0 0 0-.164.393.85.85 0 0 0 .097.418.816.816 0 0 0 .326.354c.071.04.15.065.232.072a.5.5 0 0 0 .24-.036.56.56 0 0 0 .286-.304.69.69 0 0 0 .042-.387l1.236-.546.18.408a.37.37 0 0 0 .02.035c.007.01.018.02.029.025.012.006.026.007.039.006a.09.09 0 0 0 .037-.012l.268-.12a.199.199 0 0 0 .031-.018.063.063 0 0 0 .02-.03.062.062 0 0 0 0-.035c-.002-.012-.008-.022-.012-.033l-.186-.417.31-.135.19.444a.14.14 0 0 0 .019.033c.008.011.018.019.031.023a.07.07 0 0 0 .038 0 .154.154 0 0 0 .036-.012l.27-.118c.01-.005.022-.01.03-.018a.055.055 0 0 0 .018-.03.07.07 0 0 0-.001-.035.152.152 0 0 0-.013-.033l-.2-.436.25-.112.03-.015a.07.07 0 0 0 .022-.023.05.05 0 0 0 .007-.032c0-.01-.006-.02-.01-.03l-.128-.267c-.006-.012-.011-.024-.021-.034a.077.077 0 0 0-.054-.023zm-3.136 1.36h.01c.091 0 .183.07.23.171.061.135.024.285-.083.334-.107.048-.243-.022-.305-.157-.061-.135-.024-.284.082-.333a.206.206 0 0 1 .066-.015zM99.434 229.088a.552.552 0 0 0-.476.278.535.535 0 0 0-.514-.273.546.546 0 0 0-.494.53.625.625 0 0 0 .09.338c.06.101.146.187.244.252.198.13.44.176.676.177.244.002.496-.043.7-.178a.773.773 0 0 0 .248-.264c.06-.107.09-.23.08-.352a.559.559 0 0 0-.535-.508h-.019zm-.946.173a.4.4 0 0 1 .268.104c.077.07.125.173.129.277l-.267.006a.135.135 0 0 0-.032-.108.136.136 0 0 0-.103-.045.135.135 0 0 0-.1.048.136.136 0 0 0-.028.108l-.262.006c0-.1.04-.199.11-.27a.404.404 0 0 1 .284-.124zm.956 0a.4.4 0 0 1 .397.38l-.267.007a.135.135 0 0 0-.031-.108.137.137 0 0 0-.103-.045.135.135 0 0 0-.1.048.135.135 0 0 0-.029.108l-.261.006c0-.1.04-.199.109-.27a.404.404 0 0 1 .285-.124zm-.48.422.289.423-.262.225-.294-.203z" transform="translate(-5365.976 -12670.019) scale(55.51197)"/></svg>
|
After Width: | Height: | Size: 3.3 KiB |
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "foundkey",
|
||||
"version": "13.0.0-preview3",
|
||||
"version": "13.0.0-preview4",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://akkoma.dev/FoundKeyGang/FoundKey.git"
|
||||
|
@ -10,7 +10,8 @@
|
|||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "yarn workspaces foreach --parallel --topological run build && yarn run gulp",
|
||||
"build": "yarn workspaces foreach --topological run build && yarn run gulp",
|
||||
"build-parallel": "yarn workspaces foreach --parallel --topological run build && yarn run gulp",
|
||||
"start": "yarn workspace backend run start",
|
||||
"start:test": "yarn workspace backend run start:test",
|
||||
"init": "yarn migrate",
|
||||
|
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 12 KiB |
65
packages/backend/migration/1673892262930-remove-groups.js
Normal 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`);
|
||||
}
|
||||
}
|
23
packages/backend/migration/1674499888924-sync-orm.js
Normal 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.'`);
|
||||
}
|
||||
}
|
|
@ -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") `);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "13.0.0-preview3",
|
||||
"version": "13.0.0-preview4",
|
||||
"main": "./index.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
@ -113,7 +113,6 @@
|
|||
"unzipper": "0.10.11",
|
||||
"uuid": "8.3.2",
|
||||
"web-push": "3.5.0",
|
||||
"websocket": "1.0.34",
|
||||
"ws": "8.8.0",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
|
@ -164,7 +163,6 @@
|
|||
"@types/tmp": "0.2.3",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.46.1",
|
||||
"@typescript-eslint/parser": "^5.46.1",
|
||||
|
|
|
@ -26,7 +26,7 @@ const path = process.env.NODE_ENV === 'test'
|
|||
export default function load(): Config {
|
||||
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 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.');
|
||||
|
||||
|
@ -38,13 +38,30 @@ export default function load(): Config {
|
|||
|
||||
config.port = config.port || parseInt(process.env.PORT || '', 10);
|
||||
|
||||
// set default values
|
||||
config.images = Object.assign({
|
||||
info: '/twemoji/1f440.svg',
|
||||
notFound: '/twemoji/2049.svg',
|
||||
error: '/twemoji/1f480.svg',
|
||||
}, 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.host = url.host;
|
||||
|
@ -60,22 +77,9 @@ export default function load(): Config {
|
|||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
|
||||
if (!config.clusterLimits) {
|
||||
config.clusterLimits = {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -50,8 +50,6 @@ import { UserSecurityKey } from '@/models/entities/user-security-key.js';
|
|||
import { AttestationChallenge } from '@/models/entities/attestation-challenge.js';
|
||||
import { Page } from '@/models/entities/page.js';
|
||||
import { PageLike } from '@/models/entities/page-like.js';
|
||||
import { GalleryPost } from '@/models/entities/gallery-post.js';
|
||||
import { GalleryLike } from '@/models/entities/gallery-like.js';
|
||||
import { ModerationLog } from '@/models/entities/moderation-log.js';
|
||||
import { UsedUsername } from '@/models/entities/used-username.js';
|
||||
import { Announcement } from '@/models/entities/announcement.js';
|
||||
|
@ -143,8 +141,6 @@ export const entities = [
|
|||
NoteUnread,
|
||||
Page,
|
||||
PageLike,
|
||||
GalleryPost,
|
||||
GalleryLike,
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
Poll,
|
||||
|
|
|
@ -27,9 +27,5 @@ export const kinds = [
|
|||
'write:user-groups',
|
||||
'read:channels',
|
||||
'write:channels',
|
||||
'read:gallery',
|
||||
'write:gallery',
|
||||
'read:gallery-likes',
|
||||
'write:gallery-likes',
|
||||
];
|
||||
// IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions).
|
||||
|
|
|
@ -19,7 +19,6 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
|
|||
|
||||
const timeout = 30 * SECOND;
|
||||
const operationTimeout = MINUTE;
|
||||
const maxSize = config.maxFileSize || 262144000;
|
||||
|
||||
const req = got.stream(url, {
|
||||
headers: {
|
||||
|
@ -53,14 +52,14 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
|
|||
const contentLength = res.headers['content-length'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
if (size > maxSize) {
|
||||
logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
|
||||
if (size > config.maxFileSize) {
|
||||
logger.warn(`maxSize exceeded (${size} > ${config.maxFileSize}) on response`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||
if (progress.transferred > maxSize) {
|
||||
logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
|
||||
if (progress.transferred > config.maxFileSize) {
|
||||
logger.warn(`maxSize exceeded (${progress.transferred} > ${config.maxFileSize}) on downloadProgress`);
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -89,7 +89,7 @@ const _https = new https.Agent({
|
|||
lookup: cache.lookup,
|
||||
} 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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -28,7 +28,6 @@ import { packedAntennaSchema } from '@/models/schema/antenna.js';
|
|||
import { packedClipSchema } from '@/models/schema/clip.js';
|
||||
import { packedFederationInstanceSchema } from '@/models/schema/federation-instance.js';
|
||||
import { packedQueueCountSchema } from '@/models/schema/queue.js';
|
||||
import { packedGalleryPostSchema } from '@/models/schema/gallery-post.js';
|
||||
import { packedEmojiSchema } from '@/models/schema/emoji.js';
|
||||
|
||||
export const refs = {
|
||||
|
@ -61,7 +60,6 @@ export const refs = {
|
|||
Antenna: packedAntennaSchema,
|
||||
Clip: packedClipSchema,
|
||||
FederationInstance: packedFederationInstanceSchema,
|
||||
GalleryPost: packedGalleryPostSchema,
|
||||
Emoji: packedEmojiSchema,
|
||||
};
|
||||
|
||||
|
|
|
@ -79,7 +79,6 @@ export class AccessToken {
|
|||
|
||||
@Column('varchar', {
|
||||
length: 64, array: true,
|
||||
default: '{}',
|
||||
})
|
||||
public permission: string[];
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -117,6 +117,7 @@ export class Note {
|
|||
})
|
||||
public uri: string | null;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The human readable url of a note. it will be null when the note is local.',
|
||||
|
|
|
@ -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 { User } from './user.js';
|
||||
|
||||
// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい
|
||||
@Entity()
|
||||
@Unique(['userId', 'key', 'scope'])
|
||||
export class RegistryItem {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
@ -31,8 +31,7 @@ export class RegistryItem {
|
|||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
@Column('text', {
|
||||
comment: 'The key of the RegistryItem.',
|
||||
})
|
||||
public key: string;
|
||||
|
@ -48,11 +47,4 @@ export class RegistryItem {
|
|||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
public scope: string[];
|
||||
|
||||
// サードパーティアプリに開放するときのためのカラム
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
})
|
||||
public domain: string | null;
|
||||
}
|
||||
|
|
|
@ -42,8 +42,6 @@ import { UserSecurityKey } from './entities/user-security-key.js';
|
|||
import { HashtagRepository } from './repositories/hashtag.js';
|
||||
import { PageRepository } from './repositories/page.js';
|
||||
import { PageLikeRepository } from './repositories/page-like.js';
|
||||
import { GalleryPostRepository } from './repositories/gallery-post.js';
|
||||
import { GalleryLikeRepository } from './repositories/gallery-like.js';
|
||||
import { ModerationLogRepository } from './repositories/moderation-logs.js';
|
||||
import { UsedUsername } from './entities/used-username.js';
|
||||
import { ClipRepository } from './repositories/clip.js';
|
||||
|
@ -108,8 +106,6 @@ export const Signins = (SigninRepository);
|
|||
export const MessagingMessages = (MessagingMessageRepository);
|
||||
export const Pages = (PageRepository);
|
||||
export const PageLikes = (PageLikeRepository);
|
||||
export const GalleryPosts = (GalleryPostRepository);
|
||||
export const GalleryLikes = (GalleryLikeRepository);
|
||||
export const ModerationLogs = (ModerationLogRepository);
|
||||
export const Clips = (ClipRepository);
|
||||
export const ClipNotes = db.getRepository(ClipNote);
|
||||
|
|
|
@ -108,9 +108,9 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
|
|||
folderId: file.folderId,
|
||||
folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, {
|
||||
detail: true,
|
||||
}) : null,
|
||||
userId: opts.withUser ? file.userId : null,
|
||||
user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null,
|
||||
}) : undefined,
|
||||
userId: file.userId,
|
||||
user: (opts.withUser && file.userId) ? Users.pack(file.userId) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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)));
|
||||
},
|
||||
});
|
|
@ -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)));
|
||||
},
|
||||
});
|
|
@ -147,6 +147,8 @@ export const UserRepository = db.getRepository(User).extend({
|
|||
JOIN "user_group_joining"
|
||||
ON "messaging_message"."groupId" = "user_group_joining"."userGroupId"
|
||||
WHERE
|
||||
"user_group_joining"."userId" = $1
|
||||
AND
|
||||
"messaging_message"."userId" != $1
|
||||
AND
|
||||
NOT $1 = ANY("messaging_message"."reads")
|
||||
|
|
|
@ -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;
|
|
@ -96,7 +96,7 @@ export function deliver(user: ThinUser, content: unknown, to: string | null) {
|
|||
};
|
||||
|
||||
return deliverQueue.add(data, {
|
||||
attempts: config.deliverJobMaxAttempts || 12,
|
||||
attempts: config.deliverJobMaxAttempts,
|
||||
timeout: MINUTE,
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
|
@ -113,7 +113,7 @@ export function inbox(activity: IActivity, signature: httpSignature.IParsedSigna
|
|||
};
|
||||
|
||||
return inboxQueue.add(data, {
|
||||
attempts: config.inboxJobMaxAttempts || 8,
|
||||
attempts: config.inboxJobMaxAttempts,
|
||||
timeout: 5 * MINUTE,
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
|
@ -291,8 +291,8 @@ export function webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[
|
|||
export default function() {
|
||||
if (envOption.onlyServer) return;
|
||||
|
||||
deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver);
|
||||
inboxQueue.process(config.inboxJobConcurrency || 16, processInbox);
|
||||
deliverQueue.process(config.deliverJobConcurrency, processDeliver);
|
||||
inboxQueue.process(config.inboxJobConcurrency, processInbox);
|
||||
endedPollNotificationQueue.process(endedPollNotification);
|
||||
webhookDeliverQueue.process(64, processWebhookDeliver);
|
||||
processDb(dbQueue);
|
||||
|
|
|
@ -4,8 +4,8 @@ import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPol
|
|||
|
||||
export const systemQueue = initializeQueue<Record<string, unknown>>('system');
|
||||
export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification');
|
||||
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128);
|
||||
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16);
|
||||
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec);
|
||||
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec);
|
||||
export const dbQueue = initializeQueue<DbJobData>('db');
|
||||
export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage');
|
||||
export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>('webhookDeliver', 64);
|
||||
|
|
|
@ -62,9 +62,11 @@ export class DbResolver {
|
|||
id: parsed.id,
|
||||
});
|
||||
} else {
|
||||
return await Notes.findOneBy({
|
||||
return await Notes.findOneBy([{
|
||||
uri: parsed.uri,
|
||||
});
|
||||
}, {
|
||||
url: parsed.uri,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import { DbResolver } from '../db-resolver.js';
|
|||
import { apLogger } from '../logger.js';
|
||||
import { resolvePerson } from './person.js';
|
||||
import { resolveImage } from './image.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import { extractApHashtags, extractQuoteUrl } from './tag.js';
|
||||
import { extractPollFromQuestion } from './question.js';
|
||||
import { extractApMentions } from './mention.js';
|
||||
|
||||
|
@ -154,10 +154,10 @@ export async function createNote(value: string | IObject, resolver: Resolver, si
|
|||
})
|
||||
: null;
|
||||
|
||||
// 引用
|
||||
let quote: Note | undefined | null;
|
||||
const quoteUrl = extractQuoteUrl(note.tag);
|
||||
|
||||
if (note._misskey_quote || note.quoteUri) {
|
||||
if (quoteUrl || note._misskey_quote || note.quoteUri) {
|
||||
const tryResolveNote = async (uri: string): Promise<{
|
||||
status: 'ok';
|
||||
res: Note | null;
|
||||
|
@ -184,10 +184,16 @@ export async function createNote(value: string | IObject, resolver: Resolver, si
|
|||
}
|
||||
};
|
||||
|
||||
const uris = unique([note._misskey_quote, note.quoteUri].filter((x): x is string => typeof x === 'string'));
|
||||
const results = await Promise.all(uris.map(uri => tryResolveNote(uri)));
|
||||
|
||||
quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
|
||||
const uris = unique([quoteUrl, note._misskey_quote, note.quoteUri].filter((x): x is string => typeof x === 'string'));
|
||||
// check the urls sequentially and abort early to not do unnecessary HTTP requests
|
||||
// picks the first one that works
|
||||
for (const uri in uris) {
|
||||
const res = await tryResolveNote(uri);
|
||||
if (res.status === 'ok') {
|
||||
quote = res.res;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!quote) {
|
||||
if (results.some(x => x.status === 'temperror')) {
|
||||
throw new Error('quote resolve failed');
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { toArray } from '@/prelude/array.js';
|
||||
import { IObject, isHashtag, IApHashtag } from '../type.js';
|
||||
import { IObject, isHashtag, IApHashtag, isLink, ILink } from '../type.js';
|
||||
|
||||
export function extractApHashtags(tags: IObject | IObject[] | null | undefined) {
|
||||
if (tags == null) return [];
|
||||
|
@ -16,3 +16,34 @@ export function extractApHashtagObjects(tags: IObject | IObject[] | null | undef
|
|||
if (tags == null) return [];
|
||||
return toArray(tags).filter(isHashtag);
|
||||
}
|
||||
|
||||
// implements FEP-e232: Object Links (2022-12-23 version)
|
||||
export function extractQuoteUrl(tags: IObject | IObject[] | null | undefined): string | null {
|
||||
if (tags == null) return null;
|
||||
|
||||
// filter out correct links
|
||||
let quotes: ILink[] = toArray(tags)
|
||||
.filter(isLink)
|
||||
.filter(link =>
|
||||
[
|
||||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'application/activity+json'
|
||||
].includes(link.mediaType?.toLowerCase())
|
||||
)
|
||||
.filter(link =>
|
||||
toArray(link.rel)
|
||||
.some(rel =>
|
||||
[
|
||||
'https://misskey-hub.net/ns#_misskey_quote',
|
||||
'http://fedibird.com/ns#quoteUri',
|
||||
'https://www.w3.org/ns/activitystreams#quoteUrl',
|
||||
].includes(rel)
|
||||
)
|
||||
)
|
||||
// Deduplicate by href.
|
||||
.filter((x, i, arr) => arr.findIndex(y => x.href === y.href) === i);
|
||||
|
||||
if (quotes.length === 0) return null;
|
||||
// If there is more than one quote, we just pick the first/a random one.
|
||||
else return quotes[0].href;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -111,6 +111,16 @@ export default async function renderNote(note: Note, dive = true, isTalk = false
|
|||
...apemojis,
|
||||
];
|
||||
|
||||
if (quote) {
|
||||
tag.push({
|
||||
type: 'Link',
|
||||
mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
href: quote,
|
||||
name: `RE: ${quote}`,
|
||||
rel: 'https://misskey-hub.net/ns#_misskey_quote',
|
||||
});
|
||||
}
|
||||
|
||||
const asPoll = poll ? {
|
||||
type: 'Question',
|
||||
content: await toHtml(text, note.mentions),
|
||||
|
|
|
@ -30,12 +30,21 @@ export async function renderPerson(user: ILocalUser) {
|
|||
|
||||
if (profile.fields) {
|
||||
for (const field of profile.fields) {
|
||||
let value = field.value;
|
||||
// try to parse it as a url
|
||||
try {
|
||||
if (field.value?.match(/^https?:/)) {
|
||||
const url = new URL(field.value);
|
||||
value = `<a href="${url.href}" rel="me nofollow noopener" target="_blank">${url.href}</a>`;
|
||||
}
|
||||
} catch {
|
||||
// guess it wasn't a url after all...
|
||||
}
|
||||
|
||||
attachment.push({
|
||||
type: 'PropertyValue',
|
||||
name: field.name,
|
||||
value: (field.value != null && field.value.match(/^https?:/))
|
||||
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
|
||||
: field.value,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ export function getOneApId(value: ApObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object id
|
||||
*/
|
||||
export function getApId(value: string | IObject): string {
|
||||
export function getApId(value: string | Object): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
throw new Error('cannot detemine id');
|
||||
|
@ -54,7 +54,7 @@ export function getApId(value: string | IObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object type
|
||||
*/
|
||||
export function getApType(value: IObject): string {
|
||||
export function getApType(value: Object): string {
|
||||
if (typeof value.type === 'string') return value.type;
|
||||
if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0];
|
||||
throw new Error('cannot detect type');
|
||||
|
@ -196,24 +196,6 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue =>
|
|||
typeof object.name === 'string' &&
|
||||
typeof (object as any).value === 'string';
|
||||
|
||||
export interface IApMention extends IObject {
|
||||
type: 'Mention';
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const isMention = (object: IObject): object is IApMention =>
|
||||
getApType(object) === 'Mention' &&
|
||||
typeof object.href === 'string';
|
||||
|
||||
export interface IApHashtag extends IObject {
|
||||
type: 'Hashtag';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const isHashtag = (object: IObject): object is IApHashtag =>
|
||||
getApType(object) === 'Hashtag' &&
|
||||
typeof object.name === 'string';
|
||||
|
||||
export interface IApEmoji extends IObject {
|
||||
type: 'Emoji';
|
||||
updated: Date;
|
||||
|
@ -293,3 +275,34 @@ export const isLike = (object: IObject): object is ILike => getApType(object) ==
|
|||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
|
||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
||||
|
||||
export interface ILink {
|
||||
href: string;
|
||||
rel?: string | string[];
|
||||
mediaType?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface IApMention extends ILink {
|
||||
type: 'Mention';
|
||||
}
|
||||
|
||||
export interface IApHashtag extends ILink {
|
||||
type: 'Hashtag';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const isLink = (object: Record<string, any>): object is ILink =>
|
||||
typeof object.href === 'string'
|
||||
&& (
|
||||
object.rel == undefined
|
||||
|| typeof object.rel === 'string'
|
||||
|| (Array.isArray(object.rel) && object.rel.every(x => typeof x === 'string'))
|
||||
)
|
||||
&& (object.mediaType == undefined || typeof object.mediaType === 'string');
|
||||
export const isMention = (object: Record<string, any>): object is IApMention =>
|
||||
getApType(object) === 'Mention' && isLink(object);
|
||||
export const isHashtag = (object: Record<string, any>): object is IApHashtag =>
|
||||
getApType(object) === 'Hashtag'
|
||||
&& isLink(object)
|
||||
&& typeof object.name === 'string';
|
||||
|
|
|
@ -15,7 +15,8 @@ import { ILocalUser, User } from '@/models/entities/user.js';
|
|||
import { renderLike } from '@/remote/activitypub/renderer/like.js';
|
||||
import { getUserKeypair } from '@/misc/keypair-store.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 Following from './activitypub/following.js';
|
||||
import Featured from './activitypub/featured.js';
|
||||
|
@ -115,7 +116,7 @@ router.get('/notes/:note/activity', async ctx => {
|
|||
return;
|
||||
}
|
||||
|
||||
ctx.body = renderActivity(await packActivity(note));
|
||||
ctx.body = renderActivity(await renderNoteOrRenoteActivity(note));
|
||||
ctx.set('Cache-Control', 'public, max-age=180');
|
||||
setResponseType(ctx);
|
||||
});
|
||||
|
|
|
@ -7,11 +7,11 @@ import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-c
|
|||
import renderNote from '@/remote/activitypub/renderer/note.js';
|
||||
import renderCreate from '@/remote/activitypub/renderer/create.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 * as url from '@/prelude/url.js';
|
||||
import { Users, Notes } from '@/models/index.js';
|
||||
import { Note } from '@/models/entities/note.js';
|
||||
import { isPureRenote } from '@/misc/renote.js';
|
||||
import { makePaginationQuery } from '../api/common/make-pagination-query.js';
|
||||
import { setResponseType } from '../activitypub.js';
|
||||
|
||||
|
@ -63,7 +63,7 @@ export default async (ctx: Router.RouterContext) => {
|
|||
|
||||
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(
|
||||
`${partOf}?${url.query({
|
||||
page: 'true',
|
||||
|
@ -94,16 +94,3 @@ export default async (ctx: Router.RouterContext) => {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,10 +35,8 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
|
|||
limit.key = ep.name;
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(() => {
|
||||
throw new ApiError('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
// Rate limit, may throw an ApiError
|
||||
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor);
|
||||
}
|
||||
|
||||
if (ep.meta.requireCredential && user == null) {
|
||||
|
|
|
@ -138,15 +138,6 @@ import * as ep___following_requests_accept from './endpoints/following/requests/
|
|||
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
|
||||
import * as ep___following_requests_list from './endpoints/following/requests/list.js';
|
||||
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
|
||||
import * as ep___gallery_featured from './endpoints/gallery/featured.js';
|
||||
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
|
||||
import * as ep___gallery_posts from './endpoints/gallery/posts.js';
|
||||
import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js';
|
||||
import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js';
|
||||
import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js';
|
||||
import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js';
|
||||
import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js';
|
||||
import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js';
|
||||
import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js';
|
||||
import * as ep___hashtags_list from './endpoints/hashtags/list.js';
|
||||
import * as ep___hashtags_search from './endpoints/hashtags/search.js';
|
||||
|
@ -171,8 +162,6 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
|
|||
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
|
||||
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
|
||||
import * as ep___i_favorites from './endpoints/i/favorites.js';
|
||||
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
|
||||
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
|
||||
import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js';
|
||||
import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
|
||||
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
|
||||
|
@ -276,7 +265,6 @@ import * as ep___users from './endpoints/users.js';
|
|||
import * as ep___users_clips from './endpoints/users/clips.js';
|
||||
import * as ep___users_followers from './endpoints/users/followers.js';
|
||||
import * as ep___users_following from './endpoints/users/following.js';
|
||||
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
|
||||
import * as ep___users_groups_create from './endpoints/users/groups/create.js';
|
||||
import * as ep___users_groups_delete from './endpoints/users/groups/delete.js';
|
||||
import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js';
|
||||
|
@ -446,15 +434,6 @@ const eps = [
|
|||
['following/requests/cancel', ep___following_requests_cancel],
|
||||
['following/requests/list', ep___following_requests_list],
|
||||
['following/requests/reject', ep___following_requests_reject],
|
||||
['gallery/featured', ep___gallery_featured],
|
||||
['gallery/popular', ep___gallery_popular],
|
||||
['gallery/posts', ep___gallery_posts],
|
||||
['gallery/posts/create', ep___gallery_posts_create],
|
||||
['gallery/posts/delete', ep___gallery_posts_delete],
|
||||
['gallery/posts/like', ep___gallery_posts_like],
|
||||
['gallery/posts/show', ep___gallery_posts_show],
|
||||
['gallery/posts/unlike', ep___gallery_posts_unlike],
|
||||
['gallery/posts/update', ep___gallery_posts_update],
|
||||
['get-online-users-count', ep___getOnlineUsersCount],
|
||||
['hashtags/list', ep___hashtags_list],
|
||||
['hashtags/search', ep___hashtags_search],
|
||||
|
@ -479,8 +458,6 @@ const eps = [
|
|||
['i/export-notes', ep___i_exportNotes],
|
||||
['i/export-user-lists', ep___i_exportUserLists],
|
||||
['i/favorites', ep___i_favorites],
|
||||
['i/gallery/likes', ep___i_gallery_likes],
|
||||
['i/gallery/posts', ep___i_gallery_posts],
|
||||
['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount],
|
||||
['i/import-blocking', ep___i_importBlocking],
|
||||
['i/import-following', ep___i_importFollowing],
|
||||
|
@ -584,7 +561,6 @@ const eps = [
|
|||
['users/clips', ep___users_clips],
|
||||
['users/followers', ep___users_followers],
|
||||
['users/following', ep___users_following],
|
||||
['users/gallery/posts', ep___users_gallery_posts],
|
||||
['users/groups/create', ep___users_groups_create],
|
||||
['users/groups/delete', ep___users_groups_delete],
|
||||
['users/groups/invitations/accept', ep___users_groups_invitations_accept],
|
||||
|
@ -717,6 +693,14 @@ export interface IEndpointMeta {
|
|||
* @example (v0) /api/notes/create -> /api/v2/notes
|
||||
*/
|
||||
readonly alias?: string;
|
||||
|
||||
/**
|
||||
* If any path parameters were used, they have to be listed here.
|
||||
* Otherwise they will show up as query parameters in the documentation.
|
||||
*
|
||||
* Note: Path parameters cannot be optional.
|
||||
*/
|
||||
readonly pathParamers?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
description: 'Tries to fetch the given `uri` from the remote server.',
|
||||
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 30,
|
||||
|
|
|
@ -18,6 +18,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
description: 'Shows the requested object. If necessary, fetches the object from the remote server.',
|
||||
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 30,
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -20,7 +20,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.key = :key', { key: ps.key })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
|
|
@ -24,7 +24,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.key = :key', { key: ps.key })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
|
|
@ -20,7 +20,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
||||
|
|
|
@ -21,7 +21,6 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.select('item.key')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.key = :key', { key: ps.key })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
|
|
@ -17,7 +17,6 @@ export const paramDef = {
|
|||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.select('item.scope')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id });
|
||||
|
||||
const items = await query.getMany();
|
||||
|
|
|
@ -24,7 +24,6 @@ export const paramDef = {
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const query = RegistryItems.createQueryBuilder('item')
|
||||
.where('item.domain IS NULL')
|
||||
.andWhere('item.userId = :userId', { userId: user.id })
|
||||
.andWhere('item.key = :key', { key: ps.key })
|
||||
.andWhere('item.scope = :scope', { scope: ps.scope });
|
||||
|
@ -42,7 +41,6 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
userId: user.id,
|
||||
domain: null,
|
||||
scope: ps.scope,
|
||||
key: ps.key,
|
||||
value: ps.value,
|
||||
|
|
|
@ -25,6 +25,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/children',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/clips',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -22,6 +22,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/conversation',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { In } from 'typeorm';
|
||||
import { noteVisibilities } from 'foundkey-js';
|
||||
import { noteVisibilities, entities } from 'foundkey-js';
|
||||
import create from '@/services/note/create.js';
|
||||
import { User } from '@/models/entities/user.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 { Channel } from '@/models/entities/channel.js';
|
||||
import { HOUR } from '@/const.js';
|
||||
import { isPureRenote } from '@/misc/renote.js';
|
||||
import config from '@/config/index.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import define from '../../define.js';
|
||||
|
@ -160,7 +159,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
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
|
||||
if (noteVisibilities.indexOf(renote.visibility) > noteVisibilities.indexOf(ps.visibility)) {
|
||||
|
@ -185,7 +184,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
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
|
||||
if (noteVisibilities.indexOf(reply.visibility) > noteVisibilities.indexOf(ps.visibility)) {
|
||||
|
|
|
@ -14,13 +14,15 @@ export const meta = {
|
|||
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 300,
|
||||
minInterval: SECOND,
|
||||
max: 30,
|
||||
minInterval: 10 * SECOND,
|
||||
key: 'delete',
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'delete',
|
||||
alias: 'notes/:noteId',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['ACCESS_DENIED', 'NO_SUCH_NOTE'],
|
||||
|
|
|
@ -25,7 +25,8 @@ export const meta = {
|
|||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/reactions/:type?',
|
||||
alias: 'notes/:noteId/reactions',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -13,8 +13,9 @@ export const meta = {
|
|||
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 60,
|
||||
minInterval: 3 * SECOND,
|
||||
max: 30,
|
||||
minInterval: 10 * SECOND,
|
||||
key: 'delete',
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE', 'NOT_REACTED'],
|
||||
|
|
|
@ -25,6 +25,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/renotes',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -25,6 +25,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/replies',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -17,6 +17,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -30,6 +30,7 @@ export const meta = {
|
|||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/status',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -57,7 +57,8 @@ export const meta = {
|
|||
|
||||
v2: {
|
||||
method: 'get',
|
||||
alias: 'notes/:noteId/translate/:targetLang/:sourceLang?',
|
||||
alias: 'notes/:noteId/translate/:targetLang',
|
||||
pathParameters: ['noteId', 'targetLang'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -14,13 +14,15 @@ export const meta = {
|
|||
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 300,
|
||||
minInterval: SECOND,
|
||||
max: 30,
|
||||
minInterval: 10 * SECOND,
|
||||
key: 'delete',
|
||||
},
|
||||
|
||||
v2: {
|
||||
method: 'delete',
|
||||
alias: 'notes/:noteId/renotes',
|
||||
pathParameters: ['noteId'],
|
||||
},
|
||||
|
||||
errors: ['NO_SUCH_NOTE'],
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -29,8 +29,16 @@ export class ApiError extends Error {
|
|||
*/
|
||||
public apply(ctx: Koa.Context, endpoint: string): void {
|
||||
ctx.status = this.httpStatusCode;
|
||||
if (ctx.status === 401) {
|
||||
// set additional headers
|
||||
switch (ctx.status) {
|
||||
case 401:
|
||||
ctx.response.set('WWW-Authenticate', 'Bearer');
|
||||
break;
|
||||
case 429:
|
||||
if (typeof this.info === 'object' && typeof this.info.reset === 'number') {
|
||||
ctx.respose.set('Retry-After', Math.floor(this.info.reset - (Date.now() / 1000)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
ctx.body = {
|
||||
error: {
|
||||
|
@ -73,7 +81,7 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
|
|||
httpStatusCode: 409,
|
||||
},
|
||||
ALREADY_LIKED: {
|
||||
message: 'You already liked that page or gallery post.',
|
||||
message: 'You already liked that page.',
|
||||
httpStatusCode: 409,
|
||||
},
|
||||
ALREADY_MUTING: {
|
||||
|
@ -292,10 +300,6 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
|
|||
message: 'No such parent folder.',
|
||||
httpStatusCode: 404,
|
||||
},
|
||||
NO_SUCH_POST: {
|
||||
message: 'No such gallery post.',
|
||||
httpStatusCode: 404,
|
||||
},
|
||||
NO_SUCH_RESET_REQUEST: {
|
||||
message: 'No such password reset request.',
|
||||
httpStatusCode: 404,
|
||||
|
@ -337,7 +341,7 @@ export const errors: Record<string, { message: string, httpStatusCode: number }>
|
|||
httpStatusCode: 409,
|
||||
},
|
||||
NOT_LIKED: {
|
||||
message: 'You have not liked that page or gallery post.',
|
||||
message: 'You have not liked that page.',
|
||||
httpStatusCode: 409,
|
||||
},
|
||||
NOT_MUTING: {
|
||||
|
|
|
@ -2,11 +2,12 @@ import Limiter from 'ratelimiter';
|
|||
import Logger from '@/services/logger.js';
|
||||
import { redisClient } from '@/db/redis.js';
|
||||
import { IEndpointMeta } from './endpoints.js';
|
||||
import { ApiError } from './error.js';
|
||||
|
||||
const logger = new Logger('limiter');
|
||||
|
||||
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
|
||||
if (process.env.NODE_ENV === 'test') ok();
|
||||
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((resolve) => {
|
||||
if (process.env.NODE_ENV === 'test') resolve();
|
||||
|
||||
const hasShortTermLimit = typeof limitation.minInterval === 'number';
|
||||
|
||||
|
@ -19,10 +20,10 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
|
|||
} else if (hasLongTermLimit) {
|
||||
max();
|
||||
} else {
|
||||
ok();
|
||||
resolve();
|
||||
}
|
||||
|
||||
// Short-term limit
|
||||
// Short-term limit, calls long term limit if appropriate.
|
||||
function min(): void {
|
||||
const minIntervalLimiter = new Limiter({
|
||||
id: `${actor}:${limitation.key}:min`,
|
||||
|
@ -33,18 +34,19 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
|
|||
|
||||
minIntervalLimiter.get((err, info) => {
|
||||
if (err) {
|
||||
return reject('ERR');
|
||||
logger.error(err);
|
||||
throw new ApiError('INTERNAL_ERROR');
|
||||
}
|
||||
|
||||
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
||||
|
||||
if (info.remaining === 0) {
|
||||
reject('BRIEF_REQUEST_INTERVAL');
|
||||
throw new ApiError('RATE_LIMIT_EXCEEDED', info);
|
||||
} else {
|
||||
if (hasLongTermLimit) {
|
||||
max();
|
||||
} else {
|
||||
ok();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -61,15 +63,16 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
|
|||
|
||||
limiter.get((err, info) => {
|
||||
if (err) {
|
||||
return reject('ERR');
|
||||
logger.error(err);
|
||||
throw new ApiError('INTERNAL_ERROR');
|
||||
}
|
||||
|
||||
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
||||
|
||||
if (info.remaining === 0) {
|
||||
reject('RATE_LIMIT_EXCEEDED');
|
||||
throw new ApiError('RATE_LIMIT_EXCEEDED', info);
|
||||
} else {
|
||||
ok();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -126,11 +126,21 @@ export function genOpenapiSpec() {
|
|||
};
|
||||
}
|
||||
|
||||
let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n';
|
||||
let desc = endpoint.meta.description ?? 'No description provided.';
|
||||
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
|
||||
if (endpoint.meta.kind) {
|
||||
const kind = endpoint.meta.kind;
|
||||
desc += ` / **Permission**: *${kind}*`;
|
||||
desc += '\n\n**Permission**: `' + endpoint.meta.kind + '`';
|
||||
}
|
||||
if (endpoint.meta.limit) {
|
||||
const limit = endpoint.meta.limit;
|
||||
|
||||
desc += '\n### Rate limit\nRate limiting group: `' + (limit.key ?? endpoint.name) + '`';
|
||||
if (limit.duration && limit.max) {
|
||||
desc += ` \nNo more than ${limit.max} requests every ${limit.duration} ms.`;
|
||||
}
|
||||
if (limit.minInterval) {
|
||||
desc += ` \nMinimum delay between each request: ${endpoint.meta.limit.minInterval} ms.`;
|
||||
}
|
||||
}
|
||||
|
||||
const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
|
||||
|
@ -183,6 +193,7 @@ export function genOpenapiSpec() {
|
|||
},
|
||||
},
|
||||
responses,
|
||||
deprecated: endpoint.meta.stability === 'deprecated',
|
||||
};
|
||||
|
||||
const path = {
|
||||
|
@ -209,11 +220,37 @@ export function genOpenapiSpec() {
|
|||
spec.paths['/' + endpoint.name] = path;
|
||||
|
||||
if (endpoint.meta.v2) {
|
||||
const route = `/v2/${endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_')}`;
|
||||
// we need a clone of the API endpoint info because otherwise we change it by reference
|
||||
const infoClone = structuredClone(info);
|
||||
const route = `/v2/${endpoint.meta.v2.alias ?? endpoint.name.replace(/-/g, '_')}`;
|
||||
// fix the way parameters are passed
|
||||
const hasBody = !(endpoint.meta.v2.method === 'get' || endpoint.meta.v2.method === 'delete');
|
||||
if (!hasBody) {
|
||||
// these methods do not (usually) have a body
|
||||
delete infoClone.requestBody;
|
||||
infoClone.parameters = [];
|
||||
for (const name in schema.properties) {
|
||||
infoClone.parameters.push({
|
||||
name,
|
||||
in: endpoint.meta.v2?.pathParameters?.includes(name) ? 'path' : 'query',
|
||||
schema: schema.properties[name],
|
||||
required: endpoint.meta.v2?.pathParameters?.includes(name) || schema.required?.includes(name) || false,
|
||||
});
|
||||
}
|
||||
} else if (endpoint.meta.v2.pathParameters) {
|
||||
for (const name in endpoint.meta.v2.pathParameters) {
|
||||
delete infoClone.requestBody.content[requestType].schema.properties[name];
|
||||
infoClone.parameters.push({
|
||||
name,
|
||||
in: 'path',
|
||||
schema: schema.properties[name],
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
infoClone['operationId'] = infoClone['summary'] = route;
|
||||
infoClone['operationId'] = endpoint.meta.v2.method.toUpperCase() + '/' + route;
|
||||
infoClone['summary'] = endpoint.meta.v2.method.toUpperCase() + ' ' + route;
|
||||
|
||||
spec.paths[route] = {
|
||||
...spec.paths[route],
|
||||
|
|
|
@ -25,13 +25,8 @@ export default async (ctx: Koa.Context) => {
|
|||
new ApiError(e, info).apply(ctx, 'signin');
|
||||
}
|
||||
|
||||
try {
|
||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
|
||||
} catch (err) {
|
||||
error('RATE_LIMIT_EXCEEDED');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof username !== 'string') {
|
||||
error('INVALID_PARAM', { param: 'username', reason: 'not a string' });
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import * as websocket from 'websocket';
|
||||
import { WebSocket } from 'ws';
|
||||
import { readNote } from '@/services/note/read.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Channel as ChannelModel } from '@/models/entities/channel.js';
|
||||
|
@ -13,6 +13,9 @@ import { readNotification } from '../common/read-notification.js';
|
|||
import channels from './channels/index.js';
|
||||
import Channel from './channel.js';
|
||||
import { StreamEventEmitter, StreamMessages } from './types.js';
|
||||
import Logger from '@/services/logger.js';
|
||||
|
||||
const logger = new Logger('streaming');
|
||||
|
||||
/**
|
||||
* Main stream connection
|
||||
|
@ -26,29 +29,29 @@ export class Connection {
|
|||
public blocking: Set<User['id']> = new Set(); // "被"blocking
|
||||
public followingChannels: Set<ChannelModel['id']> = new Set();
|
||||
public token?: AccessToken;
|
||||
private wsConnection: websocket.connection;
|
||||
private socket: WebSocket;
|
||||
public subscriber: StreamEventEmitter;
|
||||
private channels: Channel[] = [];
|
||||
private subscribingNotes: any = {};
|
||||
private cachedNotes: Packed<'Note'>[] = [];
|
||||
|
||||
constructor(
|
||||
wsConnection: websocket.connection,
|
||||
socket: WebSocket,
|
||||
subscriber: EventEmitter,
|
||||
user: User | null | undefined,
|
||||
token: AccessToken | null | undefined,
|
||||
) {
|
||||
this.wsConnection = wsConnection;
|
||||
this.socket = socket;
|
||||
this.subscriber = subscriber;
|
||||
if (user) this.user = user;
|
||||
if (token) this.token = token;
|
||||
|
||||
this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this);
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onUserEvent = this.onUserEvent.bind(this);
|
||||
this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this);
|
||||
this.onBroadcastMessage = this.onBroadcastMessage.bind(this);
|
||||
|
||||
this.wsConnection.on('message', this.onWsConnectionMessage);
|
||||
this.socket.on('message', this.onMessage);
|
||||
|
||||
this.subscriber.on('broadcast', data => {
|
||||
this.onBroadcastMessage(data);
|
||||
|
@ -113,7 +116,7 @@ export class Connection {
|
|||
break;
|
||||
|
||||
case 'terminate':
|
||||
this.wsConnection.close();
|
||||
this.socket.close();
|
||||
this.dispose();
|
||||
break;
|
||||
|
||||
|
@ -122,40 +125,58 @@ export class Connection {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* クライアントからメッセージ受信時
|
||||
*/
|
||||
private async onWsConnectionMessage(data: websocket.Message) {
|
||||
if (data.type !== 'utf8') return;
|
||||
if (data.utf8Data == null) return;
|
||||
private async onMessage(data: WebSocket.RawData, isRaw: boolean) {
|
||||
if (isRaw) {
|
||||
logger.warn('received unexpected raw data from websocket');
|
||||
return;
|
||||
}
|
||||
|
||||
let obj: Record<string, any>;
|
||||
|
||||
try {
|
||||
obj = JSON.parse(data.utf8Data);
|
||||
} catch (e) {
|
||||
obj = JSON.parse(data);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, body } = obj;
|
||||
|
||||
switch (type) {
|
||||
case 'readNotification': this.onReadNotification(body); break;
|
||||
case 'subNote': this.onSubscribeNote(body); break;
|
||||
case 's': this.onSubscribeNote(body); break; // alias
|
||||
case 'sr': this.onSubscribeNote(body); this.readNote(body); break;
|
||||
case 'unsubNote': this.onUnsubscribeNote(body); break;
|
||||
case 'un': this.onUnsubscribeNote(body); break; // alias
|
||||
case 'connect': this.onChannelConnectRequested(body); break;
|
||||
case 'disconnect': this.onChannelDisconnectRequested(body); break;
|
||||
case 'channel': this.onChannelMessageRequested(body); break;
|
||||
case 'ch': this.onChannelMessageRequested(body); break; // alias
|
||||
case 'readNotification':
|
||||
this.onReadNotification(body);
|
||||
break;
|
||||
case 'subNote': case 's':
|
||||
this.onSubscribeNote(body);
|
||||
break;
|
||||
case 'sr':
|
||||
this.onSubscribeNote(body);
|
||||
this.readNote(body);
|
||||
break;
|
||||
case 'unsubNote': case 'un':
|
||||
this.onUnsubscribeNote(body);
|
||||
break;
|
||||
case 'connect':
|
||||
this.onChannelConnectRequested(body);
|
||||
break;
|
||||
case 'disconnect':
|
||||
this.onChannelDisconnectRequested(body);
|
||||
break;
|
||||
case 'channel': case 'ch':
|
||||
this.onChannelMessageRequested(body);
|
||||
break;
|
||||
|
||||
// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
|
||||
// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
|
||||
// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
|
||||
case 'typingOnChannel': this.typingOnChannel(body.channel); break;
|
||||
case 'typingOnMessaging': this.typingOnMessaging(body); break;
|
||||
// The reason for receiving these messages at the root level rather than in
|
||||
// individual channels is that when considering the client's circumstances, the
|
||||
// input form may be separate from the main components of the note channel or
|
||||
// message, and it would be cumbersome to have each of those components connect to
|
||||
// each channel.
|
||||
case 'typingOnChannel':
|
||||
this.typingOnChannel(body.channel);
|
||||
break;
|
||||
case 'typingOnMessaging':
|
||||
this.typingOnMessaging(body);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -259,7 +280,7 @@ export class Connection {
|
|||
* クライアントにメッセージ送信
|
||||
*/
|
||||
public sendMessageToWs(type: string, payload: any) {
|
||||
this.wsConnection.send(JSON.stringify({
|
||||
this.socket.send(JSON.stringify({
|
||||
type,
|
||||
body: payload,
|
||||
}));
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { ParsedUrlQuery } from 'querystring';
|
||||
import * as http from 'node:http';
|
||||
import * as websocket from 'websocket';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { SECOND, MINUTE } from '@/const.js';
|
||||
import { subscriber as redisClient } from '@/db/redis.js';
|
||||
import { Users } from '@/models/index.js';
|
||||
import { Connection } from './stream/index.js';
|
||||
|
@ -10,29 +10,28 @@ import authenticate from './authenticate.js';
|
|||
|
||||
export const initializeStreamingServer = (server: http.Server): void => {
|
||||
// Init websocket server
|
||||
const ws = new websocket.server({
|
||||
httpServer: server,
|
||||
});
|
||||
const ws = new WebSocketServer({ noServer: true });
|
||||
|
||||
ws.on('request', async (request): Promise<void> => {
|
||||
const q = request.resourceURL.query as ParsedUrlQuery;
|
||||
server.on('upgrade', async (request, socket, head)=> {
|
||||
if (!request.url.startsWith('/streaming?')) {
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n', undefined, () => socket.destroy());
|
||||
return;
|
||||
}
|
||||
const q = new URLSearchParams(request.url.slice(11));
|
||||
|
||||
const [user, app] = await authenticate(request.httpRequest.headers.authorization, q.i)
|
||||
const [user, app] = await authenticate(request.headers.authorization, q.get('i'))
|
||||
.catch(err => {
|
||||
request.reject(403, err.message);
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n', undefined, () => socket.destroy());
|
||||
return [];
|
||||
});
|
||||
if (typeof user === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (typeof user === 'undefined') return;
|
||||
|
||||
if (user?.isSuspended) {
|
||||
request.reject(400);
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n', undefined, () => socket.destroy());
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = request.accept();
|
||||
|
||||
ws.handleUpgrade(request, socket, head, (socket) => {
|
||||
const ev = new EventEmitter();
|
||||
|
||||
async function onRedisMessage(_: string, data: string) {
|
||||
|
@ -42,30 +41,41 @@ export const initializeStreamingServer = (server: http.Server): void => {
|
|||
|
||||
redisClient.on('message', onRedisMessage);
|
||||
|
||||
const main = new Connection(connection, 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
|
||||
const intervalId = user ? setInterval(() => {
|
||||
Users.update(user.id, {
|
||||
lastActiveDate: new Date(),
|
||||
});
|
||||
}, 1000 * 60 * 5) : null;
|
||||
}, 5 * MINUTE) : null;
|
||||
if (user) {
|
||||
Users.update(user.id, {
|
||||
lastActiveDate: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
connection.once('close', () => {
|
||||
socket.once('close', () => {
|
||||
ev.removeAllListeners();
|
||||
main.dispose();
|
||||
redisClient.off('message', onRedisMessage);
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
});
|
||||
|
||||
connection.on('message', async (data) => {
|
||||
if (data.type === 'utf8' && data.utf8Data === 'ping') {
|
||||
connection.send('pong');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -18,7 +18,7 @@ import { KoaAdapter } from '@bull-board/koa';
|
|||
import { In, IsNull } from 'typeorm';
|
||||
import { fetchMeta } from '@/misc/fetch-meta.js';
|
||||
import config from '@/config/index.js';
|
||||
import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '@/models/index.js';
|
||||
import { Users, Notes, UserProfiles, Pages, Channels, Clips, DriveFiles } from '@/models/index.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import { queues } from '@/queue/queues.js';
|
||||
|
@ -324,15 +324,75 @@ router.get('/notes/:note', async (ctx, next) => {
|
|||
if (note) {
|
||||
try {
|
||||
// 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 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', {
|
||||
note: _note,
|
||||
note: packedNote,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: note.userId })),
|
||||
filesOpengraph,
|
||||
// TODO: Let locale changeable by instance setting
|
||||
summary: getNoteSummary(_note),
|
||||
summary: getNoteSummary(packedNote),
|
||||
instanceName: meta.name || 'FoundKey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
|
@ -421,31 +481,6 @@ router.get('/clips/:clip', async (ctx, next) => {
|
|||
await next();
|
||||
});
|
||||
|
||||
// Gallery post
|
||||
router.get('/gallery/:post', async (ctx, next) => {
|
||||
const post = await GalleryPosts.findOneBy({ id: ctx.params.post });
|
||||
|
||||
if (post) {
|
||||
const _post = await GalleryPosts.pack(post);
|
||||
const profile = await UserProfiles.findOneByOrFail({ userId: post.userId });
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render('gallery-post', {
|
||||
post: _post,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({ id: post.userId })),
|
||||
instanceName: meta.name || 'FoundKey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
});
|
||||
|
||||
ctx.set('Cache-Control', 'public, max-age=15');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// Channel
|
||||
router.get('/channels/:channel', async (ctx, next) => {
|
||||
const channel = await Channels.findOneBy({
|
||||
|
|
|
@ -12,20 +12,12 @@ block desc
|
|||
meta(name='description' content= clip.description)
|
||||
|
||||
block og
|
||||
meta(property='og:type' content='article')
|
||||
meta(property='og:title' content= title)
|
||||
meta(property='og:description' content= clip.description)
|
||||
meta(property='og:url' content= url)
|
||||
meta(property='og:image' content= avatarUrl)
|
||||
meta(property='og:type' content='website')
|
||||
meta(property='og:title' content=title)
|
||||
meta(property='og:description' content=clip.description)
|
||||
meta(property='og:url' content=url)
|
||||
meta(property='og:image' content=avatarUrl)
|
||||
|
||||
block meta
|
||||
if profile.noCrawle
|
||||
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}`)
|
||||
|
|
|
@ -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')
|
|
@ -14,23 +14,18 @@ block desc
|
|||
|
||||
block og
|
||||
meta(property='og:type' content='article')
|
||||
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:url' content= url)
|
||||
meta(property='og:image' content= avatarUrl)
|
||||
for opengraphTag in filesOpengraph
|
||||
meta(property=opengraphTag[0] content=opengraphTag[1])
|
||||
|
||||
block meta
|
||||
if user.host || isRenote || profile.noCrawle
|
||||
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
|
||||
link(rel='prev' href=`${config.url}/notes/${note.prev}`)
|
||||
if note.next
|
||||
|
|
|
@ -13,19 +13,12 @@ block desc
|
|||
|
||||
block og
|
||||
meta(property='og:type' content='article')
|
||||
meta(property='og:title' content= title)
|
||||
meta(property='og:description' content= page.summary)
|
||||
meta(property='og:url' content= url)
|
||||
meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl)
|
||||
meta(property='og:article:author:username' content=user.username)
|
||||
meta(property='og:title' content=title)
|
||||
meta(property='og:description' content=page.summary)
|
||||
meta(property='og:url' content=url)
|
||||
meta(property='og:image' content=page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl)
|
||||
|
||||
block meta
|
||||
if profile.noCrawle
|
||||
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}`)
|
||||
|
|
|
@ -11,19 +11,16 @@ block desc
|
|||
meta(name='description' content= profile.description)
|
||||
|
||||
block og
|
||||
meta(property='og:type' content='blog')
|
||||
meta(property='og:title' content= title)
|
||||
meta(property='og:type' content='profile')
|
||||
meta(property='og:profile:username' content=user.username)
|
||||
meta(property='og:description' content= profile.description)
|
||||
meta(property='og:url' content= url)
|
||||
meta(property='og:image' content= avatarUrl)
|
||||
meta(property='og:url' content=url)
|
||||
meta(property='og:image' content=avatarUrl)
|
||||
|
||||
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)
|
||||
|
||||
if profile.twitter
|
||||
meta(name='twitter:creator' content=`@${profile.twitter.screenName}`)
|
||||
|
||||
|
|