diff --git a/.woodpecker/commit.yml b/.woodpecker/commit.yml index 386484ce22..6bb1b2d814 100644 --- a/.woodpecker/commit.yml +++ b/.woodpecker/commit.yml @@ -18,4 +18,4 @@ services: image: redis branches: - include: [ main, develop, feature/* ] + include: [ main, beta, develop, feature/* ] diff --git a/Dockerfile b/Dockerfile index 53734b0691..1a1a0aac64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM node:19-alpine as build WORKDIR /calckey # Install compilation dependencies -RUN apk add --no-cache --no-progress git alpine-sdk python3 rust cargo +RUN apk add --no-cache --no-progress git alpine-sdk python3 rust cargo vips # Copy only the dependency-related files first, to cache efficiently COPY package.json pnpm*.yaml ./ @@ -11,7 +11,8 @@ COPY packages/backend/package.json packages/backend/package.json COPY packages/client/package.json packages/client/package.json COPY packages/sw/package.json packages/sw/package.json COPY packages/backend/native-utils/package.json packages/backend/native-utils/package.json -COPY packages/backend/native-utils/**/*/package.json packages/backend/native-utils/**/*/package.json +COPY packages/backend/native-utils/npm/linux-x64-musl/package.json packages/backend/native-utils/npm/linux-x64-musl/package.json +COPY packages/backend/native-utils/npm/linux-arm64-musl/package.json packages/backend/native-utils/npm/linux-arm64-musl/package.json # Configure corepack and pnpm RUN corepack enable @@ -47,6 +48,8 @@ COPY --from=build /calckey/packages/client/node_modules /calckey/packages/client COPY --from=build /calckey/built /calckey/built COPY --from=build /calckey/packages/backend/built /calckey/packages/backend/built COPY --from=build /calckey/packages/backend/assets/instance.css /calckey/packages/backend/assets/instance.css +COPY --from=build /calckey/packages/backend/native-utils/built /calckey/packages/backend/native-utils/built +COPY --from=build /calckey/packages/backend/native-utils/target /calckey/packages/backend/native-utils/target RUN corepack enable ENTRYPOINT [ "/sbin/tini", "--" ] diff --git a/locales/en-US.yml b/locales/en-US.yml index 8802039220..1bdf57faef 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -12,6 +12,7 @@ fetchingAsApObject: "Fetching from the Fediverse" ok: "OK" gotIt: "Got it!" cancel: "Cancel" +noThankYou: "No thank you" enterUsername: "Enter username" renotedBy: "Boosted by {user}" noNotes: "No posts" @@ -146,6 +147,8 @@ flagAsBot: "Mark this account as a bot" flagAsBotDescription: "Enable this option if this account is controlled by a program. If enabled, it will act as a flag for other developers to prevent endless interaction chains with other bots and adjust Calckey's internal systems to treat this account as a bot." flagAsCat: "Are you a cat? 😺" flagAsCatDescription: "You'll get cat ears and speak like a cat!" +flagSpeakAsCat: "Speak as a cat" +flagSpeakAsCatDescription: "Your posts will get nyanified when in cat mode" flagShowTimelineReplies: "Show replies in timeline" flagShowTimelineRepliesDescription: "Shows replies of users to posts of other users in the timeline if turned on." autoAcceptFollowed: "Automatically approve follow requests from users you're following" @@ -914,6 +917,13 @@ navbar: "Navigation bar" shuffle: "Shuffle" account: "Account" move: "Move" +pushNotification: "Push notifications" +subscribePushNotification: "Enable push notifications" +unsubscribePushNotification: "Disable push notifications" +pushNotificationAlreadySubscribed: "Push notifications are already enabled" +pushNotificationNotSupported: "Your browser or instance does not support push notifications" +sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" +sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the battery usage of your device, if applicable." showAds: "Show ads" enterSendsMessage: "Press Return in Messaging to send message (off is Ctrl + Return)" adminCustomCssWarn: "This setting should only be used if you know what it does. Entering improper values may cause EVERYONE'S clients to stop functioning normally. Please ensure your CSS works properly by testing it in your user settings." @@ -1228,13 +1238,13 @@ _sfx: _ago: future: "Future" justNow: "Just now" - secondsAgo: "{n} second(s) ago" - minutesAgo: "{n} minute(s) ago" - hoursAgo: "{n} hour(s) ago" - daysAgo: "{n} day(s) ago" - weeksAgo: "{n} week(s) ago" - monthsAgo: "{n} month(s) ago" - yearsAgo: "{n} year(s) ago" + secondsAgo: "{n}s ago" + minutesAgo: "{n}m ago" + hoursAgo: "{n}h ago" + daysAgo: "{n}d ago" + weeksAgo: "{n}w ago" + monthsAgo: "{n}mo ago" + yearsAgo: "{n}y ago" _time: second: "Second(s)" minute: "Minute(s)" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 34dc974893..26971184c2 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -12,6 +12,7 @@ fetchingAsApObject: "連合宇宙から取得中" ok: "OK" gotIt: "わかった!" cancel: "キャンセル" +noThankYou: "やめておく" enterUsername: "ユーザー名を入力" renotedBy: "{user}がブースト" noNotes: "投稿はありません" @@ -145,7 +146,9 @@ cacheRemoteFilesDescription: "この設定を無効にすると、リモート flagAsBot: "Botとして設定" flagAsBotDescription: "このアカウントがBotである場合は、この設定をオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Calckeyのシステム上での扱いがBotに合ったものになります。" flagAsCat: "あなたは…猫?😺" -flagAsCatDescription: "このアカウントが猫であることを示す場合は、このフラグをオンにします。" +flagAsCatDescription: "このアカウントが猫であることを示す猫モードを有効にするには、このフラグをオンにします。" +flagSpeakAsCat: "猫語で話す" +flagSpeakAsCatDescription: "猫モードが有効の場合にオンにすると、あなたの投稿の「な」を「にゃ」に変換します。" flagShowTimelineReplies: "タイムラインに投稿の返信を表示する" flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーの他の投稿への返信も表示されます。" autoAcceptFollowed: "フォローしているユーザーからのフォロー申請を自動承認" @@ -916,6 +919,13 @@ navbar: "ナビゲーションバー" shuffle: "シャッフル" account: "アカウント" move: "移動" +pushNotification: "プッシュ通知" +subscribePushNotification: "プッシュ通知を有効化" +unsubscribePushNotification: "プッシュ通知を停止する" +pushNotificationAlreadySubscribed: "プッシュ通知は有効です" +pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" +sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" +sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" adminCustomCssWarn: "この設定は、それが何をするものであるかを知っている場合のみ使用してください。不適切な値を入力すると、クライアントが正常に動作しなくなる可能性があります。ユーザー設定でCSSをテストし、正しく動作することを確認してください。" customMOTD: "カスタムMOTD(スプラッシュスクリーンメッセージ)" customMOTDDescription: "ユーザがページをロード/リロードするたびにランダムに表示される、改行で区切られたMOTD(スプラッシュスクリーン)用のカスタムメッセージ" diff --git a/package.json b/package.json index 812ca0a191..240313e626 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "calckey", - "version": "13.2.0-dev23", + "version": "13.2.0-dev26", "codename": "aqua", "repository": { "type": "git", "url": "https://codeberg.org/calckey/calckey.git" }, - "packageManager": "pnpm@8.1.0", + "packageManager": "pnpm@8.1.1", "private": true, "scripts": { "rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp", diff --git a/packages/backend/migration/1669138716634-whetherPushNotifyToSendReadMessage.js b/packages/backend/migration/1669138716634-whetherPushNotifyToSendReadMessage.js new file mode 100644 index 0000000000..2265b00617 --- /dev/null +++ b/packages/backend/migration/1669138716634-whetherPushNotifyToSendReadMessage.js @@ -0,0 +1,11 @@ +export class whetherPushNotifyToSendReadMessage1669138716634 { + name = 'whetherPushNotifyToSendReadMessage1669138716634' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "sw_subscription" ADD "sendReadMessage" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "sw_subscription" DROP COLUMN "sendReadMessage"`); + } +} diff --git a/packages/backend/migration/1680426269172-SpeakAsCat.js b/packages/backend/migration/1680426269172-SpeakAsCat.js new file mode 100644 index 0000000000..3655e27265 --- /dev/null +++ b/packages/backend/migration/1680426269172-SpeakAsCat.js @@ -0,0 +1,20 @@ +export class SpeakAsCat1680426269172 { + name = 'SpeakAsCat1680426269172' + + async up(queryRunner) { + await queryRunner.query(` + ALTER TABLE "user" + ADD "speakAsCat" boolean NOT NULL DEFAULT true + `); + await queryRunner.query(` + COMMENT ON COLUMN "user"."speakAsCat" + IS 'Whether to speak as a cat if isCat.' + `); + } + + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE "user" DROP COLUMN "speakAsCat" + `); + } +} diff --git a/packages/backend/src/models/entities/sw-subscription.ts b/packages/backend/src/models/entities/sw-subscription.ts index 26891c1ce7..8f18688eab 100644 --- a/packages/backend/src/models/entities/sw-subscription.ts +++ b/packages/backend/src/models/entities/sw-subscription.ts @@ -41,4 +41,9 @@ export class SwSubscription { length: 128, }) public publickey: string; + + @Column('boolean', { + default: false, + }) + public sendReadMessage: boolean; } diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts index c57ad916c9..c23f4f28d7 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -156,6 +156,12 @@ export class User { }) public isCat: boolean; + @Column('boolean', { + default: true, + comment: 'Whether to speak as a cat if isCat.', + }) + public speakAsCat: boolean; + @Column('boolean', { default: false, comment: 'Whether the User is the admin.', diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index b99ce16350..5e56a817bc 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -263,7 +263,7 @@ export const NoteRepository = db.getRepository(Note).extend({ : {}), }); - if (packed.user.isCat && packed.text) { + if (packed.user.isCat && packed.user.speakAsCat && packed.text) { const tokens = packed.text ? mfm.parse(packed.text) : []; function nyaizeNode(node: mfm.MfmNode) { if (node.type === "quote") return; diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 0bf31b1b33..27b0c78d6e 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -438,6 +438,7 @@ export const UserRepository = db.getRepository(User).extend({ isModerator: user.isModerator || falsy, isBot: user.isBot || falsy, isCat: user.isCat || falsy, + speakAsCat: user.speakAsCat || falsy, instance: user.host ? userInstanceCache .fetch( diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index 9fb5aa8f3b..7f76891650 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -66,6 +66,11 @@ export const packedUserLiteSchema = { nullable: false, optional: true, }, + speakAsCat: { + type: "boolean", + nullable: false, + optional: true, + }, emojis: { type: "array", nullable: false, diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts index bb178506b7..6beae2c782 100644 --- a/packages/backend/src/server/api/common/signup.ts +++ b/packages/backend/src/server/api/common/signup.ts @@ -1,4 +1,3 @@ -import bcrypt from "bcryptjs"; import { generateKeyPair } from "node:crypto"; import generateUserToken from "./generate-native-user-token.js"; import { User } from "@/models/entities/user.js"; @@ -12,6 +11,7 @@ import { usersChart } from "@/services/chart/index.js"; import { UsedUsername } from "@/models/entities/used-username.js"; import { db } from "@/db/postgre.js"; import config from "@/config/index.js"; +import { hashPassword } from "@/misc/password.js"; export async function signup(opts: { username: User["username"]; @@ -42,8 +42,7 @@ export async function signup(opts: { } // Generate hash of password - const salt = await bcrypt.genSalt(8); - hash = await bcrypt.hash(password, salt); + hash = await hashPassword(password); } // Generate secret diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 920f871995..57a3ce4dc2 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -290,6 +290,8 @@ import * as ep___resetDb from "./endpoints/reset-db.js"; import * as ep___resetPassword from "./endpoints/reset-password.js"; import * as ep___serverInfo from "./endpoints/server-info.js"; import * as ep___stats from "./endpoints/stats.js"; +import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; +import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; import * as ep___sw_register from "./endpoints/sw/register.js"; import * as ep___sw_unregister from "./endpoints/sw/unregister.js"; import * as ep___test from "./endpoints/test.js"; @@ -637,6 +639,8 @@ const eps = [ ["stats", ep___stats], ["sw/register", ep___sw_register], ["sw/unregister", ep___sw_unregister], + ['sw/show-registration', ep___sw_show_registration], + ['sw/update-registration', ep___sw_update_registration], ["test", ep___test], ["username/available", ep___username_available], ["users", ep___users], diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index dd3f258f74..cbe6735ce5 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -1,7 +1,8 @@ import define from "../../define.js"; -import bcrypt from "bcryptjs"; +// import bcrypt from "bcryptjs"; import rndstr from "rndstr"; import { Users, UserProfiles } from "@/models/index.js"; +import { hashPassword } from "@/misc/password.js"; export const meta = { tags: ["admin"], @@ -47,7 +48,8 @@ export default define(meta, paramDef, async (ps) => { const passwd = rndstr("a-zA-Z0-9", 8); // Generate hash of password - const hash = bcrypt.hashSync(passwd); + // const hash = bcrypt.hashSync(passwd); + const hash = await hashPassword(passwd); await UserProfiles.update( { diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 28aac4a4d4..d3a39acd2d 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -209,7 +209,12 @@ export default define(meta, paramDef, async (ps, me) => { } if (Array.isArray(ps.blockedHosts)) { - set.blockedHosts = ps.blockedHosts.filter(Boolean); + let lastValue = ''; + set.blockedHosts = ps.blockedHosts.sort().filter(h => { + const lv = lastValue; + lastValue = h; + return h !== '' && h !== lv; + }); } if (ps.themeColor !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index e80dc4d711..f0581de4b4 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -1,4 +1,3 @@ -import bcrypt from "bcryptjs"; import { promisify } from "node:util"; import * as cbor from "cbor"; import define from "../../../define.js"; @@ -11,6 +10,7 @@ import { import config from "@/config/index.js"; import { procedures, hash } from "../../../2fa.js"; import { publishMainStream } from "@/services/stream.js"; +import { comparePassword } from "@/misc/password.js"; const cborDecodeFirst = promisify(cbor.decodeFirst) as any; const rpIdHashReal = hash(Buffer.from(config.hostname, "utf-8")); @@ -43,7 +43,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 96239b56de..a10dc9b256 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -1,10 +1,10 @@ -import bcrypt from "bcryptjs"; import define from "../../../define.js"; import { UserProfiles, AttestationChallenges } from "@/models/index.js"; import { promisify } from "node:util"; import * as crypto from "node:crypto"; import { genId } from "@/misc/gen-id.js"; import { hash } from "../../../2fa.js"; +import { comparePassword } from "@/misc/password.js"; const randomBytes = promisify(crypto.randomBytes); @@ -26,7 +26,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index 9019787f23..533035bc91 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,9 +1,9 @@ -import bcrypt from "bcryptjs"; import * as speakeasy from "speakeasy"; import * as QRCode from "qrcode"; import config from "@/config/index.js"; import { UserProfiles } from "@/models/index.js"; import define from "../../../define.js"; +import { comparePassword } from "@/misc/password.js"; export const meta = { requireCredential: true, @@ -23,7 +23,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index d491f0a6ee..862c971e75 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -1,4 +1,4 @@ -import bcrypt from "bcryptjs"; +import { comparePassword } from "@/misc/password.js"; import define from "../../../define.js"; import { UserProfiles, UserSecurityKeys, Users } from "@/models/index.js"; import { publishMainStream } from "@/services/stream.js"; @@ -22,7 +22,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 9bb1538b00..57d57ff65a 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,6 +1,6 @@ -import bcrypt from "bcryptjs"; import define from "../../../define.js"; import { UserProfiles } from "@/models/index.js"; +import { comparePassword } from "@/misc/password.js"; export const meta = { requireCredential: true, @@ -20,7 +20,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index fcfc38bd14..8bbb3ad93a 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -1,6 +1,6 @@ -import bcrypt from "bcryptjs"; import define from "../../define.js"; import { UserProfiles } from "@/models/index.js"; +import { hashPassword, comparePassword } from "@/misc/password.js"; export const meta = { requireCredential: true, @@ -21,15 +21,14 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.currentPassword, profile.password!); + const same = await comparePassword(ps.currentPassword, profile.password!); if (!same) { throw new Error("incorrect password"); } // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.newPassword, salt); + const hash = await hashPassword(ps.newPassword); await UserProfiles.update(user.id, { password: hash, diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index 81aee9a41a..781abe0b38 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -1,7 +1,7 @@ -import bcrypt from "bcryptjs"; import { UserProfiles, Users } from "@/models/index.js"; import { deleteAccount } from "@/services/delete-account.js"; import define from "../../define.js"; +import { comparePassword } from "@/misc/password.js"; export const meta = { requireCredential: true, @@ -25,7 +25,7 @@ export default define(meta, paramDef, async (ps, user) => { } // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index dff37cf3d3..b5b34c0902 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -1,4 +1,3 @@ -import bcrypt from "bcryptjs"; import { publishInternalEvent, publishMainStream, @@ -7,6 +6,7 @@ import { import generateUserToken from "../../common/generate-native-user-token.js"; import define from "../../define.js"; import { Users, UserProfiles } from "@/models/index.js"; +import { comparePassword } from "@/misc/password.js"; export const meta = { requireCredential: true, @@ -29,7 +29,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new Error("incorrect password"); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index c3c24d4d3d..94ad6b3c72 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -2,12 +2,12 @@ import { publishMainStream } from "@/services/stream.js"; import define from "../../define.js"; import rndstr from "rndstr"; import config from "@/config/index.js"; -import bcrypt from "bcryptjs"; import { Users, UserProfiles } from "@/models/index.js"; import { sendEmail } from "@/services/send-email.js"; import { ApiError } from "../../error.js"; import { validateEmailForAccount } from "@/services/validate-email-for-account.js"; import { HOUR } from "@/const.js"; +import { comparePassword } from "@/misc/password.js"; export const meta = { requireCredential: true, @@ -47,7 +47,7 @@ export default define(meta, paramDef, async (ps, user) => { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await comparePassword(ps.password, profile.password!); if (!same) { throw new ApiError(meta.errors.incorrectPassword); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 48868de37e..56ed64296b 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -104,6 +104,7 @@ export const paramDef = { noCrawle: { type: "boolean" }, isBot: { type: "boolean" }, isCat: { type: "boolean" }, + speakAsCat: { type: "boolean" }, showTimelineReplies: { type: "boolean" }, injectFeaturedNote: { type: "boolean" }, receiveAnnouncementEmail: { type: "boolean" }, @@ -191,6 +192,7 @@ export default define(meta, paramDef, async (ps, _user, token) => { profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.noCrawle === "boolean") profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.isCat === "boolean") updates.isCat = ps.isCat; + if (typeof ps.speakAsCat === "boolean") updates.speakAsCat = ps.speakAsCat; if (typeof ps.injectFeaturedNote === "boolean") profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === "boolean") diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index e4a38cffb1..584a6ce020 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -93,13 +93,27 @@ export default define(meta, paramDef, async (ps, user) => { } //#endregion - const timeline = await query.take(ps.limit).getMany(); - process.nextTick(() => { if (user) { activeUsersChart.read(user); } }); + + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } - return await Notes.packMany(timeline, user); + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 3d6103da87..78846861ad 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -151,11 +151,25 @@ export default define(meta, paramDef, async (ps, user) => { } //#endregion - const timeline = await query.take(ps.limit).getMany(); - process.nextTick(() => { activeUsersChart.read(user); }); - return await Notes.packMany(timeline, user); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } + + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 22e5965fce..fead877387 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -123,13 +123,27 @@ export default define(meta, paramDef, async (ps, user) => { } //#endregion - const timeline = await query.take(ps.limit).getMany(); - process.nextTick(() => { if (user) { activeUsersChart.read(user); } }); - return await Notes.packMany(timeline, user); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } + + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 3e5c4f18b2..7ffe83e5c9 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -86,9 +86,24 @@ export default define(meta, paramDef, async (ps, user) => { query.setParameters(followingQuery.getParameters()); } - const mentions = await query.take(ps.limit).getMany(); - read(user.id, mentions); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } - return await Notes.packMany(mentions, user); + if (found.length > ps.limit) { + found.length = ps.limit; + } + + read(user.id, found); + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts index 6dacadec2a..c73114de61 100644 --- a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts @@ -126,13 +126,27 @@ export default define(meta, paramDef, async (ps, user) => { } //#endregion - const timeline = await query.take(ps.limit).getMany(); - process.nextTick(() => { if (user) { activeUsersChart.read(user); } }); - return await Notes.packMany(timeline, user); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } + + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index b09243e7e6..60d398088a 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -74,7 +74,21 @@ export default define(meta, paramDef, async (ps, user) => { if (user) generateMutedUserQuery(query, user); if (user) generateBlockedUserQuery(query, user); - const renotes = await query.take(ps.limit).getMany(); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } - return await Notes.packMany(renotes, user); + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 0a8e909496..94a752a64b 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -58,7 +58,21 @@ export default define(meta, paramDef, async (ps, user) => { if (user) generateMutedUserQuery(query, user); if (user) generateBlockedUserQuery(query, user); - const timeline = await query.take(ps.limit).getMany(); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } - return await Notes.packMany(timeline, user); + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index d8d0dbbf73..f1cae78ba9 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -145,8 +145,21 @@ export default define(meta, paramDef, async (ps, me) => { } } - // Search notes - const notes = await query.take(ps.limit).getMany(); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, me)) + skip += take; + if (notes.length < take) break; + } - return await Notes.packMany(notes, me); + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 083f41365a..e79f6b5898 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -143,11 +143,25 @@ export default define(meta, paramDef, async (ps, user) => { } //#endregion - const timeline = await query.take(ps.limit).getMany(); - process.nextTick(() => { activeUsersChart.read(user); }); - return await Notes.packMany(timeline, user); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } + + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index c1e5357222..1123a33ae7 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -138,9 +138,27 @@ export default define(meta, paramDef, async (ps, user) => { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); - activeUsersChart.read(user); + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(ps.limit * 1.5); + let skip = 0; + while (found.length < ps.limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...await Notes.packMany(notes, user)) + skip += take; + if (notes.length < take) break; + } - return await Notes.packMany(timeline, user); + if (found.length > ps.limit) { + found.length = ps.limit; + } + + return found; }); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index 51755727a3..f695ae41f1 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -1,8 +1,8 @@ -import bcrypt from "bcryptjs"; import { publishMainStream } from "@/services/stream.js"; import { Users, UserProfiles, PasswordResetRequests } from "@/models/index.js"; import define from "../define.js"; import { ApiError } from "../error.js"; +import { hashPassword } from "@/misc/password.js"; export const meta = { tags: ["reset password"], @@ -34,8 +34,7 @@ export default define(meta, paramDef, async (ps, user) => { } // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.password, salt); + const hash = await hashPassword(ps.password); await UserProfiles.update(req.userId, { password: hash, diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index 7218b0d50a..d2b805d974 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -26,6 +26,18 @@ export const meta = { optional: false, nullable: true, }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + endpoint: { + type: 'string', + optional: false, nullable: false, + }, + sendReadMessage: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, } as const; @@ -36,14 +48,15 @@ export const paramDef = { endpoint: { type: "string" }, auth: { type: "string" }, publickey: { type: "string" }, + sendReadMessage: { type: 'boolean', default: false }, }, required: ["endpoint", "auth", "publickey"], } as const; -export default define(meta, paramDef, async (ps, user) => { +export default define(meta, paramDef, async (ps, me) => { // if already subscribed const exist = await SwSubscriptions.findOneBy({ - userId: user.id, + userId: me.id, endpoint: ps.endpoint, auth: ps.auth, publickey: ps.publickey, @@ -55,20 +68,27 @@ export default define(meta, paramDef, async (ps, user) => { return { state: "already-subscribed" as const, key: instance.swPublicKey, + userId: me.id, + endpoint: exist.endpoint, + sendReadMessage: exist.sendReadMessage, }; } await SwSubscriptions.insert({ id: genId(), createdAt: new Date(), - userId: user.id, + userId: me.id, endpoint: ps.endpoint, auth: ps.auth, publickey: ps.publickey, + sendReadMessage: ps.sendReadMessage, }); return { state: "subscribed" as const, key: instance.swPublicKey, + userId: me.id, + endpoint: ps.endpoint, + sendReadMessage: ps.sendReadMessage, }; }); diff --git a/packages/backend/src/server/api/endpoints/sw/show-registration.ts b/packages/backend/src/server/api/endpoints/sw/show-registration.ts new file mode 100644 index 0000000000..25eb53f527 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/show-registration.ts @@ -0,0 +1,55 @@ +import { SwSubscriptions } from '@/models/index.js'; +import define from "../../define.js"; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + description: 'Check push notification registration exists.', + + res: { + type: 'object', + optional: false, nullable: true, + properties: { + userId: { + type: 'string', + optional: false, nullable: false, + }, + endpoint: { + type: 'string', + optional: false, nullable: false, + }, + sendReadMessage: { + type: 'boolean', + optional: false, nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + endpoint: { type: 'string' }, + }, + required: ['endpoint'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const exist = await SwSubscriptions.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + }); + + if (exist != null) { + return { + userId: exist.userId, + endpoint: exist.endpoint, + sendReadMessage: exist.sendReadMessage, + }; + } + + return null; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts index b025630e4b..e2a40f51cb 100644 --- a/packages/backend/src/server/api/endpoints/sw/unregister.ts +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -4,7 +4,7 @@ import define from "../../define.js"; export const meta = { tags: ["account"], - requireCredential: true, + requireCredential: false, description: "Unregister from receiving push notifications.", } as const; @@ -17,9 +17,9 @@ export const paramDef = { required: ["endpoint"], } as const; -export default define(meta, paramDef, async (ps, user) => { +export default define(meta, paramDef, async (ps, me) => { await SwSubscriptions.delete({ - userId: user.id, + ...(me ? { userId: me.id } : {}), endpoint: ps.endpoint, }); }); diff --git a/packages/backend/src/server/api/endpoints/sw/update-registration.ts b/packages/backend/src/server/api/endpoints/sw/update-registration.ts new file mode 100644 index 0000000000..0b0a56d499 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/update-registration.ts @@ -0,0 +1,44 @@ +import { SwSubscriptions } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + description: "Unregister from receiving push notifications.", +} as const; + +export const paramDef = { + type: "object", + properties: { + endpoint: { type: "string" }, + sendReadMessage: { type: 'boolean' }, + }, + required: ["endpoint"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const swSubscription = await SwSubscriptions.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + }); + + if (swSubscription === null) { + throw new Error("No such registration"); + } + + if (ps.sendReadMessage !== undefined) { + swSubscription.sendReadMessage = ps.sendReadMessage; + } + + await SwSubscriptions.update(swSubscription.id, { + sendReadMessage: swSubscription.sendReadMessage, + }); + + return { + userId: swSubscription.userId, + endpoint: swSubscription.endpoint, + sendReadMessage: swSubscription.sendReadMessage, + }; +}); diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts index 7c4b511a2f..ef5b137813 100644 --- a/packages/backend/src/server/api/private/signin.ts +++ b/packages/backend/src/server/api/private/signin.ts @@ -1,5 +1,4 @@ import type Koa from "koa"; -import bcrypt from "bcryptjs"; import * as speakeasy from "speakeasy"; import signin from "../common/signin.js"; import config from "@/config/index.js"; diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts index dc5eb23168..754d86c3b8 100644 --- a/packages/backend/src/server/api/private/signup.ts +++ b/packages/backend/src/server/api/private/signup.ts @@ -1,6 +1,5 @@ import type Koa from "koa"; import rndstr from "rndstr"; -import bcrypt from "bcryptjs"; import { fetchMeta } from "@/misc/fetch-meta.js"; import { verifyHcaptcha, verifyRecaptcha } from "@/misc/captcha.js"; import { Users, RegistrationTickets, UserPendings } from "@/models/index.js"; @@ -9,6 +8,7 @@ import config from "@/config/index.js"; import { sendEmail } from "@/services/send-email.js"; import { genId } from "@/misc/gen-id.js"; import { validateEmailForAccount } from "@/services/validate-email-for-account.js"; +import { hashPassword } from "@/misc/password.js"; export default async (ctx: Koa.Context) => { const body = ctx.request.body; @@ -79,8 +79,7 @@ export default async (ctx: Koa.Context) => { const code = rndstr("a-z0-9", 16); // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); + const hash = await hashPassword(password); await UserPendings.insert({ id: genId(), diff --git a/packages/backend/src/services/create-system-user.ts b/packages/backend/src/services/create-system-user.ts index def3ee98fb..24536090a9 100644 --- a/packages/backend/src/services/create-system-user.ts +++ b/packages/backend/src/services/create-system-user.ts @@ -1,4 +1,3 @@ -import bcrypt from "bcryptjs"; import { v4 as uuid } from "uuid"; import generateNativeUserToken from "../server/api/common/generate-native-user-token.js"; import { genRsaKeyPair } from "@/misc/gen-key-pair.js"; @@ -9,13 +8,13 @@ import { genId } from "@/misc/gen-id.js"; import { UserKeypair } from "@/models/entities/user-keypair.js"; import { UsedUsername } from "@/models/entities/used-username.js"; import { db } from "@/db/postgre.js"; +import { hashPassword } from "@/misc/password.js"; export async function createSystemUser(username: string) { const password = uuid(); // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); + const hash = await hashPassword(password); // Generate secret const secret = generateNativeUserToken(); diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts index 0e51ad9675..a3abaf769c 100644 --- a/packages/backend/src/services/push-notification.ts +++ b/packages/backend/src/services/push-notification.ts @@ -63,6 +63,13 @@ export async function pushNotification( }); for (const subscription of subscriptions) { + if ([ + 'readNotifications', + 'readAllNotifications', + 'readAllMessagingMessages', + 'readAllMessagingMessagesOfARoom', + ].includes(type) && !subscription.sendReadMessage) continue; + const pushSubscription = { endpoint: subscription.endpoint, keys: { diff --git a/packages/client/src/components/MkFollowButton.vue b/packages/client/src/components/MkFollowButton.vue index 37c48261fa..ba8c8170ea 100644 --- a/packages/client/src/components/MkFollowButton.vue +++ b/packages/client/src/components/MkFollowButton.vue @@ -141,7 +141,7 @@ onBeforeUnmount(() => {