Compare commits

...

23 commits

Author SHA1 Message Date
vib
9cfc7c1027 Merge branch 'main'(preview4) into snug.moe 2023-02-11 04:04:35 +02:00
6a40ef3569 fix typo
tfw no building before push
2023-02-10 20:35:09 -05:00
syuilo
09fe55379e
client: check input for aiscript
af1c9251fc
5f3640c7fd

Co-authored-by: Johann150 <johann.galle@protonmail.com>
Changelog: Fixed
2023-02-10 20:06:31 +01:00
27b912b9b0
security: check schema for URL previews
Changelog: Fixed
2023-02-10 20:06:18 +01:00
48fd543d0f
security: check URL schema of AP URIs
Changelog: Fixed
2023-02-10 20:06:12 +01:00
syuilo
af272ce358
fix(server): validate filename and emoji name to improve security
0d7256678e

Co-authored-by: Johann150 <johann.galle@protonmail.com>
Changelog: Fixed
2023-02-10 20:05:53 +01:00
c1ae134c0a
security: make sure there is no SQL insertion 2023-02-10 18:31:23 +01:00
3ad6323c23
fix registry migration
closes FoundKeyGang/FoundKey#337
2023-02-05 20:37:06 +01:00
3489c8ac3a
fix: loading config 2023-02-04 23:24:05 +01:00
06ef752218
adjust readme 2023-02-04 23:00:34 +01:00
44f02fa3ec
update documents for new release 2023-02-04 22:22:00 +01:00
d655bda30c
add foundkey floofer 2023-02-04 22:15:28 +01:00
839daea887
remove mi-white.png asset 2023-02-04 18:08:19 +01:00
41c42f96f0
BREAKING server: disable deliver rate limit by default
The deliver rate limit seems to cause a lot of performance problems,
presumably because of the overhead the rate limit has. It also does
not really make sense to rate limit outgoing because we are requesting
from different servers anyway.

fixes FoundKeyGang/FoundKey#190

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

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

Changelog: Fixed
2023-02-03 23:12:12 +01:00
57 changed files with 343 additions and 179 deletions

View file

@ -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'
#]

View file

@ -11,6 +11,108 @@ Unreleased changes should not be listed in this file.
Instead, run `git shortlog --format='%h %s' --group=trailer:changelog <last tag>..` to see unreleased changes; replace `<last tag>` with the tag you wish to compare from.
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.

View file

@ -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/

View file

@ -1,3 +1,5 @@
<div align="center"><img src="./logo.svg" height="200" alt="Foundkey logo, an owl holding a key"/></div>
# FoundKey
FoundKey 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -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.'`);
}

View file

@ -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")`);
}

View file

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

View file

@ -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);

View file

@ -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();
}
});

View file

@ -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

View file

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

View file

@ -1,3 +0,0 @@
export function safeForSql(text: string): boolean {
return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
}

View file

@ -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);

View file

@ -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;

View file

@ -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({

View file

@ -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);

View file

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

View file

@ -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 {

View file

@ -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);
});

View file

@ -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);
}
}

View file

@ -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()

View file

@ -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)) {

View file

@ -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) });
}
}));
}

View file

@ -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/')) {

View file

@ -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',
});
}

View file

@ -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)

View file

@ -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({

View file

@ -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 });
}

View file

@ -8,7 +8,7 @@ export const logger = new Logger('email');
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<void> {
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 !== '';

View file

@ -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",

View file

@ -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 = () => {

View file

@ -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);

View file

@ -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));

View file

@ -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>();

View file

@ -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>();

View file

@ -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,

View file

@ -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>

View file

@ -59,6 +59,7 @@ async function run() {
os.inputText({
title: q,
}).then(({ canceled, result: a }) => {
if (canceled) return;
ok(a);
});
});

View file

@ -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);
});
});

View file

@ -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));

View file

@ -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);
}
/**

View file

@ -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({

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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",

View file

@ -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;
}

View file

@ -1,6 +1,6 @@
{
"name": "sw",
"version": "13.0.0-preview3",
"version": "13.0.0-preview4",
"private": true,
"scripts": {
"watch": "node build.js watch",