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 └─────────────────────────────────────────────────────
|
||||
|
||||
# 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'
|
||||
#]
|
||||
|
|
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).
|
||||
|
|
BIN
logo.svg
Normal file
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"
|
||||
|
|
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) {
|
||||
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.'`);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ export class registryRemoveDomain1675375940759 {
|
|||
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(`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")`);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "13.0.0-preview3",
|
||||
"version": "13.0.0-preview4",
|
||||
"main": "./index.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
@ -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,21 +77,8 @@ 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');
|
||||
}
|
||||
if (config.clusterLimits.web < 1 || config.clusterLimits.queue < 1) {
|
||||
throw new Error('invalid cluster limits');
|
||||
}
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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, {
|
||||
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);
|
||||
|
|
|
@ -58,6 +58,10 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
|
|||
});
|
||||
|
||||
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 fileName = emoji.name + (ext ? '.' + ext : '');
|
||||
const emojiPath = path + '/' + fileName;
|
||||
|
|
|
@ -50,6 +50,10 @@ export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, don
|
|||
|
||||
for (const record of meta.emojis) {
|
||||
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 emojiPath = outputPath + '/' + record.fileName;
|
||||
await Emojis.delete({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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');
|
||||
let url = null;
|
||||
if (typeof value === 'string') url = value;
|
||||
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');
|
||||
}
|
||||
|
||||
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
|
||||
const firstOne = Array.isArray(value) ? value[0] : value;
|
||||
return getApHrefNullable(firstOne);
|
||||
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
|
||||
let url = null;
|
||||
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 {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value?.href === 'string') return value.href;
|
||||
return undefined;
|
||||
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
} 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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { MINUTE, HOUR } from '@/const.js';
|
|||
import { fetchMeta } from '@/misc/fetch-meta.js';
|
||||
import { Notes } from '@/models/index.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 define from '../../define.js';
|
||||
|
||||
|
@ -122,7 +121,7 @@ export default define(meta, paramDef, async () => {
|
|||
for (let i = 0; i < range; i++) {
|
||||
countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
|
||||
.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 > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
|
||||
.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')
|
||||
.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) })
|
||||
.cache(60000 * 60) // 60 min
|
||||
.getRawOne()
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Brackets } from 'typeorm';
|
||||
import { Notes } from '@/models/index.js';
|
||||
import { safeForSql } from '@/misc/safe-for-sql.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import define from '../../define.js';
|
||||
import { makePaginationQuery } from '../../common/make-pagination-query.js';
|
||||
|
@ -86,15 +85,14 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
|
||||
try {
|
||||
if (ps.tag) {
|
||||
if (!safeForSql(ps.tag)) throw new Error('Injection');
|
||||
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
|
||||
query.andWhere(':tag = ANY(note.tags)', { tag: normalizeForSearch(ps.tag) });
|
||||
} else {
|
||||
let i = 0;
|
||||
query.andWhere(new Brackets(qb => {
|
||||
for (const tags of ps.query!) {
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
for (const tag of tags) {
|
||||
if (!safeForSql(tag)) throw new Error('Injection');
|
||||
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
|
||||
qb.andWhere(`:tag${++i} = ANY(note.tags)`, { ['tag' + i]: normalizeForSearch(tag) });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
// files, they are not used for a preview.
|
||||
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;
|
||||
for (const file of packedNote.files) {
|
||||
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}`);
|
||||
|
||||
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.thumbnail = wrap(summary.thumbnail);
|
||||
|
||||
|
@ -54,12 +62,10 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
|
|||
};
|
||||
|
||||
function wrap(url?: string): string | null {
|
||||
return url != null
|
||||
? url.match(/^https?:\/\//)
|
||||
? `${config.url}/proxy/preview.webp?${query({
|
||||
url,
|
||||
preview: '1',
|
||||
})}`
|
||||
: url
|
||||
: null;
|
||||
if (url == null) return null;
|
||||
if (!['http:', 'https:'].includes(new URL(url).protocol)) return null;
|
||||
return config.url + '/proxy/preview.webp?' + query({
|
||||
url,
|
||||
preview: '1',
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ block desc
|
|||
|
||||
block og
|
||||
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:title' content= title)
|
||||
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 { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
||||
import { IActivity } from '@/remote/activitypub/type.js';
|
||||
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
|
||||
import { MINUTE } from '@/const.js';
|
||||
import { updateHashtags } from '../update-hashtag.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
|
||||
if (Users.isLocalUser(user)) {
|
||||
if (Users.isLocalUser(user) && !data.localOnly) {
|
||||
(async () => {
|
||||
const noteActivity = await renderNoteOrRenoteActivity(data, note);
|
||||
const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
|
||||
const dm = new DeliverManager(user, noteActivity);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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 {
|
||||
Notes.createQueryBuilder().update()
|
||||
.set({
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
import * as foundkey from 'foundkey-js';
|
||||
import { publishNoteStream } from '@/services/stream.js';
|
||||
import renderDelete from '@/remote/activitypub/renderer/delete.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 { DeliverManager } from '@/remote/activitypub/deliver-manager.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 { 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;
|
||||
|
||||
// if deleted note is renote
|
||||
if (isPureRenote(note)) {
|
||||
if (foundkey.entities.isPureRenote(note)) {
|
||||
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> {
|
||||
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 enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "client",
|
||||
"version": "13.0.0-preview3",
|
||||
"version": "13.0.0-preview4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch": "vite build --watch --mode development",
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import { computed } from 'vue';
|
||||
import { length } from 'stringz';
|
||||
import * as foundkey from 'foundkey-js';
|
||||
import { concat } from '@/scripts/array';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -22,11 +21,12 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const label = computed(() => {
|
||||
return concat([
|
||||
return [
|
||||
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.poll != null ? [i18n.ts.poll] : [],
|
||||
] as string[][]).join(' / ');
|
||||
props.note.renoteId != null ? [i18n.ts.quote] : [],
|
||||
].flat().join(' / ');
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
|
|
|
@ -35,6 +35,7 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const self = props.url.startsWith(local);
|
||||
const uri = new URL(props.url);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
|
||||
let el: HTMLElement | null = $ref(null);
|
||||
|
||||
let schema = $ref(uri.protocol);
|
||||
|
|
|
@ -4,7 +4,6 @@ import MkUrl from '@/components/global/url.vue';
|
|||
import MkLink from '@/components/link.vue';
|
||||
import MkMention from '@/components/mention.vue';
|
||||
import MkEmoji from '@/components/global/emoji.vue';
|
||||
import { concat } from '@/scripts/array';
|
||||
import MkFormula from '@/components/formula.vue';
|
||||
import MkCode from '@/components/code.vue';
|
||||
import MkSearch from '@/components/mfm-search.vue';
|
||||
|
@ -50,7 +49,7 @@ export default defineComponent({
|
|||
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) {
|
||||
case 'text': {
|
||||
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
|
@ -314,7 +313,7 @@ export default defineComponent({
|
|||
return [];
|
||||
}
|
||||
}
|
||||
}));
|
||||
}).flat();
|
||||
|
||||
// Parse ast to DOM
|
||||
return h('span', genEl(ast));
|
||||
|
|
|
@ -159,12 +159,7 @@ if (noteViewInterruptors.length > 0) {
|
|||
});
|
||||
}
|
||||
|
||||
const isRenote = (
|
||||
note.renote != null &&
|
||||
note.text == null &&
|
||||
note.fileIds.length === 0 &&
|
||||
note.poll == null
|
||||
);
|
||||
const isRenote = foundkey.entities.isPureRenote(note);
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
|
|
|
@ -148,12 +148,7 @@ if (noteViewInterruptors.length > 0) {
|
|||
});
|
||||
}
|
||||
|
||||
const isRenote = (
|
||||
note.renote != null &&
|
||||
note.text == null &&
|
||||
note.fileIds.length === 0 &&
|
||||
note.poll == null
|
||||
);
|
||||
const isRenote = foundkey.entities.isPureRenote(note);
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
|
|
|
@ -207,7 +207,7 @@ const maxTextLength = $computed((): number => {
|
|||
|
||||
const canPost = $computed((): boolean => {
|
||||
return !posting &&
|
||||
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
|
||||
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote || !!quoteId) &&
|
||||
(textLength <= maxTextLength) &&
|
||||
(!poll || poll.choices.length >= 2);
|
||||
});
|
||||
|
@ -573,7 +573,7 @@ async function post() {
|
|||
text: text === '' ? undefined : text,
|
||||
fileIds: files.length > 0 ? files.map(f => f.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,
|
||||
poll,
|
||||
cw: useCw ? cw || '' : undefined,
|
||||
|
|
|
@ -54,6 +54,7 @@ let player = $ref({
|
|||
let playerEnabled = $ref(false);
|
||||
|
||||
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)')) {
|
||||
requestUrl.hostname = 'www.youtube.com';
|
||||
|
@ -72,7 +73,9 @@ fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).the
|
|||
icon = info.icon;
|
||||
sitename = info.sitename;
|
||||
fetching = false;
|
||||
player = info.player;
|
||||
if (['http:', 'https:'].includes(new URL(info.player.url).protocol)) {
|
||||
player = info.player;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -59,6 +59,7 @@ async function run() {
|
|||
os.inputText({
|
||||
title: q,
|
||||
}).then(({ canceled, result: a }) => {
|
||||
if (canceled) return;
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,7 +18,8 @@ export function install(plugin) {
|
|||
return new Promise(ok => {
|
||||
inputText({
|
||||
title: q,
|
||||
}).then(({ result: a }) => {
|
||||
}).then(({ canceled, result: a }) => {
|
||||
if (canceled) return;
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -24,7 +24,11 @@ export function createAiScriptEnv(opts) {
|
|||
return confirm.canceled ? values.FALSE : values.TRUE;
|
||||
}),
|
||||
'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++;
|
||||
if (apiRequests > 16) return values.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param sep The element to be interspersed
|
||||
*/
|
||||
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>;
|
||||
currentClipPage?: Ref<foundkey.entities.Clip>;
|
||||
}) {
|
||||
const isRenote = (
|
||||
props.note.renote != null &&
|
||||
props.note.text == null &&
|
||||
props.note.fileIds.length === 0 &&
|
||||
props.note.poll == null
|
||||
);
|
||||
|
||||
const appearNote = isRenote ? props.note.renote as foundkey.entities.Note : props.note;
|
||||
const appearNote = foundkey.entities.isPureRenote(props.note)
|
||||
? props.note.renote as foundkey.entities.Note
|
||||
: props.note;
|
||||
|
||||
function del(): void {
|
||||
os.confirm({
|
||||
|
|
|
@ -72,7 +72,8 @@ const run = async (): Promise<void> => {
|
|||
return new Promise(ok => {
|
||||
os.inputText({
|
||||
title: q,
|
||||
}).then(({ result: a }) => {
|
||||
}).then(({ canceled, result: a }) => {
|
||||
if (canceled) return;
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -60,7 +60,8 @@ const run = async (): Promise<void> => {
|
|||
return new Promise(ok => {
|
||||
os.inputText({
|
||||
title: q,
|
||||
}).then(({ result: a }) => {
|
||||
}).then(({ canceled, result: a }) => {
|
||||
if (canceled) return;
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "foundkey-js",
|
||||
"version": "13.0.0-preview3",
|
||||
"version": "13.0.0-preview4",
|
||||
"description": "Fork of misskey-js for Foundkey",
|
||||
"type": "module",
|
||||
"main": "./built/index.js",
|
||||
|
|
|
@ -471,3 +471,14 @@ export type UserSorting =
|
|||
| '+updatedAt'
|
||||
| '-updatedAt';
|
||||
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",
|
||||
"version": "13.0.0-preview3",
|
||||
"version": "13.0.0-preview4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch": "node build.js watch",
|
||||
|
|