diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fde7ec0f2..6accd4347 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,5 +7,18 @@ "ghcr.io/devcontainers-contrib/features/pnpm:2": {} }, "forwardPorts": [3000], - "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh" + "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", + "customizations": { + "vscode": { + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "Vue.volar", + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" + ] + } + } } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6ec3c86a4..8f8c5a13a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -16,7 +16,7 @@ services: - external_network redis: - restart: always + restart: unless-stopped image: redis:7-alpine networks: - internal_network diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 450c3920c..bcad3e6d8 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -4,6 +4,7 @@ set -xe sudo chown -R node /workspace git submodule update --init +pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml pnpm build diff --git a/.dockerignore b/.dockerignore index 8f984831e..151ede038 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,8 @@ fluent-emojis/ !.yarn/sdks !.yarn/versions +.pnpm-store + .idea/ packages/*/.vscode/ packages/backend/test/docker-compose.yml diff --git a/.gitignore b/.gitignore index 62b818c62..c413cd4da 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ packages/frontend/.yarn/cache packages/backend/.yarn/cache packages/sw/.yarn/cache +# pnpm +.pnpm-store + # Cypress cypress/screenshots cypress/videos diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 42264548e..baca8db24 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,11 @@ { "recommendations": [ "editorconfig.editorconfig", - "eg2.vscode-npm-script", "dbaeumer.vscode-eslint", "Vue.volar", - "Vue.vscode-typescript-vue-plugin" + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 6a0497946..baffbe18e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "typescript.tsdk": "node_modules/typescript/lib", "files.associations": { "*.test.ts": "typescript" - } + }, + "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a5ab3e1..f067de292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,28 @@ - ### Bugfixes -- +x You should also include the user name that made the change. --> +## 13.9.2 (2023/03/06) + +### Improvements +- クリップ、チャンネルページに共有ボタンを追加 +- チャンネルでタイムライン上部に投稿フォームを表示するかどうかのオプションを追加 +- ブラウザでメディアプロキシ(/proxy)からファイルを保存した際に、なるべくオリジナルのファイル名を継承するように +- ドライブの「URLからアップロード」で、content-dispositionのfilenameがあればそれをファイル名に +- Identiconがローカルとリモートで同じになるように + - これまでのIdenticonは異なる画像になります +- サーバーのパフォーマンスを改善 + +### Bugfixes +- ロールの権限で「一般ユーザー」のロールがいきなり設定できない問題を修正 +- ユーザーページのバッジ表示を適切に折り返すように @arrow2nd +- fix(client): みつけるのロール一覧でコンディショナルロールが含まれるのを修正 +- macOSでDev Containerが動作しない問題を修正 @RyotaK + ## 13.9.1 (2023/03/03) ### Bugfixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d93cd9f..887d17961 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. diff --git a/Dockerfile b/Dockerfile index b439716be..fd0b5e1c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ ARG NODE_VERSION=18.13.0-bullseye -FROM node:${NODE_VERSION} AS builder +# build assets & compile TypeScript + +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS native-builder RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ @@ -33,33 +35,49 @@ RUN git submodule update --init RUN pnpm build RUN rm -rf .git/ -FROM node:${NODE_VERSION}-slim AS runner +# build native dependencies for target platform + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION} AS target-builder + +RUN apt-get update \ + && apt-get install -yqq --no-install-recommends \ + build-essential + +RUN corepack enable + +WORKDIR /misskey + +COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] +COPY --link ["scripts", "./scripts"] +COPY --link ["packages/backend/package.json", "./packages/backend/"] + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm i --frozen-lockfile --aggregate-output + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION}-slim AS runner ARG UID="991" ARG GID="991" -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - rm -f /etc/apt/apt.conf.d/docker-clean \ - ; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ - && apt-get update \ +RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg tini curl \ && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ - && find / -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ - && find / -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; + && find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ + && find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists USER misskey WORKDIR /misskey -COPY --chown=misskey:misskey --from=builder /misskey/node_modules ./node_modules -COPY --chown=misskey:misskey --from=builder /misskey/built ./built -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/built ./packages/backend/built -COPY --chown=misskey:misskey --from=builder /misskey/packages/frontend/node_modules ./packages/frontend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/fluent-emojis /misskey/fluent-emojis +COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules +COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ ENV NODE_ENV=production diff --git a/chart/templates/Deployment.yml b/chart/templates/Deployment.yml index d16aece91..d5dd14f59 100644 --- a/chart/templates/Deployment.yml +++ b/chart/templates/Deployment.yml @@ -3,16 +3,16 @@ kind: Deployment metadata: name: {{ include "misskey.fullname" . }} labels: - {{- include "misskey.labels" . | nindent 4 }} + {{- include "misskey.labels" . | nindent 4 }} spec: selector: matchLabels: - {{- include "misskey.selectorLabels" . | nindent 6 }} + {{- include "misskey.selectorLabels" . | nindent 6 }} replicas: 1 template: metadata: labels: - {{- include "misskey.selectorLabels" . | nindent 8 }} + {{- include "misskey.selectorLabels" . | nindent 8 }} spec: containers: - name: misskey diff --git a/chart/templates/Service.yml b/chart/templates/Service.yml index 320958129..afd851a9f 100644 --- a/chart/templates/Service.yml +++ b/chart/templates/Service.yml @@ -11,4 +11,4 @@ spec: protocol: TCP name: http selector: - {{- include "misskey.selectorLabels" . | nindent 4 }} + {{- include "misskey.selectorLabels" . | nindent 4 }} diff --git a/locales/de-DE.yml b/locales/de-DE.yml index c5ddf334c..eae9389ac 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -345,7 +345,7 @@ basicInfo: "Grundlegende Informationen" pinnedUsers: "Angeheftete Benutzer" pinnedUsersDescription: "Gib durch Leerzeichen getrennte Benutzer an, die an die \"Erkunden\"-Seite angeheftet werden sollen." pinnedPages: "Angeheftete Seiten" -pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfäde zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen.\n" +pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfade zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen." pinnedClipId: "ID des anzuheftenden Clips" pinnedNotes: "Angeheftete Notizen" hcaptcha: "hCaptcha" @@ -404,7 +404,7 @@ securityKey: "Sicherheitsschlüssel" lastUsed: "Zuletzt benutzt" lastUsedAt: "Zuletzt verwendet: {t}" unregister: "Deaktivieren" -passwordLessLogin: "Passwortloses Anmelden einrichten" +passwordLessLogin: "Passwortloses Anmelden" passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey" resetPassword: "Passwort zurücksetzen" newPasswordIs: "Das neue Passwort ist „{password}“" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Bei Upload auf \"public-read\" stellen" serverLogs: "Serverprotokolle" deleteAll: "Alle löschen" showFixedPostForm: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen" +showFixedPostFormInChannel: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen (Kanäle)" newNoteRecived: "Es gibt neue Notizen" sounds: "Töne" sound: "Töne" @@ -955,6 +956,9 @@ exploreOtherServers: "Eine andere Instanz finden" letsLookAtTimeline: "Die Chronik durchstöbern" disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt." invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren." +emailNotSupported: "Diese Instanz unterstützt das Versenden von Emails nicht" +postToTheChannel: "In Kanal senden" +cannotBeChangedLater: "Kann später nicht mehr geändert werden." _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1163,7 +1167,7 @@ _achievements: description: "Du hast hier geklickt" _justPlainLucky: title: "Pures Glück" - description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.01% erhalten werden" + description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.005% erhalten werden" _setNameToSyuilo: title: "Gottkomplex" description: "Setze deinen Namen auf \"syuilo\"" @@ -1510,7 +1514,7 @@ _2fa: step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" step3Title: "Authentifizierungsscode eingeben" step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." - step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." + step4: "Alle folgenden Anmeldeversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens." registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren." securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." diff --git a/locales/en-US.yml b/locales/en-US.yml index 638c47091..53a30c741 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -197,7 +197,7 @@ clearQueueConfirmText: "Any undelivered notes remaining in the queue will not be clearCachedFiles: "Clear cache" clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" blockedInstances: "Blocked Instances" -blockedInstancesDescription: "List the hostnames of the instances that you want to block. Listed instances will no longer be able to communicate with this instance." +blockedInstancesDescription: "List the hostnames of the instances that you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Set \"public-read\" on upload" serverLogs: "Server logs" deleteAll: "Delete all" showFixedPostForm: "Display the posting form at the top of the timeline" +showFixedPostFormInChannel: "Display the posting form at the top of the timeline (Channels)" newNoteRecived: "There are new notes" sounds: "Sounds" sound: "Sounds" @@ -955,6 +956,9 @@ exploreOtherServers: "Look for another instance" letsLookAtTimeline: "Have a look at the timeline" disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting." invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." +emailNotSupported: "This instance does not support sending emails" +postToTheChannel: "Post to channel" +cannotBeChangedLater: "This cannot be changed later." _achievements: earnedAt: "Unlocked at" _types: @@ -1182,7 +1186,7 @@ _achievements: _loggedInOnNewYearsDay: title: "Happy New Year!" description: "Logged in on the first day of the year" - flavor: "To another great year!" + flavor: "To another great year on this instance" _cookieClicked: title: "A game in which you click cookies" description: "Clicked the cookie" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 89d456d1f..d5638aeb6 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -54,8 +54,8 @@ copyUsername: "Copia nome utente" searchUser: "Cerca utente" reply: "Rispondi" loadMore: "Mostra di più" -showMore: "Mostra di più" -showLess: "Chiudi" +showMore: "Espandi" +showLess: "Comprimi" youGotNewFollower: "Ha iniziato a seguirti" receiveFollowRequest: "Hai ricevuto una richiesta di follow" followRequestAccepted: "Richiesta di follow accettata" @@ -76,7 +76,7 @@ noLists: "Nessuna lista" note: "Nota" notes: "Note" following: "Follow" -followers: "Followers" +followers: "Follower" followsYou: "Ti segue" createList: "Aggiungi una nuova lista" manageLists: "Gestisci liste" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di cari serverLogs: "Log del server" deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" +showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" newNoteRecived: "Vedi le nuove note" sounds: "Impostazioni suoni" sound: "Impostazioni suoni" @@ -558,7 +559,7 @@ visibility: "Visibilità" poll: "Sondaggio" useCw: "Nascondere media" enablePlayer: "Apri in lettore video" -disablePlayer: "Chiudi lettore video" +disablePlayer: "Chiudi il lettore" expandTweet: "Espandi tweet" themeEditor: "Editor di temi" description: "Descrizione" @@ -955,6 +956,9 @@ exploreOtherServers: "Trova altre istanze" letsLookAtTimeline: "Sbircia la timeline" disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione." invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore." +emailNotSupported: "L'istanza non supporta l'invio di email" +postToTheChannel: "Pubblica sul canale" +cannotBeChangedLater: "Non sarà più modificabile" _achievements: earnedAt: "Data di conseguimento" _types: @@ -1613,7 +1617,7 @@ _widgets: clicker: "Cliccaggio" _cw: hide: "Nascondere" - show: "Mostra di più" + show: "Apri..." chars: "{count} caratteri" files: "{count} file" _poll: diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8fee2726e..e441055cb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "アップロード時に'public-read'を設定す serverLogs: "サーバーログ" deleteAll: "全て削除" showFixedPostForm: "タイムライン上部に投稿フォームを表示する" +showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" newNoteRecived: "新しいノートがあります" sounds: "サウンド" sound: "サウンド" @@ -955,6 +956,9 @@ exploreOtherServers: "他のサーバーを探す" letsLookAtTimeline: "タイムラインを見てみる" disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" +emailNotSupported: "このサーバーではメール配信はサポートされていません" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後から変更できません。" _achievements: earnedAt: "獲得日時" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 862453dd5..081457c2f 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "アップロードした時に'public-read'を設 serverLogs: "サーバーログ" deleteAll: "全て削除してや" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" +showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)" newNoteRecived: "新しいノートがあるで" sounds: "サウンド" sound: "サウンド" @@ -909,7 +910,7 @@ subscribePushNotification: "プッシュ通知をオンにするで" unsubscribePushNotification: "プッシュ通知を止めるで" pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで" pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。" -sendPushNotificationReadMessage: "通知やメッセージが既読担ったらプッシュ通知を消すで" +sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" windowMaximize: "最大化" windowRestore: "元に戻す" @@ -955,6 +956,9 @@ exploreOtherServers: "他のサーバー見てみる" letsLookAtTimeline: "タイムライン見てみーや" disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" +emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後からは変えられへんで。" _achievements: earnedAt: "貰った日ぃ" _types: @@ -1072,7 +1076,7 @@ _achievements: description: "プロフィールを設定した" _markedAsCat: title: "吾輩は猫やねん" - description: "アカウントがCatになってもうた" + description: "アカウントをCatにしたった" flavor: "名前はまだないねん。" _following1: title: "はじめてのフォロー" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index ce596d038..9c1a48c67 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -168,7 +168,9 @@ done: "ສຳເລັດ" processing: "ກຳລັງປະມວນຜົນ" preview: "ສະແດງເປັນຕົວຢ່າງ" default: "ຄ່າເລີ່ມຕົ້ນ" +federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " +suspended: "ໂຈະ" all: "ທັງໝົດ" subscribing: "ສະໝັກສະມາຊິກແລັວ" publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່" @@ -177,15 +179,35 @@ instanceFollowing: "ກຳລັງຕິດຕາມສຸດຕົວຢ່າ instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ" instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​" changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" +security: "ຄວາມປອດໄພ" +retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" +currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" +more: "ເພີ່ມເຕີມ!" featured: "ໄຮໄລທ໌" +usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" +noSuchUser: "ບໍ່ພົບຜູ້ໃຊ້" +lookup: "ຄົ້ນ​ຫາ" announcements: "ປະກາດ" +imageUrl: "URL ຮູບພາບ" remove: "ລຶບ" +removed: "ລຶບແລ້ວ" +resetAreYouSure: "ຣີ​ເຊັດບໍ?" +saved: "ບັນທຶກແລ້ວ" messaging: "ແຊ໋ດ" +upload: "ອັບໂຫຼດ" +keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" +fromUrl: "ຈາກ URL" +uploadFromUrl: "ອັບໂຫຼດຈາກ URL" +uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" +messageRead: "ອ່ານແລ້ວ" +startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" +nUsersRead: "ອ່ານໂດຍ {n}" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" images: "ຮູບພາບ" birthday: "ວັນເກີດ" +yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" location: "ທີ່ຕັ້ງ" theme: "ແທ໋ມ" @@ -193,17 +215,96 @@ light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" +drive: "ຂັບ" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" selectFiles: "ເລືອກໄຟລ໌" +selectFolder: "ເລືອກໂຟລເດີ" +selectFolders: "ເລືອກໂຟລເດີ" +renameFile: "ປ່ຽນຊື່ໄຟລ໌" +folderName: "ຊື່ໂຟນເດີ" +createFolder: "​ສ້າງ​ໂຟ​ລ​ເດີ" +renameFolder: "ປ່ຽນຊື່ໂຟນເດີນີ້" +deleteFolder: "ລົບໂຟ​ລ​ເດີ​" +addFile: "ເພີ່ມໄຟລ໌" +emptyDrive: "Drive ຂອງທ່ານຫວ່າງເປົ່າ" +emptyFolder: "ໂຟນເດີນີ້ເປົ່າຫວ່າງ" +unableToDelete: "ບໍ່​ສາ​ມາດລົບໄດ້" +inputNewFileName: "ໃສ່ຊື່ໄຟລ໌ໃໝ່" +inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່" +inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່" +circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ" +rename: "ປ່ຽນຊື່" nsfw: "NSFW" +watch: "ເບິ່ງ" +unwatch: "ຢຸດເບິ່ງ" accept: "ອະນຸຍາດ" +reject: "ປະຕິເສດ" +normal: "ປົກກະຕິ" +instanceName: "ຊື່ເຊີເວີ້" +instanceDescription: "ຄໍາອະທິບາຍຕົວຢ່າງ" +maintainerName: "ຜູ້ດູແລ" +maintainerEmail: "ອີເມວ admin" +tosUrl: "ເງື່ອນໄຂການໃຫ້ບໍລິການ URL" +thisYear: "ປີນີ້" +thisMonth: "ເດືອນນີ້" +today: "ມື້ນີ້" +dayX: "ວັນ {day}" +monthX: "ເດືອນ {month}" +yearX: "ປີ {year}" +pages: "ໜ້າ" +integration: "ຄວາມສຳພັນຂອງ" +connectService: "ເຊື່ອມຕໍ່" +disconnectService: "ຕັດການເຊື່ອມຕໍ່" +enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ່ນ" +enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ" +disablingTimelinesInfo: "ຜູ້ເບິ່ງແຍງລະບົບ ແລະຜູ້ຄວບຄຸມຈະມີການເຂົ້າເຖິງທຸກກຳນົດເວລາ, ເຖິງແມ່ນວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍຕາມ" +registration: "ລົງທະບຽນ" +enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່" +invite: "ເຊີນ" +driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" +driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" userList: "ລາຍການ" +about: "ກ່ຽວກັບ" +aboutMisskey: "ກ່ຽວກັບ Misskey" +administrator: "ຜູ້ບໍລິຫານ" +share: "ແບ່ງປັນ" +notFound: "ບໍ່ພົບ" +cacheClear: "ລຶບລ້າງແຄສ" +invites: "ເຊີນ" +title: "ຫົວຂໍ້" +text: "ຂໍ້ຄວາມ" +enable: "ເປີດໃຊ້" +next: "ຕໍ່ໄປ" +invitations: "ເຊີນ" +language: "ພາສາ" +native: "ພາ​ສາ​ແມ່" +category: "ຫມວດຫມູ່" +tags: "ແທ໋ກ" +createAccount: "ສ້າງບັນຊີ" +existingAccount: "ທີ່ມີຢູ່" +dashboard: "ໜ້າປັດ" +local: "ທ້ອງຖິ່ນ" +objectStorageRegion: "ພາກ​ພື້ນ" +sounds: "ສຽງ" +sound: "ສຽງ" +none: "ບໍ່ມີ" +volume: "ລະດັບສຽງ" +details: "ລາຍລະອຽດ" +install: "ຕິດຕັ້ງ" +uninstall: "ຖອນການຕິດຕັ້ງ" +state: "ສະຖານະ" +sort: "ຈັດຮຽງໂດຍ" +ascendingOrder: "ນ້ອຍໄປຫາໃຫຍ່" +descendingOrder: "ໃຫຍ່ຫານ້ອຍ" +output: "ຜົນຜະລິດ" +script: "ບົດ​ຄວາມ" smtpHost: "ໂຮດສ" smtpUser: "ຊື່ຜູ້ໃຊ້" smtpPass: "ລະຫັດຜ່ານ" clearCache: "ລຶບລ້າງແຄສ" +info: "ກ່ຽວກັບ" user: "ຜູ້ໃຊ້ຕ່າງໆ" searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" @@ -244,6 +345,8 @@ _charts: federation: "ສະຫະພັນ" _timelines: home: "ໜ້າຫຼັກ" +_play: + script: "ບົດ​ຄວາມ" _pages: blocks: image: "ຮູບພາບ" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 7dcc15824..cf33e6642 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -268,7 +268,7 @@ remoteUserCaution: "เนื่องจากผู้ใช้งานรา activity: "กิจกรรม" images: "รูปภาพ" birthday: "วันเกิด" -yearsOld: "{อายุ} ปี" +yearsOld: "{age} ปี" registeredDate: "วันที่สมัครสมาชิก" location: "ตำแหน่งที่ตั้ง" theme: "ธีม" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในกา serverLogs: "บันทึกของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" +showFixedPostFormInChannel: "แสดงแบบฟอร์มกำลังโพสต์ที่ด้านบนของไทม์ไลน์ (แชนแนล)" newNoteRecived: "มีโน้ตใหม่" sounds: "เสียง" sound: "เสียง" @@ -955,6 +956,9 @@ exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" +emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" +postToTheChannel: "โพสต์ลงช่อง" +cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 517f5a9ef..7798582db 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2,7 +2,7 @@ _lang_: "中文(简体)" headlineMisskey: "通过帖子连接在一起的网络" introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧!🚀" -poweredByMisskeyDescription: "{name} 由开源平台 Misskey 驱动(也被称为 Misskey 实例)" +poweredByMisskeyDescription: "{name} 由开源平台 Misskey 驱动(也被称为 Misskey 服务器)" monthAndDay: "{month}月 {day}日" search: "搜索" notifications: "通知" @@ -18,7 +18,7 @@ enterUsername: "输入用户名" renotedBy: "由 {user} 转贴" noNotes: "没有帖子" noNotifications: "无通知" -instance: "实例" +instance: "服务器" settings: "设置" basicSettings: "基本设置" otherSettings: "其他设置" @@ -144,7 +144,7 @@ emojiUrl: "表情符号地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "远程文件缓存" -cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程实例载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" +cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" flagAsBot: "这是一个机器人账号" flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" @@ -154,7 +154,7 @@ flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的 autoAcceptFollowed: "自动允许关注者的关注" addAccount: "添加账户" loginFailed: "登录失败" -showOnRemote: "转到所在实例显示" +showOnRemote: "转到所在服务器显示" general: "常规设置" wallpaper: "壁纸" setWallpaper: "设置壁纸" @@ -169,7 +169,7 @@ selectUser: "选择用户" recipient: "收件人" annotation: "注解" federation: "联合" -instances: "实例" +instances: "服务器" registeredAt: "初次观测" latestRequestReceivedAt: "上次收到的请求" latestStatus: "最后状态" @@ -178,7 +178,7 @@ charts: "图表" perHour: "每小时" perDay: "每天" stopActivityDelivery: "停止发送活动" -blockThisInstance: "阻止此实例向本实例推流" +blockThisInstance: "阻止此服务器向本服务器推流" operations: "操作" software: "软件" version: "版本" @@ -189,15 +189,15 @@ jobQueue: "作业队列" cpuAndMemory: "CPU和内存" network: "网络" disk: "存储" -instanceInfo: "实例信息" +instanceInfo: "服务器信息" statistics: "统计" clearQueue: "清除队列" clearQueueConfirmTitle: "确定清除队列?" clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。" clearCachedFiles: "清除缓存" clearCachedFilesConfirm: "确定要清除缓存文件?" -blockedInstances: "被阻拦的实例" -blockedInstancesDescription: "设定要阻拦的实例,以换行来进行分割。被阻拦的实例将无法与本实例进行交换通讯。" +blockedInstances: "被阻拦的服务器" +blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。" muteAndBlock: "屏蔽/拉黑" mutedUsers: "已屏蔽用户" blockedUsers: "被拉黑的用户" @@ -220,9 +220,9 @@ all: "全部" subscribing: "已订阅" publishing: "投递中" notResponding: "没有响应" -instanceFollowing: "关注实例" -instanceFollowers: "关注实例" -instanceUsers: "实例用户" +instanceFollowing: "关注服务器" +instanceFollowers: "关注的服务器" +instanceUsers: "服务器用户" changePassword: "修改密码" security: "安全" retypedNotMatch: "两次输入不一致!" @@ -264,7 +264,7 @@ basicNotesBeforeCreateAccount: "基本注意事项" tos: "服务条款" start: "开始" home: "首页" -remoteUserCaution: "由于此用户来自其它实例,显示的信息可能不完整。" +remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。" activity: "活动" images: "图片" birthday: "生日" @@ -314,8 +314,8 @@ unwatch: "取消关注" accept: "允许" reject: "拒绝" normal: "正常" -instanceName: "实例名称" -instanceDescription: "实例介绍" +instanceName: "服务器名称" +instanceDescription: "服务器简介" maintainerName: "管理员名称" maintainerEmail: "管理员电子邮箱" tosUrl: "服务条款URL" @@ -345,7 +345,7 @@ basicInfo: "基本信息" pinnedUsers: "置顶用户" pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。" pinnedPages: "固定页面" -pinnedPagesDescription: "输入您要固定到实例首页的页面路径,以换行符分隔。" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。" pinnedClipId: "置顶的便签ID" pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "上传时设置为public-read" serverLogs: "服务器日志" deleteAll: "全部删除" showFixedPostForm: "在时间线顶部显示发帖框" +showFixedPostFormInChannel: "在时间线顶部显示发帖对话框(频道)" newNoteRecived: "有新的帖子" sounds: "提示音" sound: "提示音" @@ -538,7 +539,7 @@ updateRemoteUser: "更新远程用户信息" deleteAllFiles: "删除所有文件" deleteAllFilesConfirm: "要删除所有文件吗?" removeAllFollowing: "取消所有关注" -removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。" +removeAllFollowingDescription: "取消{host}的所有关注者。当服务器不再存在时执行。" userSuspended: "该用户已被冻结。" userSilenced: "该用户已被禁言。" yourAccountSuspendedTitle: "账户已被冻结" @@ -635,15 +636,15 @@ abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" -forwardReport: "将该举报信息转发给远程实例" -forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。" +forwardReport: "将该举报信息转发给远程服务器" +forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。" send: "发送" abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" -instanceTicker: "帖子的实例信息" +instanceTicker: "帖子的服务器来源" waitingFor: "等待{x}" random: "随机" system: "系统" @@ -732,7 +733,7 @@ capacity: "容量" inUse: "已使用" editCode: "编辑代码" apply: "应用" -receiveAnnouncementFromInstance: "从实例接收通知" +receiveAnnouncementFromInstance: "从服务器接收通知" emailNotification: "邮件通知" publish: "发布" inChannelSearch: "频道内搜索" @@ -760,7 +761,7 @@ active: "活动" offline: "离线" notRecommended: "不推荐" botProtection: "Bot防御" -instanceBlocking: "被阻拦的实例" +instanceBlocking: "被阻拦的服务器" selectAccount: "选择账户" switchAccount: "切换账户" enabled: "已启用" @@ -844,8 +845,8 @@ themeColor: "主题颜色" size: "大小" numberOfColumn: "列数" searchByGoogle: "Google" -instanceDefaultLightTheme: "实例默认浅色主题" -instanceDefaultDarkTheme: "实例默认深色主题" +instanceDefaultLightTheme: "服务器默认浅色主题" +instanceDefaultDarkTheme: "服务器默认深色主题" instanceDefaultThemeDescription: "以对象格式键入主题代码" mutePeriod: "屏蔽期限" period: "截止时间" @@ -898,7 +899,7 @@ cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法 cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" beta: "测试" enableAutoSensitive: "自动 NSFW 识别" -enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。" +enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" navbar: "导航栏" shuffle: "随机" @@ -908,7 +909,7 @@ pushNotification: "推送通知" subscribePushNotification: "启用推送通知消息" unsubscribePushNotification: "停用推送通知消息" pushNotificationAlreadySubscribed: "推送通知消息已启用" -pushNotificationNotSupported: "浏览器或实例不支持推送通知消息" +pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" windowMaximize: "最大化" @@ -950,11 +951,14 @@ collapseRenotes: "省略显示已经看过的转发内容" internalServerError: "内部服务器错误" internalServerErrorDescription: "内部服务器发生了预期外的错误" copyErrorInfo: "复制错误信息" -joinThisServer: "在本实例上注册" -exploreOtherServers: "探索其他实例" +joinThisServer: "在本服务器上注册" +exploreOtherServers: "探索其他服务器" letsLookAtTimeline: "时间线" disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" -invitationRequiredToRegister: "此实例目前只允许拥有邀请码的人注册。" +invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人注册。" +emailNotSupported: "此服务器不支持发送邮件" +postToTheChannel: "发布到频道" +cannotBeChangedLater: "之后不能再更改。" _achievements: earnedAt: "达成时间" _types: @@ -1145,7 +1149,7 @@ _achievements: description: "在首页时间线的流速超过20npm" _viewInstanceChart: title: "分析师" - description: "查看了实例信息中的图表" + description: "查看了服务器信息中的图表" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "在AiScript控制台中输出 hello world" @@ -1182,7 +1186,7 @@ _achievements: _loggedInOnNewYearsDay: title: "恭贺新禧" description: "在元旦登入" - flavor: "今年也请对本实例多多指教!" + flavor: "今年也请对本服务器多多指教!" _cookieClicked: title: "点击饼干小游戏" description: "点击了可疑的饼干" @@ -1197,7 +1201,7 @@ _role: name: "角色名称" description: "角色描述" permission: "角色权限" - descriptionOfPermission: "监察员可以执行基本的审核操作。\n管理员可以更改实例的所有设置。" + descriptionOfPermission: "监察员可以执行基本地审核操作。\n管理员可以更改服务器的所有设置。" assignTarget: "授权对象" descriptionOfAssignTarget: "手动指手动选择谁被包括在这个角色中。\n符合条件指设置条件以自动包括符合条件的用户。" manual: "手动" @@ -1287,7 +1291,7 @@ _ad: _forgotPassword: enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。" - contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。" + contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" _gallery: my: "我的图库" liked: "喜欢的图片" @@ -1372,10 +1376,10 @@ _wordMute: hard: "硬屏蔽" mutedNotes: "被屏蔽的帖子" _instanceMute: - instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。" + instanceMuteDescription: "屏蔽配置服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" instanceMuteDescription2: "设置时用换行符来分隔" - title: "隐藏实例已设置的帖子。" - heading: "屏蔽实例" + title: "隐藏服务器已设置的帖子。" + heading: "屏蔽服务器" _theme: explore: "寻找主题" install: "安装主题" @@ -1583,7 +1587,7 @@ _weekday: saturday: "星期六" _widgets: profile: "个人资料" - instanceInfo: "实例信息" + instanceInfo: "服务器信息" memo: "便签" notifications: "通知" timeline: "时间线" @@ -1597,7 +1601,7 @@ _widgets: digitalClock: "数字时钟" unixClock: "UNIX时钟" federation: "联邦宇宙" - instanceCloud: "实例云" + instanceCloud: "服务器云" postForm: "投稿窗口" slideshow: "幻灯片展示" button: "按钮" @@ -1648,7 +1652,7 @@ _visibility: specified: "指定用户" specifiedDescription: "仅发送至指定用户" disableFederation: "不参与联合" - disableFederationDescription: "不发送到其他实例" + disableFederationDescription: "不发送到其他服务器" _postForm: replyPlaceholder: "回复这个帖子..." quotePlaceholder: "引用这个帖子..." diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 43ab334be..5c1512bd4 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "上傳時設定為\"public-read\"" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" +showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)" newNoteRecived: "發現新的貼文" sounds: "音效" sound: "音效" @@ -955,6 +956,8 @@ exploreOtherServers: "探索其他伺服器" letsLookAtTimeline: "看看時間軸" disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" +emailNotSupported: "這個伺服器不支援寄送郵件" +postToTheChannel: "發布到頻道" _achievements: earnedAt: "獲得日期" _types: diff --git a/package.json b/package.json index a2d04030f..d3e34e782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.9.1", + "version": "13.9.2", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/package.json b/packages/backend/package.json index 42efb881e..35e8dc5c6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -124,6 +124,7 @@ "seedrandom": "3.0.5", "semver": "7.3.8", "sharp": "0.31.3", + "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 852c1f32e..bd999c67d 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr'; import PrivateIp from 'private-ip'; import chalk from 'chalk'; import got, * as Got from 'got'; +import { parse } from 'content-disposition'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -32,13 +33,18 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise { + public async downloadUrl(url: string, path: string): Promise<{ + filename: string; + }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); const timeout = 30 * 1000; const operationTimeout = 60 * 1000; const maxSize = this.config.maxFileSize ?? 262144000; + const urlObj = new URL(url); + let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; + const req = got.stream(url, { headers: { 'User-Agent': this.config.userAgent, @@ -77,6 +83,14 @@ export class DownloadService { req.destroy(); } } + + const contentDisposition = res.headers['content-disposition']; + if (contentDisposition != null) { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } }).on('downloadProgress', (progress: Got.Progress) => { if (progress.transferred > maxSize) { this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); @@ -95,6 +109,10 @@ export class DownloadService { } this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + + return { + filename, + }; } @bindThis diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index b15c967c8..f4a06faeb 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type S3 from 'aws-sdk/clients/s3.js'; +import { correctFilename } from '@/misc/correct-filename.js'; type AddFileArgs = { /** User who wish to add file */ @@ -168,7 +169,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, name), + this.upload(key, fs.createReadStream(path), type, ext, name), ]; if (alts.webpublic) { @@ -176,7 +177,7 @@ export class DriveService { webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); } if (alts.thumbnail) { @@ -184,7 +185,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); } await Promise.all(uploads); @@ -360,7 +361,7 @@ export class DriveService { * Upload to ObjectStorage */ @bindThis - private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; @@ -374,7 +375,12 @@ export class DriveService { CacheControl: 'max-age=31536000, immutable', } as S3.PutObjectRequest; - if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (filename) params.ContentDisposition = contentDisposition( + 'inline', + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + ext ? correctFilename(filename, ext) : filename, + ); if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; const s3 = this.s3Service.getS3(meta); @@ -466,7 +472,12 @@ export class DriveService { //} // detect name - const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + const detectedName = correctFilename( + // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 + // extを付加してデータベースの文字数制限に当たることはまずない + (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', + info.type.ext + ); if (user && !force) { // Check if there is a file with the same hash @@ -736,24 +747,19 @@ export class DriveService { requestIp = null, requestHeaders = null, }: UploadFromUrlArgs): Promise { - let name = new URL(url).pathname.split('/').pop() ?? null; - if (name == null || !this.driveFileEntityService.validateFileName(name)) { - name = null; - } - - // If the comment is same as the name, skip comment - // (image.name is passed in when receiving attachment) - if (comment !== null && name === comment) { - comment = null; - } - // Create temp file const [path, cleanup] = await createTemp(); try { // write content at URL to temp file - await this.downloadService.downloadUrl(url, path); - + const { filename: name } = await this.downloadService.downloadUrl(url, path); + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); this.downloaderLogger.succ(`Got: ${driveFile.id}`); return driveFile!; diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 158fafa9d..f769ddd5e 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -21,6 +21,7 @@ type PackOptions = { }; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class DriveFileEntityService { @@ -255,10 +256,33 @@ export class DriveFileEntityService { @bindThis public async packMany( - files: (DriveFile['id'] | DriveFile)[], + files: DriveFile[], options?: PackOptions, ): Promise[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); } + + @bindThis + public async packManyByIdsMap( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise['id'], Packed<'DriveFile'> | null>> { + const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); + const packedFiles = await this.packMany(files, options); + const map = new Map['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); + for (const id of fileIds) { + if (!map.has(id)) map.set(id, null); + } + return map; + } + + @bindThis + public async packManyByIds( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise[]> { + const filesMap = await this.packManyByIdsMap(fileIds, options); + return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index ab29e7dba..fb147ae18 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -41,7 +41,8 @@ export class GalleryPostEntityService { title: post.title, description: post.description, fileIds: post.fileIds, - files: this.driveFileEntityService.packMany(post.fileIds), + // TODO: packMany causes N+1 queries + files: this.driveFileEntityService.packManyByIds(post.fileIds), tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2ffe5f1c2..4ec10df9a 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -248,6 +249,21 @@ export class NoteEntityService implements OnModuleInit { return true; } + @bindThis + public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map | null>): Promise[]> { + const missingIds = []; + for (const id of fileIds) { + if (!packedFiles.has(id)) missingIds.push(id); + } + if (missingIds.length) { + const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); + for (const [k, v] of additionalMap) { + packedFiles.set(k, v); + } + } + return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + } + @bindThis public async pack( src: Note['id'] | Note, @@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; _hint_?: { myReactions: Map; + packedFiles: Map | null>; }; }, ): Promise> { @@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit { emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, - files: this.driveFileEntityService.packMany(note.fileIds), + files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, channelId: note.channelId ?? undefined, @@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit { } await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく + const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); + const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, + packedFiles, }, }))); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 8c36e47f1..363564321 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -278,27 +278,27 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getAvatarUrl(user: User): Promise { if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); } else if (user.avatarId) { const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user); } else { - return this.getIdenticonUrl(user.id); + return this.getIdenticonUrl(user); } } @bindThis public getAvatarUrlSync(user: User): string { if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); } else { - return this.getIdenticonUrl(user.id); + return this.getIdenticonUrl(user); } } @bindThis - public getIdenticonUrl(userId: User['id']): string { - return `${this.config.url}/identicon/${userId}`; + public getIdenticonUrl(user: User): string { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } public async pack( diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts new file mode 100644 index 000000000..3357d8c1b --- /dev/null +++ b/packages/backend/src/misc/correct-filename.ts @@ -0,0 +1,15 @@ +// 与えられた拡張子とファイル名が一致しているかどうかを確認し、 +// 一致していない場合は拡張子を付与して返す +export function correctFilename(filename: string, ext: string | null) { + const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; + if (filename.endsWith(dotExt)) { + return filename; + } + if (ext === 'jpg' && filename.endsWith('.jpeg')) { + return filename; + } + if (ext === 'tif' && filename.endsWith('.tiff')) { + return filename; + } + return `${filename}${dotExt}`; +} diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts index acf5c1ede..0b6d147dc 100644 --- a/packages/backend/src/misc/is-mime-image.ts +++ b/packages/backend/src/misc/is-mime-image.ts @@ -4,6 +4,8 @@ const dictionary = { 'safe-file': FILE_TYPE_BROWSERSAFE, 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], }; export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index e5eefac1f..835657b62 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -22,6 +22,8 @@ import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import { isMimeImage } from '@/misc/is-mime-image.js'; import sharp from 'sharp'; +import { sharpBmp } from 'sharp-read-bmp'; +import { correctFilename } from '@/misc/correct-filename.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -51,15 +53,6 @@ export class FileServerService { //this.createServer = this.createServer.bind(this); } - @bindThis - public commonReadableHandlerGenerator(reply: FastifyReply) { - return (err: Error): void => { - this.logger.error(err); - reply.code(500); - reply.header('Cache-Control', 'max-age=300'); - }; - } - @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.addHook('onRequest', (request, reply, done) => { @@ -140,7 +133,7 @@ export class FileServerService { let image: IImageStreamable | null = null; if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image')) { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { reply.header('Cache-Control', 'max-age=31536000, immutable'); const url = new URL(`${this.config.mediaProxy}/static.webp`); @@ -190,13 +183,19 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } if (file.fileRole !== 'original') { - const filename = rename(file.file.name, { + const filename = rename(file.filename, { suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : undefined, + extname: file.ext ? `.${file.ext}` : '.unknown', }).toString(); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); @@ -204,12 +203,10 @@ export class FileServerService { reply.header('Content-Disposition', contentDisposition('inline', filename)); return fs.createReadStream(file.path); } else { - const stream = fs.createReadStream(file.path); - stream.on('error', this.commonReadableHandlerGenerator(reply)); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.file.name)); - return stream; + reply.header('Content-Disposition', contentDisposition('inline', file.filename)); + return fs.createReadStream(file.path); } } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -261,8 +258,8 @@ export class FileServerService { } try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); if ( 'emoji' in request.query || @@ -286,7 +283,7 @@ export class FileServerService { type: file.mime, }; } else { - const data = sharp(file.path, { animated: !('static' in request.query) }) + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) .resize({ height: 'emoji' in request.query ? 128 : 320, withoutEnlargement: true, @@ -300,11 +297,11 @@ export class FileServerService { }; } } else if ('static' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280); } else if ('preview' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); } else if ('badge' in request.query) { - const mask = sharp(file.path) + const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { fit: 'inside', withoutEnlargement: false, @@ -360,6 +357,12 @@ export class FileServerService { reply.header('Content-Type', image.type); reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -369,8 +372,8 @@ export class FileServerService { @bindThis private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -386,11 +389,11 @@ export class FileServerService { @bindThis private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; } + { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } > { const [path, cleanup] = await createTemp(); try { - await this.downloadService.downloadUrl(url, path); + const { filename } = await this.downloadService.downloadUrl(url, path); const { mime, ext } = await this.fileInfoService.detectType(path); @@ -398,6 +401,7 @@ export class FileServerService { state: 'remote', mime, ext, path, cleanup, + filename, }; } catch (e) { cleanup(); @@ -407,8 +411,8 @@ export class FileServerService { @bindThis private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -432,6 +436,7 @@ export class FileServerService { url: file.uri, fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', file, + filename: file.name, }; } @@ -443,6 +448,7 @@ export class FileServerService { state: 'stored_internal', fileRole: isThumbnail ? 'thumbnail' : 'webpublic', file, + filename: file.name, mime, ext, path, }; @@ -452,6 +458,7 @@ export class FileServerService { state: 'stored_internal', fileRole: 'original', file, + filename: file.name, mime: file.type, ext: null, path, diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index c6cb25e43..fd7f54da5 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -61,6 +61,13 @@ renderError('META_FETCH_V'); return; } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } + const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); if (localRes.status === 200) { localStorage.setItem('lang', lang); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index e864eab6c..42bdc5f24 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -410,11 +410,19 @@ describe('Endpoints', () => { }); test('ファイルに名前を付けられる', async () => { + const res = await uploadFile(alice, { name: 'Belmond.jpg' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Belmond.jpg'); + }); + + test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => { const res = await uploadFile(alice, { name: 'Belmond.png' }); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Belmond.png'); + assert.strictEqual(res.body.name, 'Belmond.png.jpg'); }); test('ファイル無しで怒られる', async () => { diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 98ee34d8d..1b5f9580d 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { Note } from '@/models/entities/Note.js'; -import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js'; +import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Note', () => { @@ -213,6 +213,122 @@ describe('Note', () => { assert.deepStrictEqual(noteDoc.mentions, [bob.id]); }); + describe('添付ファイル情報', () => { + test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const res = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.files.length, 1); + assert.strictEqual(res.body.createdNote.files[0].id, file.body.id); + }); + + test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const res = await api('/notes', { + withFiles: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.files.length, 1); + assert.strictEqual(myNote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const renoted = await api('/notes/create', { + renoteId: createdNote.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.files.length, 1); + assert.strictEqual(myNote.renote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const res = await api('/notes', { + reply: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.reply.files.length, 1); + assert.strictEqual(myNote.reply.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const renoted = await api('/notes/create', { + renoteId: reply.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.reply.files.length, 1); + assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id); + }); + }); + describe('notes/create', () => { test('投票を添付できる', async () => { const res = await api('/notes/create', { diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts new file mode 100644 index 000000000..c476aef33 --- /dev/null +++ b/packages/backend/test/unit/misc/others.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from '@jest/globals'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; + +describe('misc:content-disposition', () => { + test('inline', () => { + expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('attachment', () => { + expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('non ascii', () => { + expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D'); + }); +}); + +describe('misc:correct-filename', () => { + test('simple', () => { + expect(correctFilename('filename', 'jpg')).toBe('filename.jpg'); + }); + test('with same ext', () => { + expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg'); + }); + test('.ext', () => { + expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg'); + }); + test('with different ext', () => { + expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg'); + }); + test('non ascii with space', () => { + expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg'); + }); + test('jpeg', () => { + expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg'); + }); + test('tiff', () => { + expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff'); + }); + test('null ext', () => { + expect(correctFilename('filename', null)).toBe('filename.unknown'); + }); +}); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index a54a1c230..2748a9e49 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,41 +1,46 @@ @@ -117,12 +122,6 @@ onMounted(() => { .root { display: block; - - &.opened { - > .header { - border-radius: 6px 6px 0 0; - } - } } .header { @@ -132,6 +131,8 @@ onMounted(() => { box-sizing: border-box; padding: 9px 12px 9px 12px; background: var(--buttonBg); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); border-radius: 6px; transition: border-radius 0.3s; @@ -144,6 +145,10 @@ onMounted(() => { color: var(--accent); background: var(--buttonHoverBg); } + + &.opened { + border-radius: 6px 6px 0 0; + } } .headerUpper { @@ -153,7 +158,7 @@ onMounted(() => { .headerLower { color: var(--fgTransparentWeak); - font-size: .85em; + font-size: .85em; padding-left: 4px; } @@ -202,7 +207,6 @@ onMounted(() => { background: var(--panel); border-radius: 0 0 6px 6px; container-type: inline-size; - overflow: auto; &.bgSame { background: var(--bg); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 378d0ac02..a1a61a6fd 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -21,14 +21,14 @@
- + {{ i18n.ts.loadMore }}
- + {{ i18n.ts.loadMore }} diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 2b3e2c864..09f672be7 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -658,7 +658,14 @@ async function post(ev?: MouseEvent) { if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) { claimAchievement('iLoveMisskey'); } - if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) { + if ( + text.includes('https://youtu.be/Efrlqw8ytg4'.toLowerCase()) || + text.includes('https://www.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) || + text.includes('https://m.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) || + text.includes('https://youtu.be/XVCwzwxdHuA'.toLowerCase()) || + text.includes('https://www.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase()) || + text.includes('https://m.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase()) + ) { claimAchievement('brainDiver'); } diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue index d8703a0b1..62ada6b73 100644 --- a/packages/frontend/src/components/MkSignup.vue +++ b/packages/frontend/src/components/MkSignup.vue @@ -9,6 +9,7 @@