Compare commits
23 commits
b1b6eb2a86
...
9cfc7c1027
Author | SHA1 | Date | |
---|---|---|---|
9cfc7c1027 | |||
6a40ef3569 | |||
|
09fe55379e | ||
27b912b9b0 | |||
48fd543d0f | |||
|
af272ce358 | ||
c1ae134c0a | |||
3ad6323c23 | |||
3489c8ac3a | |||
06ef752218 | |||
44f02fa3ec | |||
d655bda30c | |||
839daea887 | |||
41c42f96f0 | |||
9a6bb8be7d | |||
1adf88b090 | |||
28c11ca7af | |||
9458045c8f | |||
a8c0e1f827 | |||
63665e8bd1 | |||
85a68a5eee | |||
0bb4a6af50 | |||
a45908c1cb |
|
@ -6,10 +6,11 @@
|
||||||
#───┘ URL └─────────────────────────────────────────────────────
|
#───┘ URL └─────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Final accessible URL seen by a user.
|
# Final accessible URL seen by a user.
|
||||||
url: https://example.tld/
|
# Only the host part will be used.
|
||||||
|
|
||||||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||||
# URL SETTINGS AFTER THAT!
|
# URL SETTINGS AFTER THAT!
|
||||||
|
url: https://example.tld/
|
||||||
|
|
||||||
|
|
||||||
# ┌───────────────────────┐
|
# ┌───────────────────────┐
|
||||||
#───┘ Port and TLS settings └───────────────────────────────────
|
#───┘ Port and TLS settings └───────────────────────────────────
|
||||||
|
@ -45,6 +46,7 @@ db:
|
||||||
pass: example-foundkey-pass
|
pass: example-foundkey-pass
|
||||||
|
|
||||||
# Whether to disable query caching
|
# Whether to disable query caching
|
||||||
|
# Default is to cache, i.e. false.
|
||||||
#disableCache: true
|
#disableCache: true
|
||||||
|
|
||||||
# Extra connection options
|
# Extra connection options
|
||||||
|
@ -57,7 +59,11 @@ db:
|
||||||
redis:
|
redis:
|
||||||
host: localhost
|
host: localhost
|
||||||
port: 6379
|
port: 6379
|
||||||
#family: dual # can be either a number or string (0/dual, 4/ipv4, 6/ipv6)
|
# Address family to connect over.
|
||||||
|
# Can be either a number or string (0/dual, 4/ipv4, 6/ipv6)
|
||||||
|
# Default is "dual".
|
||||||
|
#family: dual
|
||||||
|
# The following properties are optional.
|
||||||
#pass: example-pass
|
#pass: example-pass
|
||||||
#prefix: example-prefix
|
#prefix: example-prefix
|
||||||
#db: 1
|
#db: 1
|
||||||
|
@ -65,6 +71,7 @@ redis:
|
||||||
# ┌─────────────────────────────┐
|
# ┌─────────────────────────────┐
|
||||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||||
|
|
||||||
|
# Elasticsearch is optional.
|
||||||
#elasticsearch:
|
#elasticsearch:
|
||||||
# host: localhost
|
# host: localhost
|
||||||
# port: 9200
|
# port: 9200
|
||||||
|
@ -75,35 +82,41 @@ redis:
|
||||||
# ┌─────────────────────┐
|
# ┌─────────────────────┐
|
||||||
#───┘ Other configuration └─────────────────────────────────────
|
#───┘ Other configuration └─────────────────────────────────────
|
||||||
|
|
||||||
# Whether disable HSTS
|
# Whether to disable HSTS (not recommended)
|
||||||
|
# Default is to enable HSTS, i.e. false.
|
||||||
#disableHsts: true
|
#disableHsts: true
|
||||||
|
|
||||||
# Number of worker processes by type.
|
# Number of worker processes by type.
|
||||||
# The sum must not exceed the number of available cores.
|
# The sum should not exceed the number of available cores.
|
||||||
#clusterLimits:
|
#clusterLimits:
|
||||||
# web: 1
|
# web: 1
|
||||||
# queue: 1
|
# queue: 1
|
||||||
|
|
||||||
# Job concurrency per worker
|
# Jobs each worker will try to work on at a time.
|
||||||
# deliverJobConcurrency: 128
|
#deliverJobConcurrency: 128
|
||||||
# inboxJobConcurrency: 16
|
#inboxJobConcurrency: 16
|
||||||
|
|
||||||
# Job rate limiter
|
# Rate limit for each Worker.
|
||||||
# deliverJobPerSec: 128
|
# Use -1 to disable.
|
||||||
# inboxJobPerSec: 16
|
# A rate limit for deliver jobs is not recommended as it comes with
|
||||||
|
# a big performance penalty due to overhead of rate limiting.
|
||||||
|
#deliverJobPerSec: -1
|
||||||
|
#inboxJobPerSec: 16
|
||||||
|
|
||||||
# Job attempts
|
# Number of times each job will be tried.
|
||||||
# deliverJobMaxAttempts: 12
|
# 1 means only try once and don't retry.
|
||||||
# inboxJobMaxAttempts: 8
|
#deliverJobMaxAttempts: 12
|
||||||
|
#inboxJobMaxAttempts: 8
|
||||||
|
|
||||||
# Syslog option
|
# Syslog option
|
||||||
#syslog:
|
#syslog:
|
||||||
# host: localhost
|
# host: localhost
|
||||||
# port: 514
|
# port: 514
|
||||||
|
|
||||||
# Proxy for HTTP/HTTPS
|
# Proxy for HTTP/HTTPS outgoing connections
|
||||||
#proxy: http://127.0.0.1:3128
|
#proxy: http://127.0.0.1:3128
|
||||||
|
|
||||||
|
# Hosts that should not be connected to through the proxy specified above
|
||||||
#proxyBypassHosts: [
|
#proxyBypassHosts: [
|
||||||
# 'example.com',
|
# 'example.com',
|
||||||
# '192.0.2.8'
|
# '192.0.2.8'
|
||||||
|
@ -117,7 +130,8 @@ redis:
|
||||||
# Media Proxy
|
# Media Proxy
|
||||||
#mediaProxy: https://example.com/proxy
|
#mediaProxy: https://example.com/proxy
|
||||||
|
|
||||||
# Proxy remote files (default: false)
|
# Proxy remote files
|
||||||
|
# Default is to not proxy remote files, i.e. false.
|
||||||
#proxyRemoteFiles: true
|
#proxyRemoteFiles: true
|
||||||
|
|
||||||
# Storage path for files if stored locally (absolute path)
|
# Storage path for files if stored locally (absolute path)
|
||||||
|
@ -125,11 +139,15 @@ redis:
|
||||||
#internalStoragePath: '/etc/foundkey/files'
|
#internalStoragePath: '/etc/foundkey/files'
|
||||||
|
|
||||||
# Upload or download file size limits (bytes)
|
# Upload or download file size limits (bytes)
|
||||||
|
# default is 262144000 = 250MiB
|
||||||
#maxFileSize: 262144000
|
#maxFileSize: 262144000
|
||||||
|
|
||||||
# Max note text length (in characters)
|
# Max note text length (in characters)
|
||||||
#maxNoteTextLength: 3000
|
#maxNoteTextLength: 3000
|
||||||
|
|
||||||
|
# By default, Foundkey will fail when something tries to make it fetch something from private IPs.
|
||||||
|
# With the following setting you can explicitly allow some private CIDR subnets.
|
||||||
|
# Default is an empty list, i.e. none allowed.
|
||||||
#allowedPrivateNetworks: [
|
#allowedPrivateNetworks: [
|
||||||
# '127.0.0.1/32'
|
# '127.0.0.1/32'
|
||||||
#]
|
#]
|
||||||
|
|
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.
|
Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from.
|
||||||
If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead.
|
If you are a contributor, please read [CONTRIBUTING.md, section "Changelog Trailer"](./CONTRIBUTING.md#changelog-trailer) on what to do instead.
|
||||||
|
|
||||||
|
## 13.0.0-preview4 - 2023-02-05
|
||||||
|
This release contains 6 breaking changes, including changes to the configuration file format.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- new Foundkey logo
|
||||||
|
- client: add button to unrenote/remove all own renotes
|
||||||
|
- client: add mod tracker
|
||||||
|
- client: add button to delete all files of a user for moderators
|
||||||
|
- server: implement OAuth 2.0 Authorization Code grant
|
||||||
|
- server: add config for error images
|
||||||
|
- server: expire notifications after 3 months
|
||||||
|
- server: start adding /api/v2 routes
|
||||||
|
- server: indicate Retry-After when rate limiting
|
||||||
|
- docs: show rate limit information
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** server: implement separate web workers
|
||||||
|
The configuration file format has been changed: The `clusterLimit` item has been removed
|
||||||
|
and `clusterLimits` has been added instead. Check the example configuration file.
|
||||||
|
- **BREAKING** server: remove wildcard blocking and instead block subdomains (#269)
|
||||||
|
As an administrator you may need to check the list of blocked instances.
|
||||||
|
- **BREAKING** server: disable deliver rate limit by default
|
||||||
|
We found that the deliver rate limit causes a lot of load for no real benefit. Because of this,
|
||||||
|
it will be disabled by default. The default value of `deliverJobPerSec` is set to
|
||||||
|
disable this rate limit.
|
||||||
|
- server: adjust permissions for `/api/admin/accounts/delete`
|
||||||
|
The admin/accounts/delete endpoint now requries administrator privileges
|
||||||
|
instead of just moderator privileges.
|
||||||
|
- server: increase nodeinfo caching
|
||||||
|
- client: headlines in queue widget are links
|
||||||
|
- client: add tooltips to visibility icons
|
||||||
|
- server: improve error messages
|
||||||
|
- server: change default value for `/api/admin/show-users` origin param
|
||||||
|
- server: lower rate limit for deletion activities
|
||||||
|
Deleting things that result in federating a delete activity have a more strict rate limit.
|
||||||
|
This affects the following endpoints:
|
||||||
|
- `/api/notes/delete`
|
||||||
|
- `/api/notes/reactions/delete`
|
||||||
|
- `/api/notes/unrenote`
|
||||||
|
- server: improve OpenGraph data
|
||||||
|
- properly render note attachments as RDFa
|
||||||
|
- add more metadata about e.g. author
|
||||||
|
- proper OpenGraph data replaces custom `misskey:` RDFa tags
|
||||||
|
- activitypub: implement [FEP-e232](https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-e232.md) qoutes
|
||||||
|
- activitypub: use `quoteUri` instead of `quoteUrl`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- client: fix layout of app authorization page
|
||||||
|
- client: unify different error dialogs
|
||||||
|
- client: set display name limit same as server
|
||||||
|
- client: dont display instance banner tooltip if software name is unknown
|
||||||
|
- client: fix 500 error in notifications
|
||||||
|
- client: fix some tooltips not closing
|
||||||
|
- client: fix issue of search only working once
|
||||||
|
- client: check `quoteId` for canPost computation
|
||||||
|
- client: fix quotes with only a CW
|
||||||
|
- server: fix thread mutes not applying to renotes
|
||||||
|
- server: fix ReferenceError: meta is undefined
|
||||||
|
- server: fix TypeError in registerOrFetchInstanceDoc
|
||||||
|
- server: fix ratelimit in `/api/i/import-following`
|
||||||
|
- server: handle redirects in signed get
|
||||||
|
- server: remove reversi database tables
|
||||||
|
- server: set file permissions after copy
|
||||||
|
- server: also use human readable URL in search
|
||||||
|
- server: fix user deletion race condition
|
||||||
|
- server: add websocket ping mechanism
|
||||||
|
This should help keep websocket connections alive even if there are no events for
|
||||||
|
prolonged time periods. This should also fix issues where the "connection has been lost"
|
||||||
|
dialog appeared despite the connection being fine.
|
||||||
|
- activitypub: properly parse incoming hashtags
|
||||||
|
- activitypub: Do block checks more globally
|
||||||
|
- activitypub: properly render CW only quotes
|
||||||
|
|
||||||
|
### Removed:
|
||||||
|
- **BREAKING** server: remove Twitter, Github and Discord integrations
|
||||||
|
ff31b8b06 server: remove bios and cli
|
||||||
|
a673647fb server: remove avatarColor and bannerColor properties
|
||||||
|
- **BREAKING** server: remove `api/admin/delete-account`,
|
||||||
|
You should use the API endpoint `admin/accounts/delete` instead.
|
||||||
|
It has the same parameter and the same behaviour.
|
||||||
|
- **BREAKING** remove galleries
|
||||||
|
Galleries have been removed because low usage and duplication of other behaviour.
|
||||||
|
Existing gallery posts will be turned into ordinary notes.
|
||||||
|
If a user had any gallery posts, a new clip called "Gallery" will be created containing
|
||||||
|
all of the former gallery posts that are now notes.
|
||||||
|
This affects the following endpoints:
|
||||||
|
- `/api/gallery/featured`
|
||||||
|
- `/api/gallery/popular`
|
||||||
|
- `/api/gallery/posts`
|
||||||
|
- `/api/gallery/posts/create`
|
||||||
|
- `/api/gallery/posts/delete`
|
||||||
|
- `/api/gallery/posts/like`
|
||||||
|
- `/api/gallery/posts/show`
|
||||||
|
- `/api/gallery/posts/unlike`
|
||||||
|
- `/api/i/gallery/likes`
|
||||||
|
- `/api/i/gallery/posts`
|
||||||
|
- `/api/users/gallery/posts`
|
||||||
|
- server: remove application level websocket ping
|
||||||
|
This pinging mechanism was unused in `foundkey-js`, and we expect other usage to be low.
|
||||||
|
You can use the pinging mechanism built into the websocket protocol if you wish.
|
||||||
|
Note that the Server will now also send pings on its own (see *Fixed* section).
|
||||||
|
|
||||||
## 13.0.0-preview3 - 2022-12-02
|
## 13.0.0-preview3 - 2022-12-02
|
||||||
This release contains 1 urgent security fix necessitated by `misskey-forkbomb`.
|
This release contains 1 urgent security fix necessitated by `misskey-forkbomb`.
|
||||||
This release contains 1 breaking change.
|
This release contains 1 breaking change.
|
||||||
|
|
4
COPYING
|
@ -21,3 +21,7 @@ https://github.com/deskjet/chiptune2.js#license
|
||||||
libopenmpt (as part of openmpt) by OpenMPT
|
libopenmpt (as part of openmpt) by OpenMPT
|
||||||
License: BSD 3-Clause
|
License: BSD 3-Clause
|
||||||
https://github.com/OpenMPT/openmpt/blob/master/LICENSE
|
https://github.com/OpenMPT/openmpt/blob/master/LICENSE
|
||||||
|
|
||||||
|
The logo file (logo.svg) was created by Blinry
|
||||||
|
License: [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
|
https://blinry.org/
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
<div align="center"><img src="./logo.svg" height="200" alt="Foundkey logo, an owl holding a key"/></div>
|
||||||
|
|
||||||
# FoundKey
|
# FoundKey
|
||||||
FoundKey is a free and open source microblogging server compatible with ActivityPub. Forked from Misskey, FoundKey improves on maintainability and behaviour, while also bringing in useful features.
|
FoundKey is a free and open source microblogging server compatible with ActivityPub. Forked from Misskey, FoundKey improves on maintainability and behaviour, while also bringing in useful features.
|
||||||
|
|
||||||
|
@ -10,4 +12,5 @@ FoundKey's documentation is a work in progress. In the meantime, much of the doc
|
||||||
If you're interested in helping out with the project, please read the [contributing guide](./CONTRIBUTING.md).
|
If you're interested in helping out with the project, please read the [contributing guide](./CONTRIBUTING.md).
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
FoundKey is not interested in sponsorships.
|
FoundKey is not interested in finanical sponsorships.
|
||||||
|
We welcome contributions in the forms of code, testing and bug reporting (see also section *Contributing* above).
|
||||||
|
|
BIN
logo.svg
Normal file
After Width: | Height: | Size: 3.3 KiB |
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "foundkey",
|
"name": "foundkey",
|
||||||
"version": "13.0.0-preview3",
|
"version": "13.0.0-preview4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://akkoma.dev/FoundKeyGang/FoundKey.git"
|
"url": "https://akkoma.dev/FoundKeyGang/FoundKey.git"
|
||||||
|
|
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 |
|
@ -4,13 +4,19 @@ export class syncOrm1674499888924 {
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`COMMENT ON COLUMN "user"."token" IS 'The native access token of local users, or null.'`);
|
await queryRunner.query(`COMMENT ON COLUMN "user"."token" IS 'The native access token of local users, or null.'`);
|
||||||
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66"`);
|
await queryRunner.query(`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_71d35fceee0d0fa62b2fa8f3b2" ON "note" ("url") `);
|
||||||
|
|
||||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `);
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `);
|
||||||
}
|
}
|
||||||
|
|
||||||
async down(queryRunner) {
|
async down(queryRunner) {
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_d9ecaed8c6dc43f3592c229282"`);
|
await queryRunner.query(`DROP INDEX "public"."IDX_d9ecaed8c6dc43f3592c229282"`);
|
||||||
|
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_71d35fceee0d0fa62b2fa8f3b2"`);
|
await queryRunner.query(`DROP INDEX "public"."IDX_71d35fceee0d0fa62b2fa8f3b2"`);
|
||||||
|
|
||||||
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66" UNIQUE ("accessTokenId")`);
|
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.'`);
|
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.'`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ export class registryRemoveDomain1675375940759 {
|
||||||
await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "domain"`);
|
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`);
|
await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "key" TYPE text USING "key"::text`);
|
||||||
// delete existing duplicated entries, keeping the latest updated one
|
// 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(`DELETE FROM "registry_item" AS "a" WHERE "updatedAt" != (SELECT MAX("updatedAt") FROM "registry_item" AS "b" WHERE "a"."userId" = "b"."userId" AND "a"."key" = "b"."key" AND "a"."scope" = "b"."scope" GROUP BY "userId", "key", "scope")`);
|
||||||
await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "UQ_b8d6509f847331273ab99daccc7" UNIQUE ("userId", "key", "scope")`);
|
await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "UQ_b8d6509f847331273ab99daccc7" UNIQUE ("userId", "key", "scope")`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "13.0.0-preview3",
|
"version": "13.0.0-preview4",
|
||||||
"main": "./index.js",
|
"main": "./index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
@ -26,7 +26,7 @@ const path = process.env.NODE_ENV === 'test'
|
||||||
export default function load(): Config {
|
export default function load(): Config {
|
||||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
||||||
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8'));
|
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8'));
|
||||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
let config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||||
|
|
||||||
if (config.id && config.id !== 'aid') throw new Error('Unsupported ID algorithm. Only "aid" is supported.');
|
if (config.id && config.id !== 'aid') throw new Error('Unsupported ID algorithm. Only "aid" is supported.');
|
||||||
|
|
||||||
|
@ -38,13 +38,30 @@ export default function load(): Config {
|
||||||
|
|
||||||
config.port = config.port || parseInt(process.env.PORT || '', 10);
|
config.port = config.port || parseInt(process.env.PORT || '', 10);
|
||||||
|
|
||||||
|
// set default values
|
||||||
config.images = Object.assign({
|
config.images = Object.assign({
|
||||||
info: '/twemoji/1f440.svg',
|
info: '/twemoji/1f440.svg',
|
||||||
notFound: '/twemoji/2049.svg',
|
notFound: '/twemoji/2049.svg',
|
||||||
error: '/twemoji/1f480.svg',
|
error: '/twemoji/1f480.svg',
|
||||||
}, config.images ?? {});
|
}, config.images ?? {});
|
||||||
|
|
||||||
if (!config.maxNoteTextLength) config.maxNoteTextLength = 3000;
|
config.clusterLimits = Object.assign({
|
||||||
|
web: 1,
|
||||||
|
queue: 1,
|
||||||
|
}, config.clusterLimits ?? {});
|
||||||
|
|
||||||
|
config = Object.assign({
|
||||||
|
disableHsts: false,
|
||||||
|
deliverJobConcurrency: 128,
|
||||||
|
inboxJobConcurrency: 16,
|
||||||
|
deliverJobPerSec: -1,
|
||||||
|
inboxJobPerSec: 16,
|
||||||
|
deliverJobMaxAttempts: 12,
|
||||||
|
inboxJobMaxAttempts: 8,
|
||||||
|
proxyRemoteFiles: false,
|
||||||
|
maxFileSize: 262144000, // 250 MiB
|
||||||
|
maxNoteTextLength: 3000,
|
||||||
|
}, config);
|
||||||
|
|
||||||
mixin.version = meta.version;
|
mixin.version = meta.version;
|
||||||
mixin.host = url.host;
|
mixin.host = url.host;
|
||||||
|
@ -60,21 +77,8 @@ export default function load(): Config {
|
||||||
|
|
||||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||||
|
|
||||||
if (!config.clusterLimits) {
|
if (config.clusterLimits.web < 1 || config.clusterLimits.queue < 1) {
|
||||||
config.clusterLimits = {
|
throw new Error('invalid cluster limits');
|
||||||
web: 1,
|
|
||||||
queue: 1,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
config.clusterLimits = {
|
|
||||||
web: 1,
|
|
||||||
queue: 1,
|
|
||||||
...config.clusterLimits,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.clusterLimits.web < 1 || config.clusterLimits.queue < 1) {
|
|
||||||
throw new Error('invalid cluster limits');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign(config, mixin);
|
return Object.assign(config, mixin);
|
||||||
|
|
|
@ -19,7 +19,6 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
|
||||||
|
|
||||||
const timeout = 30 * SECOND;
|
const timeout = 30 * SECOND;
|
||||||
const operationTimeout = MINUTE;
|
const operationTimeout = MINUTE;
|
||||||
const maxSize = config.maxFileSize || 262144000;
|
|
||||||
|
|
||||||
const req = got.stream(url, {
|
const req = got.stream(url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -53,14 +52,14 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
|
||||||
const contentLength = res.headers['content-length'];
|
const contentLength = res.headers['content-length'];
|
||||||
if (contentLength != null) {
|
if (contentLength != null) {
|
||||||
const size = Number(contentLength);
|
const size = Number(contentLength);
|
||||||
if (size > maxSize) {
|
if (size > config.maxFileSize) {
|
||||||
logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
|
logger.warn(`maxSize exceeded (${size} > ${config.maxFileSize}) on response`);
|
||||||
req.destroy();
|
req.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||||
if (progress.transferred > maxSize) {
|
if (progress.transferred > config.maxFileSize) {
|
||||||
logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
|
logger.warn(`maxSize exceeded (${progress.transferred} > ${config.maxFileSize}) on downloadProgress`);
|
||||||
req.destroy();
|
req.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -89,7 +89,7 @@ const _https = new https.Agent({
|
||||||
lookup: cache.lookup,
|
lookup: cache.lookup,
|
||||||
} as https.AgentOptions);
|
} as https.AgentOptions);
|
||||||
|
|
||||||
const maxSockets = Math.max(256, config.deliverJobConcurrency || 128);
|
const maxSockets = Math.max(256, config.deliverJobConcurrency);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get http proxy or non-proxy agent
|
* Get http proxy or non-proxy agent
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export function safeForSql(text: string): boolean {
|
|
||||||
return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
|
|
||||||
}
|
|
|
@ -96,7 +96,7 @@ export function deliver(user: ThinUser, content: unknown, to: string | null) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return deliverQueue.add(data, {
|
return deliverQueue.add(data, {
|
||||||
attempts: config.deliverJobMaxAttempts || 12,
|
attempts: config.deliverJobMaxAttempts,
|
||||||
timeout: MINUTE,
|
timeout: MINUTE,
|
||||||
backoff: {
|
backoff: {
|
||||||
type: 'apBackoff',
|
type: 'apBackoff',
|
||||||
|
@ -113,7 +113,7 @@ export function inbox(activity: IActivity, signature: httpSignature.IParsedSigna
|
||||||
};
|
};
|
||||||
|
|
||||||
return inboxQueue.add(data, {
|
return inboxQueue.add(data, {
|
||||||
attempts: config.inboxJobMaxAttempts || 8,
|
attempts: config.inboxJobMaxAttempts,
|
||||||
timeout: 5 * MINUTE,
|
timeout: 5 * MINUTE,
|
||||||
backoff: {
|
backoff: {
|
||||||
type: 'apBackoff',
|
type: 'apBackoff',
|
||||||
|
@ -291,8 +291,8 @@ export function webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[
|
||||||
export default function() {
|
export default function() {
|
||||||
if (envOption.onlyServer) return;
|
if (envOption.onlyServer) return;
|
||||||
|
|
||||||
deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver);
|
deliverQueue.process(config.deliverJobConcurrency, processDeliver);
|
||||||
inboxQueue.process(config.inboxJobConcurrency || 16, processInbox);
|
inboxQueue.process(config.inboxJobConcurrency, processInbox);
|
||||||
endedPollNotificationQueue.process(endedPollNotification);
|
endedPollNotificationQueue.process(endedPollNotification);
|
||||||
webhookDeliverQueue.process(64, processWebhookDeliver);
|
webhookDeliverQueue.process(64, processWebhookDeliver);
|
||||||
processDb(dbQueue);
|
processDb(dbQueue);
|
||||||
|
|
|
@ -58,6 +58,10 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const emoji of customEmojis) {
|
for (const emoji of customEmojis) {
|
||||||
|
if (!/^[a-zA-Z0-9_]+$/.test(emoji.name)) {
|
||||||
|
this.logger.error(`invalid emoji name: ${emoji.name}, skipping in emoji export`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const ext = mime.extension(emoji.type);
|
const ext = mime.extension(emoji.type);
|
||||||
const fileName = emoji.name + (ext ? '.' + ext : '');
|
const fileName = emoji.name + (ext ? '.' + ext : '');
|
||||||
const emojiPath = path + '/' + fileName;
|
const emojiPath = path + '/' + fileName;
|
||||||
|
|
|
@ -50,6 +50,10 @@ export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, don
|
||||||
|
|
||||||
for (const record of meta.emojis) {
|
for (const record of meta.emojis) {
|
||||||
if (!record.downloaded) continue;
|
if (!record.downloaded) continue;
|
||||||
|
if (!/^[a-zA-Z0-9_]+?([a-zA-Z0-9\.]+)?$/.test(record.fileName)) {
|
||||||
|
this.logger.error(`invalid filename: ${record.fileName}, skipping in emoji import`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const emojiInfo = record.emoji;
|
const emojiInfo = record.emoji;
|
||||||
const emojiPath = outputPath + '/' + record.fileName;
|
const emojiPath = outputPath + '/' + record.fileName;
|
||||||
await Emojis.delete({
|
await Emojis.delete({
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPol
|
||||||
|
|
||||||
export const systemQueue = initializeQueue<Record<string, unknown>>('system');
|
export const systemQueue = initializeQueue<Record<string, unknown>>('system');
|
||||||
export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification');
|
export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification');
|
||||||
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128);
|
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec);
|
||||||
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16);
|
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec);
|
||||||
export const dbQueue = initializeQueue<DbJobData>('db');
|
export const dbQueue = initializeQueue<DbJobData>('db');
|
||||||
export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage');
|
export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage');
|
||||||
export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>('webhookDeliver', 64);
|
export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>('webhookDeliver', 64);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,21 +34,38 @@ export function getApIds(value: ApObject | undefined): string[] {
|
||||||
return array.map(x => getApId(x));
|
return array.map(x => getApId(x));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get first ActivityStreams Object id
|
|
||||||
*/
|
|
||||||
export function getOneApId(value: ApObject): string {
|
|
||||||
const firstOne = Array.isArray(value) ? value[0] : value;
|
|
||||||
return getApId(firstOne);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ActivityStreams Object id
|
* Get ActivityStreams Object id
|
||||||
*/
|
*/
|
||||||
export function getApId(value: string | Object): string {
|
export function getApId(value: string | Object): string {
|
||||||
if (typeof value === 'string') return value;
|
let url = null;
|
||||||
if (typeof value.id === 'string') return value.id;
|
if (typeof value === 'string') url = value;
|
||||||
throw new Error('cannot detemine id');
|
else if (typeof value.id === 'string') url = value.id;
|
||||||
|
|
||||||
|
if (!url || !['https:', 'http:'].includes(new URL(url).protocol)) {
|
||||||
|
throw new Error('cannot determine id');
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get first (valid) ActivityStreams Object id
|
||||||
|
*/
|
||||||
|
export function getOneApId(value: ApObject): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// find the first valid ID
|
||||||
|
for (const id of value) {
|
||||||
|
try {
|
||||||
|
return getApId(x);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('cannot determine id');
|
||||||
|
} else {
|
||||||
|
return getApId(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,15 +77,34 @@ export function getApType(value: Object): string {
|
||||||
throw new Error('cannot detect type');
|
throw new Error('cannot detect type');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
|
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
|
||||||
const firstOne = Array.isArray(value) ? value[0] : value;
|
let url = null;
|
||||||
return getApHrefNullable(firstOne);
|
if (typeof value === 'string') url = value;
|
||||||
|
else if (typeof value?.href === 'string') url = value.href;
|
||||||
|
|
||||||
|
if (!url || !['https:', 'http:'].includes(new URL(url).protocol)) {
|
||||||
|
return undefined;
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
|
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
|
||||||
if (typeof value === 'string') return value;
|
if (!value) {
|
||||||
if (typeof value?.href === 'string') return value.href;
|
return;
|
||||||
return undefined;
|
} else if (Array.isArray(value)) {
|
||||||
|
// find the first valid href
|
||||||
|
for (const href of value) {
|
||||||
|
try {
|
||||||
|
return getApHrefNullable(href);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} else {
|
||||||
|
return getApHrefNullable(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IActivity extends IObject {
|
export interface IActivity extends IObject {
|
||||||
|
|
|
@ -15,7 +15,8 @@ import { ILocalUser, User } from '@/models/entities/user.js';
|
||||||
import { renderLike } from '@/remote/activitypub/renderer/like.js';
|
import { renderLike } from '@/remote/activitypub/renderer/like.js';
|
||||||
import { getUserKeypair } from '@/misc/keypair-store.js';
|
import { getUserKeypair } from '@/misc/keypair-store.js';
|
||||||
import renderFollow from '@/remote/activitypub/renderer/follow.js';
|
import renderFollow from '@/remote/activitypub/renderer/follow.js';
|
||||||
import Outbox, { packActivity } from './activitypub/outbox.js';
|
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
|
||||||
|
import Outbox from './activitypub/outbox.js';
|
||||||
import Followers from './activitypub/followers.js';
|
import Followers from './activitypub/followers.js';
|
||||||
import Following from './activitypub/following.js';
|
import Following from './activitypub/following.js';
|
||||||
import Featured from './activitypub/featured.js';
|
import Featured from './activitypub/featured.js';
|
||||||
|
@ -115,7 +116,7 @@ router.get('/notes/:note/activity', async ctx => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = renderActivity(await packActivity(note));
|
ctx.body = renderActivity(await renderNoteOrRenoteActivity(note));
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
ctx.set('Cache-Control', 'public, max-age=180');
|
||||||
setResponseType(ctx);
|
setResponseType(ctx);
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,11 +7,11 @@ import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-c
|
||||||
import renderNote from '@/remote/activitypub/renderer/note.js';
|
import renderNote from '@/remote/activitypub/renderer/note.js';
|
||||||
import renderCreate from '@/remote/activitypub/renderer/create.js';
|
import renderCreate from '@/remote/activitypub/renderer/create.js';
|
||||||
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
|
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
|
||||||
|
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
|
||||||
import { countIf } from '@/prelude/array.js';
|
import { countIf } from '@/prelude/array.js';
|
||||||
import * as url from '@/prelude/url.js';
|
import * as url from '@/prelude/url.js';
|
||||||
import { Users, Notes } from '@/models/index.js';
|
import { Users, Notes } from '@/models/index.js';
|
||||||
import { Note } from '@/models/entities/note.js';
|
import { Note } from '@/models/entities/note.js';
|
||||||
import { isPureRenote } from '@/misc/renote.js';
|
|
||||||
import { makePaginationQuery } from '../api/common/make-pagination-query.js';
|
import { makePaginationQuery } from '../api/common/make-pagination-query.js';
|
||||||
import { setResponseType } from '../activitypub.js';
|
import { setResponseType } from '../activitypub.js';
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ export default async (ctx: Router.RouterContext) => {
|
||||||
|
|
||||||
if (sinceId) notes.reverse();
|
if (sinceId) notes.reverse();
|
||||||
|
|
||||||
const activities = await Promise.all(notes.map(note => packActivity(note)));
|
const activities = await Promise.all(notes.map(note => renderNoteOrRenoteActivity(note)));
|
||||||
const rendered = renderOrderedCollectionPage(
|
const rendered = renderOrderedCollectionPage(
|
||||||
`${partOf}?${url.query({
|
`${partOf}?${url.query({
|
||||||
page: 'true',
|
page: 'true',
|
||||||
|
@ -94,16 +94,3 @@ export default async (ctx: Router.RouterContext) => {
|
||||||
setResponseType(ctx);
|
setResponseType(ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Pack Create<Note> or Announce Activity
|
|
||||||
* @param note Note
|
|
||||||
*/
|
|
||||||
export async function packActivity(note: Note): Promise<any> {
|
|
||||||
if (isPureRenote(note)) {
|
|
||||||
const renote = await Notes.findOneByOrFail({ id: note.renoteId });
|
|
||||||
return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note);
|
|
||||||
} else {
|
|
||||||
return renderCreate(await renderNote(note, false), note);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { MINUTE, HOUR } from '@/const.js';
|
||||||
import { fetchMeta } from '@/misc/fetch-meta.js';
|
import { fetchMeta } from '@/misc/fetch-meta.js';
|
||||||
import { Notes } from '@/models/index.js';
|
import { Notes } from '@/models/index.js';
|
||||||
import { Note } from '@/models/entities/note.js';
|
import { Note } from '@/models/entities/note.js';
|
||||||
import { safeForSql } from '@/misc/safe-for-sql.js';
|
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
import define from '../../define.js';
|
import define from '../../define.js';
|
||||||
|
|
||||||
|
@ -122,7 +121,7 @@ export default define(meta, paramDef, async () => {
|
||||||
for (let i = 0; i < range; i++) {
|
for (let i = 0; i < range; i++) {
|
||||||
countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
|
countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
|
||||||
.select('count(distinct note.userId)')
|
.select('count(distinct note.userId)')
|
||||||
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
|
.where(':tag = ANY(note.tags)', { tag })
|
||||||
.andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) })
|
.andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) })
|
||||||
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
|
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
|
||||||
.cache(60000) // 1 min
|
.cache(60000) // 1 min
|
||||||
|
@ -136,7 +135,7 @@ export default define(meta, paramDef, async () => {
|
||||||
|
|
||||||
const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
|
const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
|
||||||
.select('count(distinct note.userId)')
|
.select('count(distinct note.userId)')
|
||||||
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
|
.where(':tag = ANY(note.tags)', { tag })
|
||||||
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) })
|
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) })
|
||||||
.cache(60000 * 60) // 60 min
|
.cache(60000 * 60) // 60 min
|
||||||
.getRawOne()
|
.getRawOne()
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { noteVisibilities } from 'foundkey-js';
|
import { noteVisibilities, entities } from 'foundkey-js';
|
||||||
import create from '@/services/note/create.js';
|
import create from '@/services/note/create.js';
|
||||||
import { User } from '@/models/entities/user.js';
|
import { User } from '@/models/entities/user.js';
|
||||||
import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js';
|
import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index.js';
|
||||||
|
@ -7,7 +7,6 @@ import { DriveFile } from '@/models/entities/drive-file.js';
|
||||||
import { Note } from '@/models/entities/note.js';
|
import { Note } from '@/models/entities/note.js';
|
||||||
import { Channel } from '@/models/entities/channel.js';
|
import { Channel } from '@/models/entities/channel.js';
|
||||||
import { HOUR } from '@/const.js';
|
import { HOUR } from '@/const.js';
|
||||||
import { isPureRenote } from '@/misc/renote.js';
|
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
import define from '../../define.js';
|
import define from '../../define.js';
|
||||||
|
@ -160,7 +159,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPureRenote(renote)) throw new ApiError('PURE_RENOTE', 'Cannot renote a pure renote.');
|
if (entities.isPureRenote(renote)) throw new ApiError('PURE_RENOTE', 'Cannot renote a pure renote.');
|
||||||
|
|
||||||
// check that the visibility is not less restrictive
|
// check that the visibility is not less restrictive
|
||||||
if (noteVisibilities.indexOf(renote.visibility) > noteVisibilities.indexOf(ps.visibility)) {
|
if (noteVisibilities.indexOf(renote.visibility) > noteVisibilities.indexOf(ps.visibility)) {
|
||||||
|
@ -185,7 +184,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPureRenote(reply)) throw new ApiError('PURE_RENOTE', 'Cannot reply to a pure renote.');
|
if (entities.isPureRenote(reply)) throw new ApiError('PURE_RENOTE', 'Cannot reply to a pure renote.');
|
||||||
|
|
||||||
// check that the visibility is not less restrictive
|
// check that the visibility is not less restrictive
|
||||||
if (noteVisibilities.indexOf(reply.visibility) > noteVisibilities.indexOf(ps.visibility)) {
|
if (noteVisibilities.indexOf(reply.visibility) > noteVisibilities.indexOf(ps.visibility)) {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Brackets } from 'typeorm';
|
import { Brackets } from 'typeorm';
|
||||||
import { Notes } from '@/models/index.js';
|
import { Notes } from '@/models/index.js';
|
||||||
import { safeForSql } from '@/misc/safe-for-sql.js';
|
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
import define from '../../define.js';
|
import define from '../../define.js';
|
||||||
import { makePaginationQuery } from '../../common/make-pagination-query.js';
|
import { makePaginationQuery } from '../../common/make-pagination-query.js';
|
||||||
|
@ -86,15 +85,14 @@ export default define(meta, paramDef, async (ps, me) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ps.tag) {
|
if (ps.tag) {
|
||||||
if (!safeForSql(ps.tag)) throw new Error('Injection');
|
query.andWhere(':tag = ANY(note.tags)', { tag: normalizeForSearch(ps.tag) });
|
||||||
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
|
|
||||||
} else {
|
} else {
|
||||||
|
let i = 0;
|
||||||
query.andWhere(new Brackets(qb => {
|
query.andWhere(new Brackets(qb => {
|
||||||
for (const tags of ps.query!) {
|
for (const tags of ps.query!) {
|
||||||
qb.orWhere(new Brackets(qb => {
|
qb.orWhere(new Brackets(qb => {
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
if (!safeForSql(tag)) throw new Error('Injection');
|
qb.andWhere(`:tag${++i} = ANY(note.tags)`, { ['tag' + i]: normalizeForSearch(tag) });
|
||||||
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
@ -331,7 +331,7 @@ router.get('/notes/:note', async (ctx, next) => {
|
||||||
// If the note has a CW (is sensitive as a whole) or any of the files is sensitive or there are no
|
// 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.
|
// files, they are not used for a preview.
|
||||||
let filesOpengraph = [];
|
let filesOpengraph = [];
|
||||||
if (!packedNote.cw || packedNote.files.length > 0 || packedNote.files.all(file => !file.isSensitive)) {
|
if (!packedNote.cw || packedNote.files.length > 0 || packedNote.files.every(file => !file.isSensitive)) {
|
||||||
let limit = 4;
|
let limit = 4;
|
||||||
for (const file of packedNote.files) {
|
for (const file of packedNote.files) {
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
|
|
|
@ -38,6 +38,14 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
|
||||||
|
|
||||||
logger.succ(`Got preview of ${url}: ${summary.title}`);
|
logger.succ(`Got preview of ${url}: ${summary.title}`);
|
||||||
|
|
||||||
|
if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
|
||||||
|
throw new Error('unsupported schema included');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
|
||||||
|
throw new Error('unsupported schema included');
|
||||||
|
}
|
||||||
|
|
||||||
summary.icon = wrap(summary.icon);
|
summary.icon = wrap(summary.icon);
|
||||||
summary.thumbnail = wrap(summary.thumbnail);
|
summary.thumbnail = wrap(summary.thumbnail);
|
||||||
|
|
||||||
|
@ -54,12 +62,10 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function wrap(url?: string): string | null {
|
function wrap(url?: string): string | null {
|
||||||
return url != null
|
if (url == null) return null;
|
||||||
? url.match(/^https?:\/\//)
|
if (!['http:', 'https:'].includes(new URL(url).protocol)) return null;
|
||||||
? `${config.url}/proxy/preview.webp?${query({
|
return config.url + '/proxy/preview.webp?' + query({
|
||||||
url,
|
url,
|
||||||
preview: '1',
|
preview: '1',
|
||||||
})}`
|
});
|
||||||
: url
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ block desc
|
||||||
|
|
||||||
block og
|
block og
|
||||||
meta(property='og:type' content='article')
|
meta(property='og:type' content='article')
|
||||||
meta(property='og:article:published_time' content=note.createdAt.toISOString())
|
meta(property='og:article:published_time' content=note.createdAt)
|
||||||
meta(property='og:article:author:username' content=user.username)
|
meta(property='og:article:author:username' content=user.username)
|
||||||
meta(property='og:title' content= title)
|
meta(property='og:title' content= title)
|
||||||
meta(property='og:description' content= summary)
|
meta(property='og:description' content= summary)
|
||||||
|
|
|
@ -36,6 +36,7 @@ import { Cache } from '@/misc/cache.js';
|
||||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
import { UserProfile } from '@/models/entities/user-profile.js';
|
||||||
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
||||||
import { IActivity } from '@/remote/activitypub/type.js';
|
import { IActivity } from '@/remote/activitypub/type.js';
|
||||||
|
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
|
||||||
import { MINUTE } from '@/const.js';
|
import { MINUTE } from '@/const.js';
|
||||||
import { updateHashtags } from '../update-hashtag.js';
|
import { updateHashtags } from '../update-hashtag.js';
|
||||||
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
||||||
|
@ -428,9 +429,9 @@ export default async (user: { id: User['id']; username: User['username']; host:
|
||||||
});
|
});
|
||||||
|
|
||||||
//#region AP deliver
|
//#region AP deliver
|
||||||
if (Users.isLocalUser(user)) {
|
if (Users.isLocalUser(user) && !data.localOnly) {
|
||||||
(async () => {
|
(async () => {
|
||||||
const noteActivity = await renderNoteOrRenoteActivity(data, note);
|
const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
|
||||||
const dm = new DeliverManager(user, noteActivity);
|
const dm = new DeliverManager(user, noteActivity);
|
||||||
|
|
||||||
// Delivered to remote users who have been mentioned
|
// Delivered to remote users who have been mentioned
|
||||||
|
@ -487,16 +488,6 @@ export default async (user: { id: User['id']; username: User['username']; host:
|
||||||
index(note);
|
index(note);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderNoteOrRenoteActivity(data: Option, note: Note): Promise<IActivity | null> {
|
|
||||||
if (data.localOnly) return null;
|
|
||||||
|
|
||||||
const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)
|
|
||||||
? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote.id}`, note)
|
|
||||||
: renderCreate(await renderNote(note, false), note);
|
|
||||||
|
|
||||||
return renderActivity(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function incRenoteCount(renote: Note): void {
|
function incRenoteCount(renote: Note): void {
|
||||||
Notes.createQueryBuilder().update()
|
Notes.createQueryBuilder().update()
|
||||||
.set({
|
.set({
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
|
import * as foundkey from 'foundkey-js';
|
||||||
import { publishNoteStream } from '@/services/stream.js';
|
import { publishNoteStream } from '@/services/stream.js';
|
||||||
import renderDelete from '@/remote/activitypub/renderer/delete.js';
|
import renderDelete from '@/remote/activitypub/renderer/delete.js';
|
||||||
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
|
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
|
||||||
|
@ -12,7 +13,6 @@ import { Notes, Users, Instances } from '@/models/index.js';
|
||||||
import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js';
|
import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index.js';
|
||||||
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
|
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
|
||||||
import { countSameRenotes } from '@/misc/count-same-renotes.js';
|
import { countSameRenotes } from '@/misc/count-same-renotes.js';
|
||||||
import { isPureRenote } from '@/misc/renote.js';
|
|
||||||
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
||||||
import { deliverToRelays } from '../relay.js';
|
import { deliverToRelays } from '../relay.js';
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
|
||||||
let renote: Note | null = null;
|
let renote: Note | null = null;
|
||||||
|
|
||||||
// if deleted note is renote
|
// if deleted note is renote
|
||||||
if (isPureRenote(note)) {
|
if (foundkey.entities.isPureRenote(note)) {
|
||||||
renote = await Notes.findOneBy({ id: note.renoteId });
|
renote = await Notes.findOneBy({ id: note.renoteId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ export const logger = new Logger('email');
|
||||||
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<void> {
|
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<void> {
|
||||||
const meta = await fetchMeta(true);
|
const meta = await fetchMeta(true);
|
||||||
|
|
||||||
const iconUrl = `${config.url}/static-assets/mi-white.png`;
|
const iconUrl = `${config.url}/static-assets/icons/512.png`;
|
||||||
const emailSettingUrl = `${config.url}/settings/email`;
|
const emailSettingUrl = `${config.url}/settings/email`;
|
||||||
|
|
||||||
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
|
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "13.0.0-preview3",
|
"version": "13.0.0-preview4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "vite build --watch --mode development",
|
"watch": "vite build --watch --mode development",
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
import * as foundkey from 'foundkey-js';
|
import * as foundkey from 'foundkey-js';
|
||||||
import { concat } from '@/scripts/array';
|
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -22,11 +21,12 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
return concat([
|
return [
|
||||||
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
|
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
|
||||||
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length }) ] : [],
|
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length }) ] : [],
|
||||||
props.note.poll != null ? [i18n.ts.poll] : [],
|
props.note.poll != null ? [i18n.ts.poll] : [],
|
||||||
] as string[][]).join(' / ');
|
props.note.renoteId != null ? [i18n.ts.quote] : [],
|
||||||
|
].flat().join(' / ');
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
|
|
@ -35,6 +35,7 @@ const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
const self = props.url.startsWith(local);
|
const self = props.url.startsWith(local);
|
||||||
const uri = new URL(props.url);
|
const uri = new URL(props.url);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
|
||||||
let el: HTMLElement | null = $ref(null);
|
let el: HTMLElement | null = $ref(null);
|
||||||
|
|
||||||
let schema = $ref(uri.protocol);
|
let schema = $ref(uri.protocol);
|
||||||
|
|
|
@ -4,7 +4,6 @@ import MkUrl from '@/components/global/url.vue';
|
||||||
import MkLink from '@/components/link.vue';
|
import MkLink from '@/components/link.vue';
|
||||||
import MkMention from '@/components/mention.vue';
|
import MkMention from '@/components/mention.vue';
|
||||||
import MkEmoji from '@/components/global/emoji.vue';
|
import MkEmoji from '@/components/global/emoji.vue';
|
||||||
import { concat } from '@/scripts/array';
|
|
||||||
import MkFormula from '@/components/formula.vue';
|
import MkFormula from '@/components/formula.vue';
|
||||||
import MkCode from '@/components/code.vue';
|
import MkCode from '@/components/code.vue';
|
||||||
import MkSearch from '@/components/mfm-search.vue';
|
import MkSearch from '@/components/mfm-search.vue';
|
||||||
|
@ -50,7 +49,7 @@ export default defineComponent({
|
||||||
return t.match(/^[0-9.]+s$/) ? t : null;
|
return t.match(/^[0-9.]+s$/) ? t : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => {
|
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode[] => {
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||||
|
@ -314,7 +313,7 @@ export default defineComponent({
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}).flat();
|
||||||
|
|
||||||
// Parse ast to DOM
|
// Parse ast to DOM
|
||||||
return h('span', genEl(ast));
|
return h('span', genEl(ast));
|
||||||
|
|
|
@ -159,12 +159,7 @@ if (noteViewInterruptors.length > 0) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRenote = (
|
const isRenote = foundkey.entities.isPureRenote(note);
|
||||||
note.renote != null &&
|
|
||||||
note.text == null &&
|
|
||||||
note.fileIds.length === 0 &&
|
|
||||||
note.poll == null
|
|
||||||
);
|
|
||||||
|
|
||||||
const el = ref<HTMLElement>();
|
const el = ref<HTMLElement>();
|
||||||
const menuButton = ref<HTMLElement>();
|
const menuButton = ref<HTMLElement>();
|
||||||
|
|
|
@ -148,12 +148,7 @@ if (noteViewInterruptors.length > 0) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRenote = (
|
const isRenote = foundkey.entities.isPureRenote(note);
|
||||||
note.renote != null &&
|
|
||||||
note.text == null &&
|
|
||||||
note.fileIds.length === 0 &&
|
|
||||||
note.poll == null
|
|
||||||
);
|
|
||||||
|
|
||||||
const el = ref<HTMLElement>();
|
const el = ref<HTMLElement>();
|
||||||
const menuButton = ref<HTMLElement>();
|
const menuButton = ref<HTMLElement>();
|
||||||
|
|
|
@ -207,7 +207,7 @@ const maxTextLength = $computed((): number => {
|
||||||
|
|
||||||
const canPost = $computed((): boolean => {
|
const canPost = $computed((): boolean => {
|
||||||
return !posting &&
|
return !posting &&
|
||||||
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
|
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote || !!quoteId) &&
|
||||||
(textLength <= maxTextLength) &&
|
(textLength <= maxTextLength) &&
|
||||||
(!poll || poll.choices.length >= 2);
|
(!poll || poll.choices.length >= 2);
|
||||||
});
|
});
|
||||||
|
@ -573,7 +573,7 @@ async function post() {
|
||||||
text: text === '' ? undefined : text,
|
text: text === '' ? undefined : text,
|
||||||
fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
|
fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
|
||||||
replyId: props.reply ? props.reply.id : undefined,
|
replyId: props.reply ? props.reply.id : undefined,
|
||||||
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
|
renoteId: props.renote?.id ?? quoteId ?? undefined,
|
||||||
channelId: props.channel ? props.channel.id : undefined,
|
channelId: props.channel ? props.channel.id : undefined,
|
||||||
poll,
|
poll,
|
||||||
cw: useCw ? cw || '' : undefined,
|
cw: useCw ? cw || '' : undefined,
|
||||||
|
|
|
@ -54,6 +54,7 @@ let player = $ref({
|
||||||
let playerEnabled = $ref(false);
|
let playerEnabled = $ref(false);
|
||||||
|
|
||||||
const requestUrl = new URL(props.url);
|
const requestUrl = new URL(props.url);
|
||||||
|
if(!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url');
|
||||||
|
|
||||||
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
|
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
|
||||||
requestUrl.hostname = 'www.youtube.com';
|
requestUrl.hostname = 'www.youtube.com';
|
||||||
|
@ -72,7 +73,9 @@ fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).the
|
||||||
icon = info.icon;
|
icon = info.icon;
|
||||||
sitename = info.sitename;
|
sitename = info.sitename;
|
||||||
fetching = false;
|
fetching = false;
|
||||||
player = info.player;
|
if (['http:', 'https:'].includes(new URL(info.player.url).protocol)) {
|
||||||
|
player = info.player;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -59,6 +59,7 @@ async function run() {
|
||||||
os.inputText({
|
os.inputText({
|
||||||
title: q,
|
title: q,
|
||||||
}).then(({ canceled, result: a }) => {
|
}).then(({ canceled, result: a }) => {
|
||||||
|
if (canceled) return;
|
||||||
ok(a);
|
ok(a);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,7 +18,8 @@ export function install(plugin) {
|
||||||
return new Promise(ok => {
|
return new Promise(ok => {
|
||||||
inputText({
|
inputText({
|
||||||
title: q,
|
title: q,
|
||||||
}).then(({ result: a }) => {
|
}).then(({ canceled, result: a }) => {
|
||||||
|
if (canceled) return;
|
||||||
ok(a);
|
ok(a);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -24,7 +24,11 @@ export function createAiScriptEnv(opts) {
|
||||||
return confirm.canceled ? values.FALSE : values.TRUE;
|
return confirm.canceled ? values.FALSE : values.TRUE;
|
||||||
}),
|
}),
|
||||||
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
|
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
|
||||||
if (token) utils.assertString(token);
|
if (token) {
|
||||||
|
utils.assertString(token);
|
||||||
|
// In case there is a bug, it could be undefined.
|
||||||
|
if (typeof token.value !== 'string') throw new Error('invalid token');
|
||||||
|
}
|
||||||
apiRequests++;
|
apiRequests++;
|
||||||
if (apiRequests > 16) return values.NULL;
|
if (apiRequests > 16) return values.NULL;
|
||||||
const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null));
|
const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null));
|
||||||
|
|
|
@ -15,19 +15,12 @@ export function count<T>(a: T, xs: T[]): number {
|
||||||
return countIf(x => x === a, xs);
|
return countIf(x => x === a, xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Concatenate an array of arrays
|
|
||||||
*/
|
|
||||||
export function concat<T>(xss: T[][]): T[] {
|
|
||||||
return ([] as T[]).concat(...xss);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intersperse the element between the elements of the array
|
* Intersperse the element between the elements of the array
|
||||||
* @param sep The element to be interspersed
|
* @param sep The element to be interspersed
|
||||||
*/
|
*/
|
||||||
export function intersperse<T>(sep: T, xs: T[]): T[] {
|
export function intersperse<T>(sep: T, xs: T[]): T[] {
|
||||||
return concat(xs.map(x => [sep, x])).slice(1);
|
return xs.map(x => [sep, x]).flat().slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,14 +16,9 @@ export function getNoteMenu(props: {
|
||||||
isDeleted: Ref<boolean>;
|
isDeleted: Ref<boolean>;
|
||||||
currentClipPage?: Ref<foundkey.entities.Clip>;
|
currentClipPage?: Ref<foundkey.entities.Clip>;
|
||||||
}) {
|
}) {
|
||||||
const isRenote = (
|
const appearNote = foundkey.entities.isPureRenote(props.note)
|
||||||
props.note.renote != null &&
|
? props.note.renote as foundkey.entities.Note
|
||||||
props.note.text == null &&
|
: props.note;
|
||||||
props.note.fileIds.length === 0 &&
|
|
||||||
props.note.poll == null
|
|
||||||
);
|
|
||||||
|
|
||||||
const appearNote = isRenote ? props.note.renote as foundkey.entities.Note : props.note;
|
|
||||||
|
|
||||||
function del(): void {
|
function del(): void {
|
||||||
os.confirm({
|
os.confirm({
|
||||||
|
|
|
@ -72,7 +72,8 @@ const run = async (): Promise<void> => {
|
||||||
return new Promise(ok => {
|
return new Promise(ok => {
|
||||||
os.inputText({
|
os.inputText({
|
||||||
title: q,
|
title: q,
|
||||||
}).then(({ result: a }) => {
|
}).then(({ canceled, result: a }) => {
|
||||||
|
if (canceled) return;
|
||||||
ok(a);
|
ok(a);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -60,7 +60,8 @@ const run = async (): Promise<void> => {
|
||||||
return new Promise(ok => {
|
return new Promise(ok => {
|
||||||
os.inputText({
|
os.inputText({
|
||||||
title: q,
|
title: q,
|
||||||
}).then(({ result: a }) => {
|
}).then(({ canceled, result: a }) => {
|
||||||
|
if (canceled) return;
|
||||||
ok(a);
|
ok(a);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "foundkey-js",
|
"name": "foundkey-js",
|
||||||
"version": "13.0.0-preview3",
|
"version": "13.0.0-preview4",
|
||||||
"description": "Fork of misskey-js for Foundkey",
|
"description": "Fork of misskey-js for Foundkey",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./built/index.js",
|
"main": "./built/index.js",
|
||||||
|
|
|
@ -471,3 +471,14 @@ export type UserSorting =
|
||||||
| '+updatedAt'
|
| '+updatedAt'
|
||||||
| '-updatedAt';
|
| '-updatedAt';
|
||||||
export type OriginType = 'combined' | 'local' | 'remote';
|
export type OriginType = 'combined' | 'local' | 'remote';
|
||||||
|
|
||||||
|
export function isPureRenote(note: Note): boolean {
|
||||||
|
return note.renoteId != null
|
||||||
|
&& note.text == null
|
||||||
|
&& note.cw == null
|
||||||
|
&& (
|
||||||
|
note.fileIds == null
|
||||||
|
|| note.fileIds.length === 0
|
||||||
|
)
|
||||||
|
&& note.poll == null;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sw",
|
"name": "sw",
|
||||||
"version": "13.0.0-preview3",
|
"version": "13.0.0-preview4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "node build.js watch",
|
"watch": "node build.js watch",
|
||||||
|
|