1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-27 06:18:40 +09:00

Merge tag '2024.5.0-io.4' into host

This commit is contained in:
あわわわとーにゅ 2024-11-06 11:18:33 +09:00
commit 97d186b111
No known key found for this signature in database
GPG Key ID: 6AFBBF529601C1DB
100 changed files with 4053 additions and 3312 deletions

View File

@ -195,4 +195,4 @@ signToActivityPubGet: true
#maxFileSize: 262144000
# Value of Content-Security-Policy header
#contentSecurityPolicy: "script-src 'self' 'unsafe-eval' https://challenges.cloudflare.com https://hcaptcha.com https://*.hcaptcha.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/; base-uri 'self'; object-src 'self';"
#contentSecurityPolicy: "script-src 'self' 'unsafe-eval' https://challenges.cloudflare.com https://hcaptcha.com https://*.hcaptcha.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ https://www.googletagmanager.com/; base-uri 'self'; object-src 'self';"

View File

@ -118,7 +118,7 @@ redis:
# ┌───────────────────────────┐
#───┘ MeiliSearch configuration └─────────────────────────────
# You can set scope to local (default value) or global
# You can set scope to local (default value) or global
# (include notes from remote).
#meilisearch:
@ -231,7 +231,7 @@ proxyRemoteFiles: true
signToActivityPubGet: true
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [
# '127.0.0.1/32'
@ -244,4 +244,4 @@ signToActivityPubGet: true
#pidFile: /tmp/misskey.pid
# Value of Content-Security-Policy header
#contentSecurityPolicy: "script-src 'self' 'unsafe-eval' https://challenges.cloudflare.com https://hcaptcha.com https://*.hcaptcha.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/; base-uri 'self'; object-src 'self';"
#contentSecurityPolicy: "script-src 'self' 'unsafe-eval' https://challenges.cloudflare.com https://hcaptcha.com https://*.hcaptcha.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ https://www.googletagmanager.com/; base-uri 'self'; object-src 'self';"

View File

@ -20,7 +20,7 @@ jobs:
- run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -31,7 +31,7 @@ jobs:
- uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v4.0.4
- uses: actions/setup-node@v4.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -57,7 +57,7 @@ jobs:
- uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v4.0.4
- uses: actions/setup-node@v4.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -82,7 +82,7 @@ jobs:
- uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v4.0.4
- uses: actions/setup-node@v4.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -59,7 +59,7 @@ jobs:
- name: Install FFmpeg
uses: FedericoCarboni/setup-ffmpeg@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
@ -119,7 +119,7 @@ jobs:
with:
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@ -39,7 +39,7 @@ jobs:
with:
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@ -32,7 +32,7 @@ jobs:
- run: corepack enable
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@ -29,7 +29,7 @@ jobs:
with:
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@ -30,7 +30,7 @@ jobs:
with:
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.1.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@ -87,6 +87,8 @@
- Fix: `/i/notifications``includeTypes`か`excludeTypes`を指定しているとき、通知が存在するのに空配列を返すことがある問題を修正
- Fix: 複数idを指定する`users/show`が関係ないユーザを返すことがある問題を修正
- Fix: `/tags``/user-tags` が検索エンジンにインデックスされないように
- Fix: もともとセンシティブではないと連合されていたファイルがセンシティブとして連合された場合にセンシティブとしてそのファイルを扱うように
- センシティブとして連合したファイルは非センシティブとして連合されてもセンシティブとして扱われます
## 2024.3.1

View File

@ -1269,10 +1269,51 @@ keepOriginalFilenameDescription: "If you turn off this setting, files names will
noDescription: "There is not the explanation"
alwaysConfirmFollow: "Always confirm when following"
inquiry: "Contact"
tryAgain: "Please try again later"
confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media"
sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?"
createdLists: "Created lists"
createdAntennas: "Created antennas"
fromX: "From {x}"
genEmbedCode: "Generate embed code"
noteOfThisUser: "Notes by this user"
clipNoteLimitExceeded: "No more notes can be added to this clip."
performance: "Performance"
modified: "Modified"
discard: "Discard"
thereAreNChanges: "There are {n} change(s)"
signinWithPasskey: "Sign in with Passkey"
unknownWebAuthnKey: "Unknown Passkey"
passkeyVerificationFailed: "Passkey verification has failed."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled."
messageToFollower: "Message to followers"
target: "Target"
testCaptchaWarning: "This function is intended for CAPTCHA testing purposes.\n<strong>Do not use in a production environment.</strong>"
prohibitedWordsForNameOfUser: "Prohibited words for user names"
prohibitedWordsForNameOfUserDescription: "If any of the strings in this list are included in the user's name, the name will be denied. Users with moderator privileges are not affected by this restriction."
yourNameContainsProhibitedWords: "Your name contains prohibited words"
yourNameContainsProhibitedWordsDescription: "If you wish to use this name, please contact your server administrator."
thisContentsAreMarkedAsSigninRequiredByAuthor: "Set by the author to require login to view"
lockdown: "Lockdown"
pleaseSelectAccount: "Select an account"
here: "here"
mutualLink: "Mutual Link"
saveThisFile: "Save this file to Drive"
changeUserName: "Change name"
gtagConsentCustomize: "Data Collection and Privacy Settings"
gtagConsentCustomizeDescription: "You can customize the scope of data collected by {host}.\nHowever, you cannot disable the collection of security-related information such as authentication features, fraud prevention, and other user protections."
gtagConsentAnalytics: "Collection of Statistical Information"
gtagConsentAnalyticsDescription: "Enable the storage (cookies, etc.) of analytics-related information such as site visit duration."
gtagConsentFunctionality: "Collection of Feature and Setting Usage"
gtagConsentFunctionalityDescription: "Enable the storage of information that supports website or app features, such as language settings."
gtagConsentPersonalization: "Collection of Personalized Information"
gtagConsentPersonalizationDescription: "Enable the storage of personalization-related information such as recommended posts."
helpUsImproveUserExperience: "To build the future of Misskey,\nplease help us by agreeing to data collection!"
pleaseConsentToTracking: "{host} may collect information that may include personal data such as your IP address, usage data, and device information during your use, based on our [Privacy Policy]({privacyPolicyUrl}), for the purpose of providing and operating the service and improving the user experience.\n\nThe collected data will be used for future feature development, operational policy decisions, and identifying areas for service improvement."
consentEssential: "Allow Essential Items"
consentAll: "Allow All Items"
consentSelected: "Allow Selected Items"
_bubbleGame:
howToPlay: "How to play"
hold: "Hold"
@ -2170,8 +2211,11 @@ _auth:
permissionAsk: "This application requests the following permissions"
pleaseGoBack: "Please go back to the application"
callback: "Returning to the application"
accepted: "Access granted"
denied: "Access denied"
scopeUser: "Operate as the following user"
pleaseLogin: "Please log in to authorize applications."
byClickingYouWillBeRedirectedToThisUrl: "When access is granted, you will automatically be redirected to the following URL"
_antennaSources:
all: "All notes"
homeTimeline: "Notes from followed users"

202
locales/index.d.ts vendored
View File

@ -5114,6 +5114,118 @@ export interface Locale extends ILocale {
*
*/
"inquiry": string;
/**
*
*/
"tryAgain": string;
/**
*
*/
"confirmWhenRevealingSensitiveMedia": string;
/**
*
*/
"sensitiveMediaRevealConfirm": string;
/**
*
*/
"createdLists": string;
/**
*
*/
"createdAntennas": string;
/**
* {x}
*/
"fromX": ParameterizedString<"x">;
/**
*
*/
"genEmbedCode": string;
/**
*
*/
"noteOfThisUser": string;
/**
*
*/
"clipNoteLimitExceeded": string;
/**
*
*/
"performance": string;
/**
*
*/
"modified": string;
/**
*
*/
"discard": string;
/**
* {n}
*/
"thereAreNChanges": ParameterizedString<"n">;
/**
*
*/
"signinWithPasskey": string;
/**
*
*/
"unknownWebAuthnKey": string;
/**
*
*/
"passkeyVerificationFailed": string;
/**
*
*/
"passkeyVerificationSucceededButPasswordlessLoginDisabled": string;
/**
*
*/
"messageToFollower": string;
/**
*
*/
"target": string;
/**
* CAPTCHAのテストを目的とした機能です<strong>使</strong>
*/
"testCaptchaWarning": string;
/**
*
*/
"prohibitedWordsForNameOfUser": string;
/**
*
*/
"prohibitedWordsForNameOfUserDescription": string;
/**
*
*/
"yourNameContainsProhibitedWords": string;
/**
* 使
*/
"yourNameContainsProhibitedWordsDescription": string;
/**
* 稿
*/
"thisContentsAreMarkedAsSigninRequiredByAuthor": string;
/**
*
*/
"lockdown": string;
/**
*
*/
"pleaseSelectAccount": string;
/**
*
*/
"availableRoles": string;
/**
*
*/
@ -5150,6 +5262,62 @@ export interface Locale extends ILocale {
*
*/
"changeUserName": string;
/**
*
*/
"gtagConsentCustomize": string;
/**
* {host}
*
*/
"gtagConsentCustomizeDescription": ParameterizedString<"host">;
/**
*
*/
"gtagConsentAnalytics": string;
/**
* Cookie
*/
"gtagConsentAnalyticsDescription": string;
/**
*
*/
"gtagConsentFunctionality": string;
/**
*
*/
"gtagConsentFunctionalityDescription": string;
/**
*
*/
"gtagConsentPersonalization": string;
/**
* 稿
*/
"gtagConsentPersonalizationDescription": string;
/**
* Misskeyの明日を作るために
*
*/
"helpUsImproveUserExperience": string;
/**
* {host}[]({privacyPolicyUrl})IPアドレス
*
*
*/
"pleaseConsentToTracking": ParameterizedString<"host" | "privacyPolicyUrl">;
/**
*
*/
"consentEssential": string;
/**
*
*/
"consentAll": string;
/**
*
*/
"consentSelected": string;
"_bubbleGame": {
/**
*
@ -8469,14 +8637,26 @@ export interface Locale extends ILocale {
*
*/
"callback": string;
/**
*
*/
"accepted": string;
/**
*
*/
"denied": string;
/**
*
*/
"scopeUser": string;
/**
*
*/
"pleaseLogin": string;
/**
* URLに遷移します
*/
"byClickingYouWillBeRedirectedToThisUrl": string;
};
"_antennaSources": {
/**
@ -10500,6 +10680,28 @@ export interface Locale extends ILocale {
*/
"nRequests": ParameterizedString<"n">;
};
"_selfXssPrevention": {
/**
*
*/
"warning": string;
/**
*
*/
"title": string;
/**
*
*/
"description1": string;
/**
* %c今すぐ作業を中止してこのウィンドウを閉じてください
*/
"description2": string;
/**
* {link}
*/
"description3": ParameterizedString<"link">;
};
}
declare const locales: {
[lang: string]: Locale;

View File

@ -1273,6 +1273,34 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ
noDescription: "説明文はありません"
alwaysConfirmFollow: "フォローの際常に確認する"
inquiry: "お問い合わせ"
tryAgain: "もう一度お試しください。"
confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する"
sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
createdLists: "作成したリスト"
createdAntennas: "作成したアンテナ"
fromX: "{x}から"
genEmbedCode: "埋め込みコードを生成"
noteOfThisUser: "このユーザーのノート一覧"
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
performance: "パフォーマンス"
modified: "変更あり"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
signinWithPasskey: "パスキーでログイン"
unknownWebAuthnKey: "登録されていないパスキーです。"
passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
messageToFollower: "フォロワーへのメッセージ"
target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています"
lockdown: "ロックダウン"
pleaseSelectAccount: "アカウントを選択してください"
availableRoles: "利用可能なロール"
abuseReportCategory: "通報の種類"
selectCategory: "カテゴリを選択"
reportComplete: "通報完了"
@ -1282,6 +1310,19 @@ here: "こちら"
mutualLink: "相互リンク"
saveThisFile: "このファイルをドライブに保存する"
changeUserName: "名前を変更"
gtagConsentCustomize: "データ収集とプライバシー設定"
gtagConsentCustomizeDescription: "{host}が収集するデータの範囲をカスタマイズできます。\nただし、認証機能、不正行為防止、その他のユーザー保護など、セキュリティに関連する情報の収集は無効化できません。"
gtagConsentAnalytics: "統計情報の収集"
gtagConsentAnalyticsDescription: "サイトの滞在時間など、分析に関連する情報の保存Cookie など)を有効にします。"
gtagConsentFunctionality: "機能・設定の利用状況の収集"
gtagConsentFunctionalityDescription: "言語設定など、ウェブサイトやアプリの機能をサポートする情報の保存を有効にします。"
gtagConsentPersonalization: "パーソナライズされた情報の収集"
gtagConsentPersonalizationDescription: "おすすめの投稿など、パーソナライズに関連する情報の保存を有効にします。"
helpUsImproveUserExperience: "Misskeyの明日を作るために、\nデータ収集にご協力ください"
pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPolicyUrl})に基づき、サービスの提供・運営・ユーザー体験の向上のためにご利用中のIPアドレス、利用状況、デバイス情報等、個人情報を含む可能性のある情報を収集することがあります。\n\n収集されたデータは今後の機能の開発、運営の方針の決定、サービスの改善点の特定に利用されます。"
consentEssential: "必須項目のみ許可"
consentAll: "全て許可"
consentSelected: "選択した項目のみ許可"
_bubbleGame:
howToPlay: "遊び方"
@ -2217,8 +2258,11 @@ _auth:
permissionAsk: "このアプリは次の権限を要求しています"
pleaseGoBack: "アプリケーションに戻ってやっていってください"
callback: "アプリケーションに戻っています"
accepted: "アクセスを許可しました"
denied: "アクセスを拒否しました"
scopeUser: "以下のユーザーとして操作しています"
pleaseLogin: "アプリケーションにアクセス許可を与えるには、ログインが必要です。"
byClickingYouWillBeRedirectedToThisUrl: "アクセスを許可すると、自動で以下のURLに遷移します"
_antennaSources:
all: "全てのノート"
@ -2790,3 +2834,10 @@ _skebStatus:
yenX: "{x}円"
nWorks: "納品実績 {n}件"
nRequests: "取引実績 {n}件"
_selfXssPrevention:
warning: "警告"
title: "「この画面に何か貼り付けろ」はすべて詐欺です。"
description1: "ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。"
description2: "貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。"
description3: "詳しくはこちらをご確認ください。 {link}"

View File

@ -1262,10 +1262,55 @@ useTotp: "일회용 비밀번호 사용"
useBackupCode: "백업 코드 사용"
launchApp: "앱 실행"
useNativeUIForVideoAudioPlayer: "브라우저 UI에서 미디어 재생"
keepOriginalFilename: "원본 파일 이름을 유지"
keepOriginalFilenameDescription: "이 설정을 끄면 업로드를 할 때 파일 이름이 자동으로 무작위 문자열로 바뀝니다."
noDescription: "설명문이 없습니다"
alwaysConfirmFollow: "팔로우일 때 항상 확인하기"
inquiry: "문의하기"
tryAgain: "다시 시도해 주세요."
confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인"
sensitiveMediaRevealConfirm: "민감한 미디어입니다. 표시할까요?"
createdLists: "만든 리스트"
createdAntennas: "만든 안테나"
fromX: "{x}부터"
genEmbedCode: "임베디드 코드 만들기"
noteOfThisUser: "이 유저의 노트 목록"
clipNoteLimitExceeded: "더 이상 이 클립에 노트를 추가 할 수 없습니다."
performance: "퍼포먼스"
modified: "변경 있음"
discard: "파기"
thereAreNChanges: "{n}건 변경이 있습니다."
signinWithPasskey: "패스키로 로그인"
unknownWebAuthnKey: "등록되지 않은 패스키입니다."
passkeyVerificationFailed: "패스키 검증을 실패했습니다."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다."
messageToFollower: "팔로워에 보낼 메시지"
target: "대상"
testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. <strong>실제 환경에서는 사용하지 마세요.</strong>"
prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)"
prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다."
yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
lockdown: "잠금"
pleaseSelectAccount: "계정을 선택해 주세요"
here: "여기"
mutualLink: "서로링크"
saveThisFile: "이 파일을 드라이브에 저장"
changeUserName: "이름 변경"
gtagConsentCustomize: "데이터 수집 및 개인정보 설정"
gtagConsentCustomizeDescription: "{host}에서 수집하는 데이터 범위를 사용자 지정할 수 있습니다.\n다만, 인증 기능, 부정 행위 방지, 기타 사용자 보호 등 보안과 관련된 정보 수집은 비활성화할 수 없습니다."
gtagConsentAnalytics: "통계 정보 수집"
gtagConsentAnalyticsDescription: "사이트 체류 시간 등 분석 관련 정보 저장(쿠키 등)을 활성화합니다."
gtagConsentFunctionality: "기능 및 설정 사용 정보 수집"
gtagConsentFunctionalityDescription: "언어 설정 등 웹사이트나 앱의 기능을 지원하는 정보 저장을 활성화합니다."
gtagConsentPersonalization: "개인 맞춤형 정보 수집"
gtagConsentPersonalizationDescription: "추천 게시물 등 개인화 관련 정보 저장을 활성화합니다."
helpUsImproveUserExperience: "Misskey의 미래를 위해,\n데이터 수집에 협조해 주세요!"
pleaseConsentToTracking: "{host}는 [개인정보 처리방침]({privacyPolicyUrl})에 따라 서비스 제공, 운영, 사용자 경험 향상을 위해 사용 중인 IP 주소, 이용 현황, 디바이스 정보 등 개인 정보를 포함할 수 있는 정보를 수집할 수 있습니다.\n\n수집된 데이터는 향후 기능 개발, 운영 방침 결정, 서비스 개선점 파악에 활용됩니다."
consentEssential: "필수 항목만 허용"
consentAll: "모두 허용"
consentSelected: "선택한 항목만 허용"
_bubbleGame:
howToPlay: "설명"
hold: "홀드"
@ -2152,8 +2197,11 @@ _auth:
permissionAsk: "이 앱은 다음의 권한을 요청합니다"
pleaseGoBack: "앱으로 돌아가서 시도해 주세요"
callback: "앱으로 돌아갑니다"
accepted: "접근이 허가되었습니다"
denied: "접근이 거부되었습니다"
scopeUser: "다음 사용자로서 작업 중"
pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오."
byClickingYouWillBeRedirectedToThisUrl: "접근을 허가하면 자동으로 다음 URL로 이동합니다"
_antennaSources:
all: "모든 노트"
homeTimeline: "팔로우중인 유저의 노트"

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.5.0-host.3d",
"version": "2024.5.0-host.4",
"codename": "nasubi",
"repository": {
"type": "git",
@ -49,7 +49,7 @@
"@tensorflow/tfjs-core": "4.22.0",
"chokidar": "4.0.1",
"cookie": "1.0.1",
"cookie-signature": "1.2.1",
"cookie-signature": "1.2.2",
"debug": "4.3.7",
"esbuild": "0.24.0",
"lodash": "4.17.21",
@ -58,18 +58,18 @@
},
"dependencies": {
"cssnano": "7.0.6",
"execa": "9.4.1",
"execa": "9.5.1",
"js-yaml": "4.1.0",
"postcss": "8.4.47",
"terser": "5.36.0",
"typescript": "5.6.3"
},
"devDependencies": {
"@types/node": "22.7.8",
"@types/node": "22.9.0",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"cross-env": "7.0.3",
"cypress": "13.15.0",
"cypress": "13.15.1",
"eslint": "8.57.1",
"ncp": "2.0.0",
"start-server-and-test": "2.0.8"

View File

@ -0,0 +1,11 @@
export class GoogleAnalyticsId1730629332694 {
name = 'GoogleAnalyticsId1730629332694'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "googleAnalyticsId" character varying(32)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "googleAnalyticsId"`);
}
}

View File

@ -34,16 +34,16 @@
"generate-api-json": "pnpm build && node ./scripts/generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.7.39",
"@swc/core-darwin-x64": "1.7.39",
"@swc/core-linux-arm-gnueabihf": "1.7.39",
"@swc/core-linux-arm64-gnu": "1.7.39",
"@swc/core-linux-arm64-musl": "1.7.39",
"@swc/core-linux-x64-gnu": "1.7.39",
"@swc/core-linux-x64-musl": "1.7.39",
"@swc/core-win32-arm64-msvc": "1.7.39",
"@swc/core-win32-ia32-msvc": "1.7.39",
"@swc/core-win32-x64-msvc": "1.7.39",
"@swc/core-darwin-arm64": "1.8.0",
"@swc/core-darwin-x64": "1.8.0",
"@swc/core-linux-arm-gnueabihf": "1.8.0",
"@swc/core-linux-arm64-gnu": "1.8.0",
"@swc/core-linux-arm64-musl": "1.8.0",
"@swc/core-linux-x64-gnu": "1.8.0",
"@swc/core-linux-x64-musl": "1.8.0",
"@swc/core-win32-arm64-msvc": "1.8.0",
"@swc/core-win32-ia32-msvc": "1.8.0",
"@swc/core-win32-x64-msvc": "1.8.0",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.8",
@ -60,15 +60,15 @@
"slacc-linux-x64-musl": "0.0.10",
"slacc-win32-arm64-msvc": "0.0.10",
"slacc-win32-x64-msvc": "0.0.10",
"utf-8-validate": "6.0.4"
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@authenio/samlify-node-xmllint": "2.0.0",
"@aws-sdk/client-s3": "3.676.0",
"@aws-sdk/lib-storage": "3.676.0",
"@bull-board/api": "6.2.4",
"@bull-board/fastify": "6.2.4",
"@bull-board/ui": "6.2.4",
"@aws-sdk/client-s3": "3.685.0",
"@aws-sdk/lib-storage": "3.685.0",
"@bull-board/api": "6.3.3",
"@bull-board/fastify": "6.3.3",
"@bull-board/ui": "6.3.3",
"@discordapp/twemoji": "15.1.0",
"@elastic/elasticsearch": "8.15.1",
"@fastify/accepts": "5.0.1",
@ -82,16 +82,16 @@
"@fastify/view": "10.0.1",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.1.0",
"@napi-rs/canvas": "0.1.58",
"@nestjs/common": "10.4.5",
"@nestjs/core": "10.4.5",
"@nestjs/testing": "10.4.5",
"@napi-rs/canvas": "0.1.59",
"@nestjs/common": "10.4.7",
"@nestjs/core": "10.4.7",
"@nestjs/testing": "10.4.7",
"@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "11.0.0",
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "3.2.5",
"@swc/cli": "0.4.0",
"@swc/core": "1.7.39",
"@swc/cli": "0.5.0",
"@swc/core": "1.8.0",
"@twemoji/parser": "15.1.1",
"accepts": "1.3.8",
"ajv": "8.17.1",
@ -100,9 +100,9 @@
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.3",
"bullmq": "5.21.2",
"bullmq": "5.24.0",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.2",
"cbor": "10.0.2",
"chalk": "5.3.0",
"chalk-template": "1.1.0",
"chokidar": "4.0.1",
@ -118,8 +118,8 @@
"file-type": "19.6.0",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.1",
"got": "14.4.3",
"happy-dom": "15.7.4",
"got": "14.4.4",
"happy-dom": "15.9.0",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",
@ -133,26 +133,26 @@
"json5": "2.2.3",
"jsonld": "8.3.2",
"jsrsasign": "11.1.0",
"meilisearch": "0.44.1",
"meilisearch": "0.45.0",
"mfm-js": "0.24.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.1",
"nanoid": "5.0.7",
"nanoid": "5.0.8",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-forge": "1.3.1",
"nodemailer": "6.9.15",
"nodemailer": "6.9.16",
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.3.4",
"parse5": "7.2.0",
"pg": "8.13.0",
"parse5": "7.2.1",
"pg": "8.13.1",
"pino": "9.5.0",
"pino-pretty": "11.3.0",
"pkce-challenge": "4.1.0",
@ -193,26 +193,26 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@misskey-dev/eslint-plugin": "1.0.0",
"@nestjs/platform-express": "10.4.5",
"@nestjs/platform-express": "10.4.7",
"@simplewebauthn/types": "11.0.0",
"@swc/jest": "0.2.36",
"@swc/jest": "0.2.37",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.2",
"@types/archiver": "6.0.3",
"@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5",
"@types/color-convert": "2.0.4",
"@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.26",
"@types/fluent-ffmpeg": "2.1.27",
"@types/htmlescape": "^1.1.3",
"@types/http-link-header": "1.0.7",
"@types/jest": "29.5.13",
"@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.14",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "22.7.8",
"@types/node": "22.9.0",
"@types/node-forge": "1.3.11",
"@types/nodemailer": "6.4.16",
"@types/oauth": "0.9.6",
@ -233,14 +233,14 @@
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.5.12",
"@types/ws": "8.5.13",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3",
"eslint": "8.57.1",
"eslint-plugin-import": "2.31.0",
"execa": "9.4.1",
"execa": "9.5.1",
"fkill": "^9.0.0",
"jest": "29.7.0",
"jest-mock": "29.7.0",

View File

@ -4,10 +4,9 @@
*/
import { LoggerService } from '@nestjs/common';
import Logger from '@/logger.js';
import { coreLogger } from '@/logger.js';
const logger = new Logger('core', 'cyan');
const nestLogger = logger.createSubLogger('nest', 'green', false);
const nestLogger = coreLogger.createSubLogger('nest', 'green', false);
export class NestLogger implements LoggerService {
/**

View File

@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
import process from 'node:process';
import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/logger.js';
import { coreLogger } from '@/logger.js';
import { envOption } from '../env.js';
import { masterMain } from './master.js';
import { workerMain } from './worker.js';
@ -24,8 +24,7 @@ process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
Error.stackTraceLimit = Infinity;
EventEmitter.defaultMaxListeners = 128;
const logger = new Logger('core', 'cyan');
const clusterLogger = logger.createSubLogger('cluster', 'orange', false);
const clusterLogger = coreLogger.createSubLogger('cluster', 'orange', false);
const ev = new Xev();
//#region Events
@ -53,12 +52,12 @@ if (cluster.isPrimary && !envOption.disableClustering) {
});
process.on('SIGINT', () => {
logger.warn(chalk.yellow('Process received SIGINT'));
coreLogger.warn(chalk.yellow('Process received SIGINT'));
isShuttingDown = true;
});
process.on('SIGTERM', () => {
logger.warn(chalk.yellow('Process received SIGTERM'));
coreLogger.warn(chalk.yellow('Process received SIGTERM'));
isShuttingDown = true;
});
}
@ -71,18 +70,18 @@ if (!envOption.quiet) {
// Display detail of uncaught exception
process.on('uncaughtException', err => {
try {
logger.error(`Uncaught exception: ${err.message}`, { error: err });
coreLogger.error(`Uncaught exception: ${err.message}`, { error: err });
} catch { }
});
// Dying away...
process.on('exit', code => {
logger.warn(chalk.yellow(`The process is going to exit with code ${code}`));
coreLogger.warn(chalk.yellow(`The process is going to exit with code ${code}`));
});
process.on('warning', warning => {
if ((warning as never)['code'] !== 'MISSKEY_SHUTDOWN') return;
logger.warn(chalk.yellow(`${warning.message}: ${(warning as never)['detail']}`));
coreLogger.warn(chalk.yellow(`${warning.message}: ${(warning as never)['detail']}`));
for (const id in cluster.workers) cluster.workers[id]?.process.kill('SIGTERM');
process.exit();
});

View File

@ -10,7 +10,7 @@ import * as os from 'node:os';
import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import Logger from '@/logger.js';
import { coreLogger } from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
@ -22,8 +22,7 @@ const _dirname = dirname(_filename);
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta', false);
const bootLogger = coreLogger.createSubLogger('boot', 'magenta', false);
const themeColor = chalk.hex('#86b300');

View File

@ -43,6 +43,7 @@ import { RoleService } from '@/core/RoleService.js';
import { correctFilename } from '@/misc/correct-filename.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { LoggerService } from '@/core/LoggerService.js';
type AddFileArgs = {
/** User who wish to add file */
@ -123,12 +124,13 @@ export class DriveService {
private globalEventService: GlobalEventService,
private queueService: QueueService,
private roleService: RoleService,
private loggerService: LoggerService,
private moderationLogService: ModerationLogService,
private driveChart: DriveChart,
private perUserDriveChart: PerUserDriveChart,
private instanceChart: InstanceChart,
) {
const logger = new Logger('drive', 'blue');
const logger = this.loggerService.getLogger('drive', 'blue');
this.registerLogger = logger.createSubLogger('register', 'yellow');
this.downloaderLogger = logger.createSubLogger('downloader');
this.deleteLogger = logger.createSubLogger('delete');
@ -499,6 +501,12 @@ export class DriveService {
if (much) {
this.registerLogger.info(`file with same hash is found: ${much.id}`);
if (sensitive && !much.isSensitive) {
// The file is federated as sensitive for this time, but was federated as non-sensitive before.
// Therefore, update the file to sensitive.
await this.driveFilesRepository.update({ id: much.id }, { isSensitive: true });
much.isSensitive = true;
}
return much;
}
}

View File

@ -159,7 +159,7 @@ export class FetchInstanceMetadataService {
throw err.statusCode ?? err.message;
});
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
this.logger.succ(`Successfully fetched nodeinfo of ${instance.host}`);
return info as NodeInfo;
} catch (err) {

View File

@ -4,7 +4,7 @@
*/
import { Injectable } from '@nestjs/common';
import Logger from '@/logger.js';
import { rootLogger } from '@/logger.js';
import { bindThis } from '@/decorators.js';
import type { KEYWORD } from 'color-convert/conversions.js';
@ -16,6 +16,6 @@ export class LoggerService {
@bindThis
public getLogger(domain: string, color?: KEYWORD | undefined, store?: boolean) {
return new Logger(domain);
return rootLogger.createSubLogger(domain, color, store);
}
}

View File

@ -410,7 +410,7 @@ export class MfmService {
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
a.className = 'u-url mention';
a.textContent = acct;

View File

@ -257,7 +257,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const policies = await this.roleService.getUserPolicies(user.id);
if (!policies.canCreateContent) {
this.logger.error('Request rejected because user has no permission to create content', { user: user.id, note: data });
this.logger.error('Request rejected because user has no permission to create content', { userId: user.id, note: data });
throw new IdentifiableError('5b1c2b67-50a6-4a8a-a59c-0ede40890de3', 'User has no permission to create content.');
}
@ -265,7 +265,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const sensitiveWords = meta.sensitiveWords;
if (this.utilityService.isKeyWordIncluded(data.cw ?? this.utilityService.concatNoteContentsForKeyWordCheck({ text: data.text, pollChoices: data.poll?.choices }), sensitiveWords)) {
data.visibility = 'home';
this.logger.warn('Visibility changed to home because sensitive words are included', { user: user.id, note: data });
this.logger.warn('Visibility changed to home because sensitive words are included', { userId: user.id, note: data });
} else if (!policies.canPublicNote) {
data.visibility = 'home';
}
@ -281,7 +281,7 @@ export class NoteCreateService implements OnApplicationShutdown {
);
if (hasProhibitedWords) {
this.logger.error('Request rejected because prohibited words are included', { user: user.id, note: data });
this.logger.error('Request rejected because prohibited words are included', { userId: user.id, note: data });
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Notes including prohibited words are not allowed.');
}
@ -384,7 +384,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (process.env.MISSKEY_BLOCK_MENTIONS_FROM_UNFAMILIAR_REMOTE_USERS === 'true' && user.host !== null && willCauseNotification) {
const userEntity = await this.usersRepository.findOneBy({ id: user.id });
if ((userEntity?.followersCount ?? 0) === 0) {
this.logger.error('Request rejected because user has no local followers', { user: user.id, note: data });
this.logger.error('Request rejected because user has no local followers', { userId: user.id, note: data });
throw new IdentifiableError('e11b3a16-f543-4885-8eb1-66cad131dbfd', 'Notes including mentions, replies, or renotes from remote users are not allowed until user has at least one local follower.');
}
}
@ -396,7 +396,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|| (data.visibility === 'specified' && data.visibleUsers?.some(u => u.id !== user.id))
|| (this.isQuote(data) && data.renote.userId !== user.id)
) {
this.logger.error('Request rejected because user has no permission to initiate conversation', { user: user.id, note: data });
this.logger.error('Request rejected because user has no permission to initiate conversation', { userId: user.id, note: data });
throw new IdentifiableError('332dd91b-6a00-430a-ac39-620cf60ad34b', 'Notes including mentions, replies, or renotes are not allowed.');
}
}

View File

@ -50,7 +50,7 @@ export class UserBlockingService implements OnModuleInit {
private apRendererService: ApRendererService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('user-block');
this.logger = this.loggerService.getLogger('user:block');
}
onModuleInit() {

View File

@ -30,9 +30,8 @@ import { AccountMoveService } from '@/core/AccountMoveService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { ThinUser } from '@/queue/types.js';
import Logger from '../logger.js';
const logger = new Logger('following/create');
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
type Local = MiLocalUser | {
id: MiLocalUser['id'];
@ -50,6 +49,7 @@ type Both = Local | Remote;
@Injectable()
export class UserFollowingService implements OnModuleInit {
private userBlockingService: UserBlockingService;
private readonly logger: Logger;
constructor(
private moduleRef: ModuleRef,
@ -73,6 +73,7 @@ export class UserFollowingService implements OnModuleInit {
private instancesRepository: InstancesRepository,
private cacheService: CacheService,
private loggerService: LoggerService,
private utilityService: UtilityService,
private userEntityService: UserEntityService,
private idService: IdService,
@ -88,6 +89,7 @@ export class UserFollowingService implements OnModuleInit {
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
) {
this.logger = this.loggerService.getLogger('user:following');
}
onModuleInit() {
@ -255,7 +257,7 @@ export class UserFollowingService implements OnModuleInit {
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
}).catch(err => {
if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
this.logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
alreadyFollowed = true;
} else {
throw err;
@ -378,7 +380,7 @@ export class UserFollowingService implements OnModuleInit {
});
if (following === null || !following.follower || !following.followee) {
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
return;
}

View File

@ -145,8 +145,7 @@ export class ApRequestService {
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
this.logger = this.loggerService.getLogger('ap:request');
}
@bindThis

View File

@ -45,7 +45,7 @@ export class Resolver {
private recursionLimit = 100,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
this.logger = this.loggerService.getLogger('ap:resolve');
}
@bindThis

View File

@ -77,6 +77,7 @@ export class MetaEntityService {
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
googleAnalyticsId: instance.googleAnalyticsId,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',

View File

@ -22,13 +22,14 @@ const pinoPrettyStream = pinoPretty({
// eslint-disable-next-line import/no-default-export
export default class Logger {
private readonly domain: string;
private logger: pino.Logger;
private readonly domain: string | undefined;
private readonly logger: pino.Logger;
private context: Record<string, any> = {};
constructor(domain: string, _color?: KEYWORD, _store = true, parentLogger?: Logger) {
constructor(domain: string | undefined, _color?: KEYWORD, _store = true, parentLogger?: Logger) {
if (parentLogger) {
this.domain = parentLogger.domain + '.' + domain;
this.domain = [parentLogger.domain, domain].filter(x => x).join('.') || undefined;
this.context = { ...JSON.parse(JSON.stringify(parentLogger.context)) };
} else {
this.domain = domain;
}
@ -50,18 +51,20 @@ export default class Logger {
formatters: {
level: (label, number) => ({ severity: label, level: number }),
},
mixin: () => ({ cluster: cluster.isPrimary ? 'primary' : `worker#${cluster.worker!.id}`, ...this.context }),
mixin: () => this.mixin(),
}, !envOption.logJson ? pinoPrettyStream : undefined);
}
@bindThis
public createSubLogger(domain: string, _color?: KEYWORD, _store = true): Logger {
return new Logger(domain, undefined, false, this);
private mixin(): Record<string, any> {
return { cluster: cluster.isPrimary ? 'primary' : `worker#${cluster.worker!.id}`, ...this.context };
}
public createSubLogger(domain?: string, _color?: KEYWORD, _store = true): Logger {
return new Logger(domain, _color, _store, this);
}
@bindThis
public setContext(context: Record<string, any>): void {
this.context = context;
this.context = { ...this.context, ...JSON.parse(JSON.stringify(context)) };
}
@bindThis
@ -130,3 +133,6 @@ export default class Logger {
this.logger.info({ context, important }, message);
}
}
export const rootLogger = new Logger(undefined, undefined, false, undefined);
export const coreLogger = rootLogger.createSubLogger('core', 'cyan');

View File

@ -250,6 +250,12 @@ export class MiMeta {
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
@Column('varchar', {
length: 32,
nullable: true,
})
public googleAnalyticsId: string | null;
@Column('enum', {
enum: ['none', 'all', 'local', 'remote'],
default: 'none',

View File

@ -8,7 +8,7 @@ import { noteVisibilities } from '@/types.js';
import { id } from './util/id.js';
import { MiNote } from './Note.js';
import type { MiUser } from './User.js';
import type { MiChannel } from "@/models/Channel.js";
import type { MiChannel } from '@/models/Channel.js';
@Entity('poll')
export class MiPoll {

View File

@ -111,6 +111,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: true,
},
googleAnalyticsId: {
type: 'string',
optional: false, nullable: true,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,

View File

@ -13,7 +13,7 @@ import type {
} from '@/models/_.js';
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
import type * as Bull from "bullmq";
import type { DbUserSuspendJobData } from "@/queue/types.js";
import type { DbUserSuspendJobData } from '@/queue/types.js';
@Injectable()
export class UserSuspendProcessorService {

View File

@ -27,6 +27,7 @@ import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { randomUUID } from 'node:crypto';
@Injectable()
export class SigninApiService {
@ -71,7 +72,7 @@ export class SigninApiService {
reply: FastifyReply,
) {
const logger = this.loggerService.getLogger('api:signin');
logger.setContext({ username: request.body.username, ip: request.ip, headers: request.headers });
logger.setContext({ username: request.body.username, ip: request.ip, headers: request.headers, span: request.headers['x-client-transaction-id'] ?? randomUUID() });
logger.info('Requested to sign in.');
reply.header('Access-Control-Allow-Origin', this.config.url);

View File

@ -22,6 +22,7 @@ import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { LoggerService } from '@/core/LoggerService.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { randomUUID } from 'node:crypto';
@Injectable()
export class SignupApiService {
@ -73,7 +74,7 @@ export class SignupApiService {
reply: FastifyReply,
) {
const logger = this.loggerService.getLogger('api:signup');
logger.setContext({ username: request.body.username, email: request.body.emailAddress, ip: request.ip, headers: request.headers });
logger.setContext({ username: request.body.username, email: request.body.emailAddress, ip: request.ip, headers: request.headers, span: request.headers['x-client-transaction-id'] ?? randomUUID() });
logger.info('Requested to create user account.');
const body = request.body;

View File

@ -6,6 +6,7 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { IdService } from '@/core/IdService.js';
export const meta = {
tags: ['admin'],
@ -13,6 +14,49 @@ export const meta = {
requireCredential: true,
requireRolePolicy: 'canManageAvatarDecorations',
kind: 'write:admin:avatar-decorations',
res: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
url: {
type: 'string',
optional: false, nullable: false,
},
roleIdsThatCanBeUsedThisDecoration: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
},
},
} as const;
export const paramDef = {
@ -32,14 +76,25 @@ export const paramDef = {
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private avatarDecorationService: AvatarDecorationService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
await this.avatarDecorationService.create({
const created = await this.avatarDecorationService.create({
name: ps.name,
description: ps.description,
url: ps.url,
roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration,
}, me);
return {
id: created.id,
createdAt: this.idService.parse(created.id).date.toISOString(),
updatedAt: null,
name: created.name,
description: created.description,
url: created.url,
roleIdsThatCanBeUsedThisDecoration: created.roleIdsThatCanBeUsedThisDecoration,
};
});
}
}

View File

@ -4,10 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js';
import type { MiAnnouncement } from '@/models/Announcement.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';

View File

@ -61,6 +61,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
googleAnalyticsId: {
type: 'string',
optional: false, nullable: true,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,
@ -497,6 +501,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
googleAnalyticsId: instance.googleAnalyticsId,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl,

View File

@ -76,6 +76,7 @@ export const paramDef = {
enableTurnstile: { type: 'boolean' },
turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true },
googleAnalyticsId: { type: 'string', nullable: true },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
@ -351,6 +352,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.turnstileSecretKey = ps.turnstileSecretKey;
}
if (ps.googleAnalyticsId !== undefined) {
set.googleAnalyticsId = ps.googleAnalyticsId;
}
if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
}

View File

@ -6,8 +6,8 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { EntityNotFoundError } from "typeorm";
import { ApiError } from "../error.js";
import { EntityNotFoundError } from 'typeorm';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['meta'],

View File

@ -80,7 +80,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me, _token, _file, _cleanup, ip, headers) => {
const logger = this.loggerService.getLogger('api:federation:instances');
logger.setContext({ params: ps, user: me?.id, ip, headers });
logger.setContext({ params: ps, userId: me?.id, ip, headers });
logger.info('Requested to fetch federated instances.');
const query = this.instancesRepository.createQueryBuilder('instance');

View File

@ -8,7 +8,9 @@ import type { UserProfilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../error.js';
import { ApiError } from '@/server/api/error.js';
import { LoggerService } from '@/core/LoggerService.js';
import { randomUUID } from 'node:crypto';
export const meta = {
tags: ['account'],
@ -44,11 +46,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private loggerService: LoggerService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, user, token) => {
super(meta, paramDef, async (ps, user, token, _file, _cleanup, ip, headers) => {
const isSecure = token == null;
const logger = this.loggerService.getLogger('api:account:i');
logger.setContext({ userId: user?.id, username: user?.username, client: isSecure ? 'misskey' : 'app', ip, headers, span: (headers ? headers['x-client-transaction-id'] : undefined) ?? randomUUID() });
logger.info('Fetching account information');
const now = new Date();
const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
@ -71,11 +78,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
}
return await this.userEntityService.pack(userProfile.user!, userProfile.user!, {
schema: 'MeDetailed',
includeSecrets: isSecure,
userProfile,
});
try {
const result = await this.userEntityService.pack(userProfile.user!, userProfile.user!, {
schema: 'MeDetailed',
includeSecrets: isSecure,
userProfile,
});
logger.info('Returning account information');
return result;
} catch (error) {
logger.error('Failed to pack user entity', { error });
throw error;
}
});
}
}

View File

@ -33,9 +33,9 @@ import type { Config } from '@/config.js';
import { safeForSql } from '@/misc/safe-for-sql.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
import { ApiLoggerService } from '../../ApiLoggerService.js';
import { ApiError } from '../../error.js';
import { IdService } from "@/core/IdService.js";
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
export const meta = {
tags: ['account'],

View File

@ -282,7 +282,7 @@ export class ClientServerService {
};
const csp = this.config.contentSecurityPolicy
?? 'script-src \'self\' ' +
'https://challenges.cloudflare.com https://hcaptcha.com https://*.hcaptcha.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ {scriptNonce}; ' +
'https://challenges.cloudflare.com https://hcaptcha.com https://*.hcaptcha.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ https://www.googletagmanager.com/ {scriptNonce}; ' +
'worker-src blob: \'self\'; ' +
'base-uri \'self\'; object-src \'self\'; report-uri /csp-error';
reply.header('Content-Security-Policy-Report-Only', csp.replace('{scriptNonce}', `'nonce-${scriptNonce}'`));
@ -478,7 +478,9 @@ export class ClientServerService {
};
// Atom
fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => {
fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
if (feed) {
@ -491,7 +493,9 @@ export class ClientServerService {
});
// RSS
fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => {
fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
if (feed) {
@ -504,7 +508,9 @@ export class ClientServerService {
});
// JSON
fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => {
fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
if (feed) {

View File

@ -19,7 +19,7 @@ import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/te
import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js';
import { loadConfig } from '@/config.js';
import type { AppLockService } from '@/core/AppLockService.js';
import Logger from '@/logger.js';
import { coreLogger } from '@/logger.js';
describe('Chart', () => {
const config = loadConfig();
@ -63,7 +63,7 @@ describe('Chart', () => {
await db.initialize();
const logger = new Logger('chart'); // TODO: モックにする
const logger = coreLogger.createSubLogger('chart'); // TODO: モックにする
testChart = new TestChart(db, appLockService, logger);
testGroupedChart = new TestGroupedChart(db, appLockService, logger);
testUniqueChart = new TestUniqueChart(db, appLockService, logger);

14
packages/frontend/@types/vue-gtag.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
declare module 'vue-gtag' {
export type GtagConsent = (command: 'consent', arg: 'default' | 'update', params: GtagConsentParams) => void;
export interface GtagConsentParams {
ad_storage?: 'granted' | 'denied',
ad_user_data?: 'granted' | 'denied',
ad_personalization?: 'granted' | 'denied',
analytics_storage?: 'granted' | 'denied',
functionality_storage?: 'granted' | 'denied',
personalization_storage?: 'granted' | 'denied',
security_storage?: 'granted' | 'denied',
wait_for_update?: number
}
}

View File

@ -25,9 +25,9 @@
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.1",
"@rollup/plugin-typescript": "12.1.1",
"@rollup/pluginutils": "5.1.2",
"@rollup/pluginutils": "5.1.3",
"@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.19.0",
"@tabler/icons-webfont": "3.21.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.1.4",
"@vue/compiler-sfc": "3.5.12",
@ -36,12 +36,12 @@
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.5",
"chart.js": "4.4.6",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "11.14.0",
"chromatic": "11.16.5",
"compare-versions": "6.1.1",
"cropperjs": "2.0.0-rc.0",
"date-fns": "4.1.0",
@ -59,85 +59,85 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode": "2.3.1",
"rollup": "4.24.0",
"rollup": "4.24.4",
"sanitize-html": "2.13.1",
"sass": "1.80.3",
"shiki": "1.22.0",
"sass": "1.80.6",
"shiki": "1.22.2",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.169.0",
"three": "0.170.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0",
"typescript": "5.6.3",
"uuid": "10.0.0",
"uuid": "11.0.2",
"v-code-diff": "1.13.1",
"vite": "5.4.9",
"vite": "5.4.10",
"vue": "3.5.12",
"vue-gtag": "2.0.1",
"vuedraggable": "next"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.1.0",
"@storybook/addon-actions": "8.3.6",
"@storybook/addon-essentials": "8.3.6",
"@storybook/addon-interactions": "8.3.6",
"@storybook/addon-links": "8.3.6",
"@storybook/addon-mdx-gfm": "8.3.6",
"@storybook/addon-storysource": "8.3.6",
"@storybook/blocks": "8.3.6",
"@storybook/components": "8.3.6",
"@storybook/core-events": "8.3.6",
"@storybook/manager-api": "8.3.6",
"@storybook/preview-api": "8.3.6",
"@storybook/react": "8.3.6",
"@storybook/react-vite": "8.3.6",
"@storybook/test": "8.3.6",
"@storybook/theming": "8.3.6",
"@storybook/types": "8.3.6",
"@storybook/vue3": "8.3.6",
"@storybook/vue3-vite": "8.3.6",
"@storybook/addon-actions": "8.4.2",
"@storybook/addon-essentials": "8.4.2",
"@storybook/addon-interactions": "8.4.2",
"@storybook/addon-links": "8.4.2",
"@storybook/addon-mdx-gfm": "8.4.2",
"@storybook/addon-storysource": "8.4.2",
"@storybook/blocks": "8.4.2",
"@storybook/components": "8.4.2",
"@storybook/core-events": "8.4.2",
"@storybook/manager-api": "8.4.2",
"@storybook/preview-api": "8.4.2",
"@storybook/react": "8.4.2",
"@storybook/react-vite": "8.4.2",
"@storybook/test": "8.4.2",
"@storybook/theming": "8.4.2",
"@storybook/types": "8.4.2",
"@storybook/vue3": "8.4.2",
"@storybook/vue3-vite": "8.4.2",
"@testing-library/vue": "8.1.0",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.6",
"@types/matter-js": "0.19.7",
"@types/micromatch": "4.0.9",
"@types/node": "22.7.8",
"@types/node": "22.9.0",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.13.0",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "10.0.0",
"@types/ws": "8.5.12",
"@types/ws": "8.5.13",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"@vitest/coverage-v8": "2.1.3",
"@vitest/coverage-v8": "2.1.4",
"@vue/runtime-core": "3.5.12",
"acorn": "8.13.0",
"acorn": "8.14.0",
"cross-env": "7.0.3",
"cypress": "13.15.0",
"cypress": "13.15.1",
"eslint": "8.57.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "9.29.1",
"eslint-plugin-vue": "9.30.0",
"fast-glob": "3.3.2",
"happy-dom": "15.7.4",
"happy-dom": "15.9.0",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"msw": "2.5.0",
"msw-storybook-addon": "2.0.3",
"msw": "2.6.0",
"msw-storybook-addon": "2.0.4",
"nodemon": "3.1.7",
"prettier": "3.3.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"start-server-and-test": "2.0.8",
"storybook": "8.3.6",
"storybook": "8.4.2",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "2.1.3",
"vitest": "2.1.4",
"vitest-fetch-mock": "0.3.0",
"vue-component-type-helpers": "2.1.6",
"vue-component-type-helpers": "2.1.10",
"vue-eslint-parser": "9.4.3",
"vue-tsc": "2.1.6"
"vue-tsc": "2.1.10"
}
}

View File

@ -10,7 +10,7 @@ import '@/style.scss';
import { mainBoot } from '@/boot/main-boot.js';
import { subBoot } from '@/boot/sub-boot.js';
const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete'];
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/sso', '/signup-complete'];
if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
subBoot();

View File

@ -12,8 +12,10 @@ import { MenuButton } from '@/types/menu.js';
import { del, get, set } from '@/scripts/idb-proxy.js';
import { apiUrl } from '@/config.js';
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { generateClientTransactionId, misskeyApi } from '@/scripts/misskey-api.js';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
import { set as gtagSet } from 'vue-gtag';
import { instance } from '@/instance.js';
// TODO: 他のタブと永続化されたstateを同期
@ -59,6 +61,7 @@ export async function signout() {
}),
headers: {
'Content-Type': 'application/json',
'X-Client-Transaction-Id': generateClientTransactionId('misskey'),
},
});
}
@ -109,6 +112,7 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
}),
headers: {
'Content-Type': 'application/json',
'X-Client-Transaction-Id': generateClientTransactionId('misskey'),
},
})
.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
@ -171,6 +175,12 @@ export function updateAccount(accountData: Partial<Account>) {
$i[key] = value;
}
miLocalStorage.setItem('account', JSON.stringify($i));
if (instance.googleAnalyticsId) {
gtagSet({
'client_id': miLocalStorage.getItem('id'),
'user_id': $i.id,
});
}
}
export async function refreshAccount() {
@ -222,24 +232,6 @@ export async function openAccountMenu(opts: {
}, ev: MouseEvent) {
if (!$i) return;
function showSigninDialog() {
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => {
addAccount(res.id, res.i);
success();
},
}, 'closed');
}
function createAccount() {
popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: res => {
addAccount(res.id, res.i);
switchAccountWithToken(res.i);
},
}, 'closed');
}
async function switchAccount(account: Misskey.entities.UserDetailed) {
const storedAccounts = await getAccounts();
const found = storedAccounts.find(x => x.id === account.id);
@ -296,10 +288,22 @@ export async function openAccountMenu(opts: {
text: i18n.ts.addAccount,
children: [{
text: i18n.ts.existingAccount,
action: () => { showSigninDialog(); },
action: () => {
getAccountWithSigninDialog().then(res => {
if (res != null) {
success();
}
});
},
}, {
text: i18n.ts.createAccount,
action: () => { createAccount(); },
action: () => {
getAccountWithSignupDialog().then(res => {
if (res != null) {
switchAccountWithToken(res.token);
}
});
},
}],
}, {
type: 'link' as const,
@ -316,6 +320,40 @@ export async function openAccountMenu(opts: {
}
}
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
await addAccount(res.id, res.i);
resolve({ id: res.id, token: res.i });
},
cancelled: () => {
resolve(null);
},
closed: () => {
dispose();
},
});
});
}
export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: async (res: Misskey.entities.SignupResponse) => {
await addAccount(res.id, res.token);
resolve({ id: res.id, token: res.token });
},
cancelled: () => {
resolve(null);
},
closed: () => {
dispose();
},
});
});
}
if (_DEV_) {
(window as any).$i = $i;
}

View File

@ -11,7 +11,7 @@ import components from '@/components/index.js';
import { version, lang, updateLocale, locale } from '@/config.js';
import { applyTheme } from '@/scripts/theme.js';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
import { updateI18n } from '@/i18n.js';
import { updateI18n, i18n } from '@/i18n.js';
import { $i, refreshAccount, login } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
import { fetchInstance, instance } from '@/instance.js';
@ -23,6 +23,8 @@ import { deckStore } from '@/ui/deck/deck-store.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } from '@/router/definition.js';
import { mainRouter } from '@/router/main.js';
import VueGtag, { bootstrap as gtagBootstrap, GtagConsent, GtagConsentParams } from 'vue-gtag';
export async function common(createVue: () => App<Element>) {
console.info(`Misskey v${version}`);
@ -60,6 +62,10 @@ export async function common(createVue: () => App<Element>) {
});
}
if (miLocalStorage.getItem('id') === null) {
miLocalStorage.setItem('id', crypto.randomUUID());
}
let isClientUpdated = false;
//#region クライアントが更新されたかチェック
@ -260,6 +266,38 @@ export async function common(createVue: () => App<Element>) {
directives(app);
components(app);
if (instance.googleAnalyticsId) {
app.use(VueGtag, {
bootstrap: false,
appName: `Misskey v${version}`,
config: {
id: instance.googleAnalyticsId,
params: {
anonymize_ip: false,
send_page_view: true,
},
},
}, mainRouter);
const gtagConsent = miLocalStorage.getItemAsJson('gtagConsent') as GtagConsentParams ?? {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted',
};
miLocalStorage.setItemAsJson('gtagConsent', gtagConsent);
if (typeof window['gtag'] === 'function') (window['gtag'] as GtagConsent)('consent', 'default', gtagConsent);
if (miLocalStorage.getItem('gaConsent') === 'true') {
// noinspection ES6MissingAwait
gtagBootstrap();
}
}
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
// なぜか2回実行されることがあるため、mountするdivを1つに制限する
const rootEl = ((): HTMLElement => {
@ -286,6 +324,27 @@ export async function common(createVue: () => App<Element>) {
removeSplash();
//#region Self-XSS 対策メッセージ
console.log(
`%c${i18n.ts._selfXssPrevention.warning}`,
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.title}`,
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.description1}`,
'font-size: 16px; font-weight: 700;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.description2}`,
'font-size: 16px;',
'font-size: 20px; font-weight: 700; color: #f00;',
);
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
//#endregion
return {
isClientUpdated,
app,

View File

@ -20,6 +20,7 @@ import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js';
import { instance } from '@/instance.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
@ -234,19 +235,25 @@ export async function mainBoot() {
}
miLocalStorage.setItem('lastUsed', Date.now().toString());
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
if (!location.pathname.startsWith('/miauth') && !location.pathname.startsWith('/sso') && !location.pathname.startsWith('/oauth')) {
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3)))) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
}
}
// const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
// if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
// popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
// }
if (instance.googleAnalyticsId && miLocalStorage.getItem('gaConsent') === null) {
popup(defineAsyncComponent(() => import('@/components/MkTrackingConsent.vue')), {}, {}, 'closed');
}
}
// const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
// if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
// popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
// }
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト
if (Notification.permission === 'default') {

View File

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkAuthConfirm from './MkAuthConfirm.vue';
void MkAuthConfirm;

View File

@ -0,0 +1,450 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_enterActive"
:leaveActiveClass="$style.transition_leaveActive"
:enterFromClass="$style.transition_enterFrom"
:leaveToClass="$style.transition_leaveTo"
:inert="_waiting"
>
<div v-if="phase === 'accountSelect'" key="accountSelect" :class="$style.root" class="_gaps">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-user"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts.pleaseSelectAccount }}</div>
</div>
<div :class="$style.accountSelectorRoot">
<div :class="$style.accountSelectorLabel">{{ i18n.ts.selectAccount }}</div>
<div :class="$style.accountSelectorList">
<template v-for="[id, user] in users">
<input :id="'account-' + id" v-model="selectedUser" type="radio" name="accountSelector" :value="id" :class="$style.accountSelectorRadio"/>
<label :for="'account-' + id" :class="$style.accountSelectorItem">
<MkAvatar :user="user" :class="$style.accountSelectorAvatar"/>
<div :class="$style.accountSelectorBody">
<MkUserName :user="user" :class="$style.accountSelectorName"/>
<MkAcct :user="user" :class="$style.accountSelectorAcct"/>
</div>
</label>
</template>
<button class="_button" :class="[$style.accountSelectorItem, $style.accountSelectorAddAccountRoot]" @click="clickAddAccount">
<div :class="[$style.accountSelectorAvatar, $style.accountSelectorAddAccountAvatar]">
<i class="ti ti-user-plus"></i>
</div>
<div :class="[$style.accountSelectorBody, $style.accountSelectorName]">{{ i18n.ts.addAccount }}</div>
</button>
</div>
</div>
<div class="_buttonsCenter">
<MkButton rounded gradate :disabled="selectedUser === null" @click="clickChooseAccount">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
<div v-else-if="phase === 'consent'" key="consent" :class="$style.root" class="_gaps">
<div :class="$style.header" class="_gaps_s">
<img v-if="icon" :class="$style.icon" :src="getProxiedImageUrl(icon, 'preview')"/>
<div v-else :class="$style.iconFallback">
<i class="ti ti-apps"></i>
</div>
<div :class="$style.headerText">{{ name ? i18n.tsx._auth.shareAccess({ name }) : i18n.ts._auth.shareAccessAsk }}</div>
</div>
<div v-if="permissions && permissions.length > 0" class="_gaps_s" :class="$style.permissionRoot">
<div>{{ name ? i18n.tsx._auth.permission({ name }) : i18n.ts._auth.permissionAsk }}</div>
<div :class="$style.permissionListWrapper">
<ul :class="$style.permissionList">
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
</div>
<slot name="consentAdditionalInfo"></slot>
<div :class="$style.accountSelectorRoot">
<div :class="$style.accountSelectorLabel">
{{ i18n.ts._auth.scopeUser }} <button class="_textButton" @click="clickBackToAccountSelect">{{ i18n.ts.switchAccount }}</button>
</div>
<div :class="$style.accountSelectorList">
<div :class="[$style.accountSelectorItem, $style.static]">
<MkAvatar :user="users.get(selectedUser!)!" :class="$style.accountSelectorAvatar"/>
<div :class="$style.accountSelectorBody">
<MkUserName :user="users.get(selectedUser!)!" :class="$style.accountSelectorName"/>
<MkAcct :user="users.get(selectedUser!)!" :class="$style.accountSelectorAcct"/>
</div>
</div>
</div>
</div>
<div class="_buttonsCenter">
<MkButton rounded @click="clickCancel">{{ i18n.ts.reject }}</MkButton>
<MkButton rounded gradate @click="clickAccept">{{ i18n.ts.accept }}</MkButton>
</div>
</div>
<div v-else-if="phase === 'success'" key="success" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-check"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div>
<div :class="$style.headerTextSub">{{ i18n.ts._auth.pleaseGoBack }}</div>
</div>
</div>
<div v-else-if="phase === 'denied'" key="denied" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-x"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts._auth.denied }}</div>
</div>
</div>
<div v-else-if="phase === 'failed'" key="failed" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-x"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts.somethingHappened }}</div>
</div>
</div>
</Transition>
<div v-if="_waiting" :class="$style.waitingRoot">
<MkLoading/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const props = defineProps<{
name?: string;
icon?: string;
permissions?: (typeof Misskey.permissions[number])[];
manualWaiting?: boolean;
waitOnDeny?: boolean;
}>();
const emit = defineEmits<{
(ev: 'accept', token: string): void;
(ev: 'deny', token: string): void;
}>();
const waiting = ref(true);
const _waiting = computed(() => waiting.value || props.manualWaiting);
const phase = ref<'accountSelect' | 'consent' | 'success' | 'denied' | 'failed'>('accountSelect');
const selectedUser = ref<string | null>(null);
const users = ref(new Map<string, Misskey.entities.UserDetailed & { token: string; }>());
async function init() {
waiting.value = true;
users.value.clear();
if ($i) {
users.value.set($i.id, $i);
}
const accounts = await getAccounts();
const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
if (accountIdsToFetch.length > 0) {
const usersRes = await misskeyApi('users/show', {
userIds: accountIdsToFetch,
});
for (const user of usersRes) {
if (users.value.has(user.id)) continue;
users.value.set(user.id, {
...user,
token: accounts.find(a => a.id === user.id)!.token,
});
}
}
waiting.value = false;
}
init();
function clickAddAccount(ev: MouseEvent) {
selectedUser.value = null;
os.popupMenu([{
text: i18n.ts.existingAccount,
action: () => {
getAccountWithSigninDialog().then(async (res) => {
if (res != null) {
os.success();
await init();
if (users.value.has(res.id)) {
selectedUser.value = res.id;
}
}
});
},
}, {
text: i18n.ts.createAccount,
action: () => {
getAccountWithSignupDialog().then(async (res) => {
if (res != null) {
os.success();
await init();
if (users.value.has(res.id)) {
selectedUser.value = res.id;
}
}
});
},
}], ev.currentTarget ?? ev.target);
}
function clickChooseAccount() {
if (selectedUser.value === null) return;
phase.value = 'consent';
}
function clickBackToAccountSelect() {
selectedUser.value = null;
phase.value = 'accountSelect';
}
function clickCancel() {
if (selectedUser.value === null) return;
const user = users.value.get(selectedUser.value)!;
const token = user.token;
if (props.waitOnDeny) {
waiting.value = true;
}
emit('deny', token);
}
async function clickAccept() {
if (selectedUser.value === null) return;
const user = users.value.get(selectedUser.value)!;
const token = user.token;
waiting.value = true;
emit('accept', token);
}
function showUI(state: 'success' | 'denied' | 'failed') {
phase.value = state;
waiting.value = false;
}
defineExpose({
showUI,
});
</script>
<style lang="scss" module>
.transition_enterActive,
.transition_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.wrapper {
overflow-x: hidden;
overflow-x: clip;
position: relative;
width: 100%;
height: 100%;
}
.waitingRoot {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: color-mix(in srgb, var(--panel), transparent 50%);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
cursor: wait;
}
.root {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 48px 24px;
}
.header {
margin: 0 auto;
max-width: 320px;
}
.icon,
.iconFallback {
display: block;
margin: 0 auto;
width: 54px;
height: 54px;
}
.icon {
border-radius: 50%;
border: 1px solid var(--divider);
background-color: #fff;
object-fit: contain;
}
.iconFallback {
border-radius: 50%;
background-color: var(--accentedBg);
color: var(--accent);
text-align: center;
line-height: 54px;
font-size: 18px;
}
.headerText,
.headerTextSub {
text-align: center;
word-break: normal;
word-break: auto-phrase;
}
.headerText {
font-size: 16px;
font-weight: 700;
}
.permissionRoot {
padding: 16px;
border-radius: var(--radius);
background-color: var(--bg);
}
.permissionListWrapper {
max-height: 350px;
overflow-y: auto;
padding: 12px;
border-radius: var(--radius);
background-color: var(--panel);
}
.permissionList {
margin: 0 0 0 1.5em;
padding: 0;
font-size: 90%;
}
.accountSelectorLabel {
font-size: 0.85em;
opacity: 0.7;
margin-bottom: 8px;
}
.accountSelectorList {
border-radius: var(--radius);
border: 1px solid var(--divider);
overflow: hidden;
overflow: clip;
}
.accountSelectorRadio {
position: absolute;
clip: rect(0, 0, 0, 0);
pointer-events: none;
&:focus-visible + .accountSelectorItem {
outline: 2px solid var(--accent);
outline-offset: -4px;
}
&:checked:focus-visible + .accountSelectorItem {
outline-color: #fff;
}
&:checked + .accountSelectorItem {
background: color-mix(in srgb, var(--accent), transparent 50%);
color: #fff;
}
}
.accountSelectorItem {
display: flex;
align-items: center;
padding: 8px;
font-size: 14px;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
&:hover {
background: var(--buttonHoverBg);
}
&.static {
cursor: unset;
&:hover {
background: none;
}
}
}
.accountSelectorAddAccountRoot {
width: 100%;
}
.accountSelectorBody {
padding: 0 8px;
min-width: 0;
}
.accountSelectorAvatar {
width: 45px;
height: 45px;
}
.accountSelectorAddAccountAvatar {
background-color: var(--accentedBg);
color: var(--accent);
font-size: 16px;
line-height: 45px;
text-align: center;
border-radius: 50%;
}
.accountSelectorName {
display: block;
font-weight: bold;
}
.accountSelectorAcct {
opacity: 0.5;
}
</style>

View File

@ -26,12 +26,12 @@ import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
import MkModal from './MkModal.vue';
const props = withDefaults(defineProps<{
withOkButton: boolean;
withCloseButton: boolean;
okButtonDisabled: boolean;
escKeyDisabled: boolean;
width: number;
height: number;
withOkButton?: boolean;
withCloseButton?: boolean;
okButtonDisabled?: boolean;
escKeyDisabled?: boolean;
width?: number;
height?: number;
}>(), {
withOkButton: false,
withCloseButton: true,

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="dialog"
:width="500"
:height="600"
@close="dialog?.close()"
@close="onClose"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts.signup }}</template>
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="$style.transition_x_leaveTo"
>
<template v-if="!isAcceptedServerRule">
<XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/>
<XServerRules @done="isAcceptedServerRule = true" @cancel="onClose"/>
</template>
<template v-else>
<XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
@ -47,7 +47,8 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', res: Misskey.entities.SigninResponse): void;
(ev: 'done', res: Misskey.entities.SignupResponse): void;
(ev: 'cancelled'): void;
(ev: 'closed'): void;
}>();
@ -55,7 +56,12 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const isAcceptedServerRule = ref(false);
function onSignup(res: Misskey.entities.SigninResponse) {
function onClose() {
emit('cancelled');
dialog.value?.close();
}
function onSignup(res: Misskey.entities.SignupResponse) {
emit('done', res);
dialog.value?.close();
}

View File

@ -0,0 +1,177 @@
<template>
<div class="_panel _shadow" :class="$style.root">
<div :class="$style.main">
<div style="display: flex; align-items: center;">
<div :class="$style.headerIcon">
<i class="ti ti-report-analytics"></i>
</div>
<div :class="$style.headerTitle"><Mfm :text="i18n.ts.helpUsImproveUserExperience" /></div>
</div>
<div :class="$style.text">
<Mfm
:text="i18n.tsx.pleaseConsentToTracking({
host: instance.name ?? host,
privacyPolicyUrl: instance.privacyPolicyUrl,
})"
/>
</div>
<div class="_gaps_s">
<div class="_buttons" style="justify-content: right;">
<MkButton @click="consentEssential">{{ i18n.ts.consentEssential }}</MkButton>
<MkButton primary @click="consentAll">{{ i18n.ts.consentAll }}</MkButton>
</div>
<MkFolder>
<template #icon><i class="ti ti-lock-square"></i></template>
<template #label>{{ i18n.ts.gtagConsentCustomize }}</template>
<div class="_gaps_s">
<MkInfo>{{ i18n.tsx.gtagConsentCustomizeDescription({ host: instance.name ?? host }) }}</MkInfo>
<MkSwitch v-model="gtagConsentAnalytics">
{{ i18n.ts.gtagConsentAnalytics }}
<template #caption>{{ i18n.ts.gtagConsentAnalyticsDescription }}</template>
</MkSwitch>
<MkSwitch v-model="gtagConsentFunctionality">
{{ i18n.ts.gtagConsentFunctionality }}
<template #caption>{{ i18n.ts.gtagConsentFunctionalityDescription }}</template>
</MkSwitch>
<MkSwitch v-model="gtagConsentPersonalization">
{{ i18n.ts.gtagConsentPersonalization }}
<template #caption>{{ i18n.ts.gtagConsentPersonalizationDescription }}</template>
</MkSwitch>
<div class="_buttons" style="justify-content: right;">
<MkButton @click="consentSelected">{{ i18n.ts.consentSelected }}</MkButton>
</div>
</div>
</MkFolder>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInfo from '@/components/MkInfo.vue';
import { miLocalStorage } from '@/local-storage.js';
import { instance } from '@/instance.js';
import { host } from '@/config.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import * as os from '@/os.js';
import {
bootstrap as gtagBootstrap,
GtagConsent,
GtagConsentParams,
set as gtagSet
} from 'vue-gtag';
const emit = defineEmits<(ev: 'closed') => void>();
const zIndex = os.claimZIndex('middle');
const gtagConsentAnalytics = ref(false);
const gtagConsentFunctionality = ref(false);
const gtagConsentPersonalization = ref(false);
function consentAll() {
miLocalStorage.setItem('gaConsent', 'true');
const gtagConsent = <GtagConsentParams>{
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted',
functionality_storage: 'granted',
personalization_storage: 'granted',
security_storage: 'granted',
};
miLocalStorage.setItemAsJson('gtagConsent', gtagConsent);
if (typeof window['gtag'] === 'function') (window['gtag'] as GtagConsent)('consent', 'update', gtagConsent);
bootstrap();
emit('closed');
}
function consentEssential() {
miLocalStorage.setItem('gaConsent', 'true');
const gtagConsent = <GtagConsentParams>{
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted',
};
miLocalStorage.setItemAsJson('gtagConsent', gtagConsent);
if (typeof window['gtag'] === 'function') (window['gtag'] as GtagConsent)('consent', 'update', gtagConsent);
bootstrap();
emit('closed');
}
function consentSelected() {
miLocalStorage.setItem('gaConsent', 'true');
const gtagConsent = <GtagConsentParams>{
ad_storage: gtagConsentAnalytics.value ? 'granted' : 'denied',
ad_user_data: gtagConsentFunctionality.value ? 'granted' : 'denied',
ad_personalization: gtagConsentPersonalization.value ? 'granted' : 'denied',
analytics_storage: gtagConsentAnalytics.value ? 'granted' : 'denied',
functionality_storage: gtagConsentFunctionality.value ? 'granted' : 'denied',
personalization_storage: gtagConsentPersonalization.value ? 'granted' : 'denied',
security_storage: 'granted',
};
miLocalStorage.setItemAsJson('gtagConsent', gtagConsent);
if (typeof window['gtag'] === 'function') (window['gtag'] as GtagConsent)('consent', 'update', gtagConsent);
bootstrap();
emit('closed');
}
function bootstrap() {
gtagBootstrap();
gtagSet({
'client_id': miLocalStorage.getItem('id'),
'user_id': $i?.id,
});
}
</script>
<style lang="scss" module>
.root {
position: fixed;
z-index: v-bind(zIndex);
bottom: var(--margin);
left: 0;
right: 0;
margin: auto;
box-sizing: border-box;
width: calc(100% - (var(--margin) * 2));
max-width: 500px;
display: flex;
}
.main {
padding: 25px 25px 25px 25px;
width: inherit;
flex: 1;
}
.headerIcon {
margin-right: 8px;
font-size: 40px;
color: var(--accent);
}
.headerTitle {
font-weight: bold;
font-size: 16px;
}
.text {
margin: 0.7em 0 1em 0;
}
</style>

View File

@ -120,6 +120,7 @@ async function done(): Promise<void> {
imageUrl: null,
display: display.value,
needConfirmationToRead: needConfirmationToRead.value,
needEnrollmentTutorialToRead: needEnrollmentTutorialToRead.value,
closeDuration: closeDuration.value,
displayOrder: displayOrder.value,
silence: silence.value,

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow
ref="dialog"
:width="500"
:height="550"
:height="600"
data-cy-user-setup
:withCloseButton="false"
:escKeyDisabled="true"

View File

@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
rel: 'nofollow noopener',
target: '_blank',
}"
@click="onAdClicked"
>
<img :src="chosen.imageUrl" :class="$style.img">
<button class="_button" :class="$style.i" @click.prevent.stop="toggleMenu"><i :class="$style.iIcon" class="ti ti-info-circle"></i></button>
@ -42,7 +43,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
/* eslint-disable id-denylist */
import { ref, computed, onActivated, onMounted } from 'vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { url as local, host } from '@/config.js';
@ -50,6 +52,7 @@ import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
import { $i } from '@/account.js';
import { usageReport } from '@/scripts/usage-report.js';
type Ad = (typeof instance)['ads'][number];
@ -123,6 +126,36 @@ function reduceFrequency(): void {
chosen.value = choseAd();
showMenu.value = false;
}
function onAdClicked(): void {
if (chosen.value == null) return;
usageReport({
t: Math.floor(Date.now() / 1000),
e: 'a',
i: chosen.value.id,
a: 'c',
});
}
onMounted(() => {
if (chosen.value == null) return;
usageReport({
t: Math.floor(Date.now() / 1000),
e: 'a',
i: chosen.value.id,
a: 'v',
});
});
onActivated(() => {
if (chosen.value == null) return;
usageReport({
t: Math.floor(Date.now() / 1000),
e: 'a',
i: chosen.value.id,
a: 'v',
});
});
</script>
<style lang="scss" module>

View File

@ -16,13 +16,13 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/ https://fonts.gstatic.com/ https://www.google-analytics.com/ https://www.googletagmanager.com/;
worker-src 'self';
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://www.googletagmanager.com https://esm.sh;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com https://www.googletagmanager.com;
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://fonts.gstatic.com https://www.googletagmanager.com;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.pwnedpasswords.com;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.pwnedpasswords.com https://www.google-analytics.com https://analytics.google.com;
frame-src *;"
/>
<meta property="og:site_name" content="[DEV BUILD] Misskey" />

View File

@ -4,6 +4,7 @@
*/
type Keys =
'id' |
'v' |
'lastVersion' |
'instance' |
@ -39,7 +40,10 @@ type Keys =
'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~)
'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~);
`channelLastReadedAt:${string}` |
'kawaii'
'kawaii' |
'gaConsent' |
'gtagConsent'
;
export const miLocalStorage = {
getItem: (key: Keys): string | null => window.localStorage.getItem(key),

View File

@ -71,6 +71,8 @@ export type Resolved = {
};
};
export type AfterNavigationHook = (to: RouteDef, from: RouteDef) => any;
function parsePath(path: string): ParsedPath {
const res = [] as ParsedPath;
@ -101,6 +103,7 @@ export interface IRouter extends EventEmitter<RouterEvent> {
currentRef: ShallowRef<Resolved>;
currentRoute: ShallowRef<RouteDef>;
navHook: ((path: string, flag?: any) => boolean) | null;
afterHooks: Array<AfterNavigationHook | null>;
/**
* eventListenerの定義後に必ず呼び出すこと
@ -109,10 +112,14 @@ export interface IRouter extends EventEmitter<RouterEvent> {
resolve(path: string): Resolved | null;
isReady(): Promise<boolean>;
getCurrentPath(): any;
getCurrentKey(): string;
afterEach(hook: AfterNavigationHook): AfterNavigationHook | undefined;
push(path: string, flag?: any): void;
replace(path: string, key?: string | null): void;
@ -191,6 +198,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
private redirectCount = 0;
public navHook: ((path: string, flag?: any) => boolean) | null = null;
public afterHooks: Array<AfterNavigationHook | null> = [];
constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) {
super();
@ -339,6 +347,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
private navigate(path: string, key: string | null | undefined, emitChange = true, _redirected = false): Resolved {
const beforePath = this.currentPath;
const beforeRoute = this.currentRoute.value;
this.currentPath = path;
const res = this.resolve(this.currentPath);
@ -382,6 +391,12 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
});
}
if (this.afterHooks.length > 0) {
for (const hook of this.afterHooks) {
if (hook) hook(res.route, beforeRoute);
}
}
this.redirectCount = 0;
return {
...res,
@ -389,6 +404,10 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
};
}
public isReady() {
return Promise.resolve(true);
}
public getCurrentPath() {
return this.currentPath;
}
@ -397,6 +416,18 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
return this.currentKey;
}
public afterEach(hook: AfterNavigationHook): AfterNavigationHook | undefined {
this.afterHooks.push(hook);
return () => {
const index = this.afterHooks.indexOf(hook);
if (index !== -1) {
return this.afterHooks.splice(index, 1);
} else {
return undefined;
}
};
}
public push(path: string, flag?: any) {
const beforePath = this.currentPath;
if (path === beforePath) {

View File

@ -21,6 +21,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</div>
</FormSection>
<FormSection>
<template #label>Google Analytics</template>
<div class="_gaps_m">
<MkInput v-model="googleAnalyticsId">
<template #prefix><i class="ti ti-report-analytics"></i></template>
<template #label>Google Analytics ID</template>
</MkInput>
</div>
</FormSection>
</FormSuspense>
</MkSpacer>
<template #footer>
@ -49,17 +59,20 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const deeplAuthKey = ref<string>('');
const deeplIsPro = ref<boolean>(false);
const googleAnalyticsId = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
deeplAuthKey.value = meta.deeplAuthKey;
deeplIsPro.value = meta.deeplIsPro;
googleAnalyticsId.value = meta.googleAnalyticsId;
}
function save() {
os.apiWithDialog('admin/update-meta', {
deeplAuthKey: deeplAuthKey.value,
deeplIsPro: deeplIsPro.value,
googleAnalyticsId: googleAnalyticsId.value,
}).then(() => {
fetchInstance(true);
});

View File

@ -0,0 +1,220 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkWindow
ref="windowEl"
:initialWidth="400"
:initialHeight="500"
:canResize="true"
@close="windowEl?.close()"
@closed="emit('closed')"
>
<template v-if="avatarDecoration" #header>{{ avatarDecoration.name }}</template>
<template v-else #header>New decoration</template>
<div style="display: flex; flex-direction: column; min-height: 100%;">
<MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
<div class="_gaps_m">
<div :class="$style.preview">
<div :class="[$style.previewItem, $style.light]">
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="url != '' ? [{ url }] : []" forceShowDecoration/>
</div>
<div :class="[$style.previewItem, $style.dark]">
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="url != '' ? [{ url }] : []" forceShowDecoration/>
</div>
</div>
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkInput v-model="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkTextarea v-model="description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkFolder>
<template #label>{{ i18n.ts.availableRoles }}</template>
<template #suffix>{{ rolesThatCanBeUsedThisDecoration.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisDecoration.length }}</template>
<div class="_gaps">
<MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<div v-for="role in rolesThatCanBeUsedThisDecoration" :key="role.id" :class="$style.roleItem">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
</div>
</MkFolder>
<MkButton v-if="avatarDecoration" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</MkSpacer>
<div :class="$style.footer">
<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.avatarDecoration ? i18n.ts.update : i18n.ts.create }}</MkButton>
</div>
</div>
</MkWindow>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { signinRequired } from '@/account.js';
const $i = signinRequired();
const props = defineProps<{
avatarDecoration?: any,
}>();
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : '');
const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : '');
const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : '');
const roleIdsThatCanBeUsedThisDecoration = ref(props.avatarDecoration ? props.avatarDecoration.roleIdsThatCanBeUsedThisDecoration : []);
const rolesThatCanBeUsedThisDecoration = ref<Misskey.entities.Role[]>([]);
watch(roleIdsThatCanBeUsedThisDecoration, async () => {
rolesThatCanBeUsedThisDecoration.value = (await Promise.all(roleIdsThatCanBeUsedThisDecoration.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
async function addRole() {
const roles = await misskeyApi('admin/roles/list');
const currentRoleIds = rolesThatCanBeUsedThisDecoration.value.map(x => x.id);
const { canceled, result: role } = await os.select({
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
});
if (canceled || role == null) return;
rolesThatCanBeUsedThisDecoration.value.push(role);
}
async function removeRole(role, ev) {
rolesThatCanBeUsedThisDecoration.value = rolesThatCanBeUsedThisDecoration.value.filter(x => x.id !== role.id);
}
async function done() {
const params = {
url: url.value,
name: name.value,
description: description.value,
roleIdsThatCanBeUsedThisDecoration: rolesThatCanBeUsedThisDecoration.value.map(x => x.id),
};
if (props.avatarDecoration) {
await os.apiWithDialog('admin/avatar-decorations/update', {
id: props.avatarDecoration.id,
...params,
});
emit('done', {
updated: {
id: props.avatarDecoration.id,
...params,
},
});
windowEl.value?.close();
} else {
const created = await os.apiWithDialog('admin/avatar-decorations/create', params);
emit('done', {
created: created,
});
windowEl.value?.close();
}
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.removeAreYouSure({ x: name.value }),
});
if (canceled) return;
misskeyApi('admin/avatar-decorations/delete', {
id: props.avatarDecoration.id,
}).then(() => {
emit('done', {
deleted: true,
});
windowEl.value?.close();
});
}
</script>
<style lang="scss" module>
.preview {
display: grid;
place-items: center;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: var(--margin);
}
.previewItem {
width: 100%;
height: 100%;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
&.light {
background: #eee;
}
&.dark {
background: #222;
}
}
.roleItem {
display: flex;
}
.role {
flex: 1;
}
.roleUnassign {
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
}
.footer {
position: sticky;
z-index: 10000;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
background: var(--acrylicBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@ -5,78 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps">
<MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null">
<template #label>{{ avatarDecoration.name }}</template>
<template #caption>{{ avatarDecoration.description }}</template>
<div class="_gaps_m">
<MkInput v-model="avatarDecoration.name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="avatarDecoration.description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkInput v-model="avatarDecoration.url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(avatarDecoration)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="avatarDecoration.id != null" class="button" inline danger @click="del(avatarDecoration)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
<div :class="$style.decorations">
<div
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
v-panel
:class="$style.decoration"
@click="edit(avatarDecoration)"
>
<div :class="$style.decorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/>
</div>
</MkFolder>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { signinRequired } from '@/account.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkFolder from '@/components/MkFolder.vue';
const $i = signinRequired();
const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]);
function add() {
avatarDecorations.value.unshift({
_id: Math.random().toString(36),
id: null,
name: '',
description: '',
url: '',
});
}
function del(avatarDecoration) {
os.confirm({
type: 'warning',
text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }),
}).then(({ canceled }) => {
if (canceled) return;
avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration);
misskeyApi('admin/avatar-decorations/delete', avatarDecoration);
});
}
async function save(avatarDecoration) {
if (avatarDecoration.id == null) {
await os.apiWithDialog('admin/avatar-decorations/create', avatarDecoration);
load();
} else {
os.apiWithDialog('admin/avatar-decorations/update', avatarDecoration);
}
}
function load() {
misskeyApi('admin/avatar-decorations/list').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations;
@ -85,6 +46,37 @@ function load() {
load();
async function add(ev: MouseEvent) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
}, {
done: result => {
if (result.created) {
avatarDecorations.value.unshift(result.created);
}
},
closed: () => dispose(),
});
}
function edit(avatarDecoration) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
avatarDecoration: avatarDecoration,
}, {
done: result => {
if (result.updated) {
const index = avatarDecorations.value.findIndex(x => x.id === avatarDecoration.id);
avatarDecorations.value[index] = {
...avatarDecorations.value[index],
...result.updated,
};
} else if (result.deleted) {
avatarDecorations.value = avatarDecorations.value.filter(x => x.id !== avatarDecoration.id);
}
},
closed: () => dispose(),
});
}
const headerActions = computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
@ -99,3 +91,28 @@ definePageMetadata(() => ({
icon: 'ti ti-sparkles',
}));
</script>
<style lang="scss" module>
.decorations {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-gap: 12px;
}
.decoration {
cursor: pointer;
padding: 16px 16px 28px 16px;
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.decorationName {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
</style>

View File

@ -5,136 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkSpacer :contentMax="1200">
<MkTab v-model="origin" style="margin-bottom: var(--margin);">
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkTab>
<div v-if="origin === 'local'">
<template v-if="tag == null">
<MkFoldableSection class="_margin" persistKey="explore-pinned-users">
<template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<MkUserList :pagination="pinnedUsers"/>
</MkFoldableSection>
<MkFoldableSection class="_margin" persistKey="explore-popular-users">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<MkUserList :pagination="popularUsers"/>
</MkFoldableSection>
<MkFoldableSection class="_margin" persistKey="explore-recently-updated-users">
<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<MkUserList :pagination="recentlyUpdatedUsers"/>
</MkFoldableSection>
<MkFoldableSection class="_margin" persistKey="explore-recently-registered-users">
<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<MkUserList :pagination="recentlyRegisteredUsers"/>
</MkFoldableSection>
</template>
</div>
<div v-else>
<MkFoldableSection ref="tagsEl" :foldable="true" :expanded="false" class="_margin">
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div>
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" style="margin-right: 16px; font-weight: bold;">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`" style="margin-right: 16px;">{{ tag.tag }}</MkA>
</div>
</MkFoldableSection>
<MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin">
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
<MkUserList :pagination="tagUsers"/>
</MkFoldableSection>
<template v-if="tag == null">
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<MkUserList :pagination="popularUsersF"/>
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<MkUserList :pagination="recentlyUpdatedUsersF"/>
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
<MkUserList :pagination="recentlyRegisteredUsersF"/>
</MkFoldableSection>
</template>
</div>
<MkFoldableSection class="_margin" persistKey="explore-pinned-users">
<template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<MkUserList :pagination="pinnedUsers"/>
</MkFoldableSection>
<MkFoldableSection class="_margin" persistKey="explore-popular-users">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<MkUserList :pagination="popularUsers"/>
</MkFoldableSection>
</MkSpacer>
</template>
<script lang="ts" setup>
import { watch, ref, shallowRef, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
tag?: string;
}>();
const origin = ref('local');
const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>();
const tagsLocal = ref<Misskey.entities.Hashtag[]>([]);
const tagsRemote = ref<Misskey.entities.Hashtag[]>([]);
watch(() => props.tag, () => {
if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null);
});
const tagUsers = computed(() => ({
endpoint: 'hashtags/users' as const,
limit: 30,
params: {
tag: props.tag,
origin: 'combined',
sort: '+follower',
},
}));
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
const popularUsers = { endpoint: 'users', limit: 30, noPaging: true, params: {
state: 'alive',
origin: 'local',
sort: '+pv',
} };
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local',
sort: '+updatedAt',
} };
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local',
state: 'alive',
sort: '+createdAt',
} };
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'remote',
sort: '+follower',
} };
const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined',
sort: '+updatedAt',
} };
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined',
sort: '+createdAt',
} };
misskeyApi('hashtags/list', {
sort: '+attachedLocalUsers',
attachedToLocalUserOnly: true,
limit: 30,
}).then(tags => {
tagsLocal.value = tags;
});
misskeyApi('hashtags/list', {
sort: '+attachedRemoteUsers',
attachedToRemoteUserOnly: true,
limit: 30,
}).then(tags => {
tagsRemote.value = tags;
});
</script>

View File

@ -4,95 +4,79 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div v-if="$i">
<div v-if="state == 'waiting'">
<MkLoading/>
</div>
<div v-if="state == 'denied'">
<p>{{ i18n.ts._auth.denied }}</p>
</div>
<div v-else-if="state == 'accepted'" class="accepted">
<p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
<p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p>
</div>
<div v-else>
<div v-if="_permissions.length > 0">
<p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p>
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p>
<ul>
<li v-for="p in _permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<div :class="$style.buttons">
<MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton>
</div>
</div>
<div>
<MkAnimBg style="position: fixed; top: 0;"/>
<div :class="$style.formContainer">
<div :class="$style.form">
<MkAuthConfirm
ref="authRoot"
:name="name"
:icon="icon || undefined"
:permissions="_permissions"
@accept="onAccept"
@deny="onDeny"
>
<template #consentAdditionalInfo>
<div v-if="callback != null" class="_gaps_s" :class="$style.redirectRoot">
<div>{{ i18n.ts._auth.byClickingYouWillBeRedirectedToThisUrl }}</div>
<div class="_monospace" :class="$style.redirectUrl">{{ callback }}</div>
</div>
</template>
</MkAuthConfirm>
</div>
<div v-else>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
<MkSignin @login="onLogin"/>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import MkSignin from '@/components/MkSignin.vue';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { $i, login } from '@/account.js';
import { computed, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkAnimBg from '@/components/MkAnimBg.vue';
import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const props = defineProps<{
session: string;
callback?: string;
name: string;
icon: string;
permission: string; //
name?: string;
icon?: string;
permission?: string; //
}>();
const _permissions = props.permission ? props.permission.split(',') : [];
const _permissions = computed(() => {
return (props.permission ? props.permission.split(',').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) : []);
});
const state = ref<string | null>(null);
const authRoot = useTemplateRef('authRoot');
async function accept(): Promise<void> {
state.value = 'waiting';
async function onAccept(token: string) {
await misskeyApi('miauth/gen-token', {
session: props.session,
name: props.name,
iconUrl: props.icon,
permission: _permissions,
permission: _permissions.value,
}, token).catch(() => {
authRoot.value?.showUI('failed');
});
state.value = 'accepted';
if (props.callback) {
if (props.callback && props.callback !== '') {
const cbUrl = new URL(props.callback);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url');
cbUrl.searchParams.set('session', props.session);
location.href = cbUrl.href;
location.href = cbUrl.toString();
} else {
authRoot.value?.showUI('success');
}
}
function deny(): void {
state.value = 'denied';
function onDeny() {
authRoot.value?.showUI('denied');
}
function onLogin(res): void {
login(res.i);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: 'MiAuth',
icon: 'ti ti-apps',
@ -100,15 +84,38 @@ definePageMetadata(() => ({
</script>
<style lang="scss" module>
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
.formContainer {
min-height: 100svh;
padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px;
box-sizing: border-box;
display: grid;
place-content: center;
}
.loginMessage {
text-align: center;
margin: 8px 0 24px;
.form {
position: relative;
z-index: 10;
border-radius: var(--radius);
background-color: var(--panel);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
overflow: clip;
max-width: 500px;
width: calc(100vw - 64px);
height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px)));
overflow-y: scroll;
}
.redirectRoot {
padding: 16px;
border-radius: var(--radius);
background-color: var(--bg);
}
.redirectUrl {
font-size: 90%;
padding: 12px;
border-radius: var(--radius);
background-color: var(--panel);
overflow-x: scroll;
}
</style>

View File

@ -4,40 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div v-if="$i">
<div v-if="permissions.length > 0">
<p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p>
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p>
<ul>
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post">
<input name="login_token" class="mk-input-token-hidden" type="hidden" :value="$i.token"/>
<input name="transaction_id" class="mk-input-tr-id-hidden" type="hidden" :value="transactionIdMeta?.content"/>
<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary>{{ i18n.ts.accept }}</MkButton>
</form>
<div>
<MkAnimBg style="position: fixed; top: 0;"/>
<div :class="$style.formContainer">
<div :class="$style.form">
<MkAuthConfirm
ref="authRoot"
:name="name"
:permissions="permissions"
:waitOnDeny="true"
@accept="onAccept"
@deny="onDeny"
/>
</div>
<div v-else>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
<MkSignin @login="onLogin"/>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</div>
</template>
<script lang="ts" setup>
import MkSignin from '@/components/MkSignin.vue';
import MkButton from '@/components/MkButton.vue';
import { $i, login } from '@/account.js';
import { i18n } from '@/i18n.js';
import * as Misskey from 'misskey-js';
import MkAnimBg from '@/components/MkAnimBg.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]');
if (transactionIdMeta) {
@ -45,10 +33,44 @@ if (transactionIdMeta) {
}
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content;
const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? [];
const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? [];
function onLogin(res): void {
login(res.i);
function doPost(token: string, decision: 'accept' | 'deny') {
const form = document.createElement('form');
form.action = '/oauth/decision';
form.method = 'post';
form.acceptCharset = 'utf-8';
const loginToken = document.createElement('input');
loginToken.type = 'hidden';
loginToken.name = 'login_token';
loginToken.value = token;
form.appendChild(loginToken);
const transactionId = document.createElement('input');
transactionId.type = 'hidden';
transactionId.name = 'transaction_id';
transactionId.value = transactionIdMeta?.content ?? '';
form.appendChild(transactionId);
if (decision === 'deny') {
const cancel = document.createElement('input');
cancel.type = 'hidden';
cancel.name = 'cancel';
cancel.value = 'cancel';
form.appendChild(cancel);
}
document.body.appendChild(form);
form.submit();
}
function onAccept(token: string) {
doPost(token, 'accept');
}
function onDeny(token: string) {
doPost(token, 'deny');
}
definePageMetadata(() => ({
@ -58,15 +80,24 @@ definePageMetadata(() => ({
</script>
<style lang="scss" module>
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
.formContainer {
min-height: 100svh;
padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px;
box-sizing: border-box;
display: grid;
place-content: center;
}
.loginMessage {
text-align: center;
margin: 8px 0 24px;
.form {
position: relative;
z-index: 10;
border-radius: var(--radius);
background-color: var(--panel);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
overflow: clip;
max-width: 500px;
width: calc(100vw - 64px);
height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px)));
overflow-y: scroll;
}
</style>

View File

@ -19,13 +19,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { ref, computed } from 'vue';
import type * as Misskey from 'misskey-js';
import FormSuspense from '@/components/form/suspense.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account.js';
import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
@ -74,22 +74,20 @@ async function removeAccount(account) {
}
function addExistingAccount() {
os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: async res => {
await addAccounts(res.id, res.i);
getAccountWithSigninDialog().then((res) => {
if (res != null) {
os.success();
init();
},
}, 'closed');
}
});
}
function createAccount() {
os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: async res => {
await addAccounts(res.id, res.i);
switchAccountWithToken(res.i);
},
}, 'closed');
getAccountWithSignupDialog().then((res) => {
if (res != null) {
switchAccountWithToken(res.token);
}
});
}
async function switchAccount(account: any) {

View File

@ -10,12 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<div :class="$style.name"><MkCondensedLine :minScale="0.5">{{ decoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: decoration.url, angle, flipH, offsetX, offsetY }]" forceShowDecoration/>
<i v-if="decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.lock" class="ti ti-lock"></i>
<i v-if="locked" :class="$style.lock" class="ti ti-lock"></i>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import { computed } from 'vue';
import { signinRequired } from '@/account.js';
const $i = signinRequired();
@ -37,6 +37,8 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'click'): void;
}>();
const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id)));
</script>
<style lang="scss" module>
@ -67,5 +69,6 @@ const emit = defineEmits<{
position: absolute;
bottom: 12px;
right: 12px;
color: var(--warn);
}
</style>

View File

@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.footer" class="_buttonsCenter">
<MkButton v-if="usingIndex != null" primary rounded @click="update"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
<MkButton v-if="usingIndex != null" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
<MkButton v-else :disabled="exceeded" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
<MkButton v-else :disabled="exceeded || locked" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
</div>
</div>
</MkModalWindow>
@ -61,6 +61,7 @@ const props = defineProps<{
id: string;
url: string;
name: string;
roleIdsThatCanBeUsedThisDecoration: string[];
};
}>();
@ -83,6 +84,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0);
const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id)));
const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0);
const flipH = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].flipH : null) ?? false);
const offsetX = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetX : null) ?? 0);

View File

@ -68,20 +68,49 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection>
<MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch>
<FormSection v-if="instance.googleAnalyticsId">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-lock-square"></i></template>
<template #label>{{ i18n.ts.gtagConsentCustomize }}</template>
<div class="_gaps_s">
<MkInfo>{{ i18n.tsx.gtagConsentCustomizeDescription({ host: instance.name ?? host }) }}</MkInfo>
<MkSwitch v-model="gtagConsentAnalytics">
{{ i18n.ts.gtagConsentAnalytics }}
<template #caption>{{ i18n.ts.gtagConsentAnalyticsDescription }}</template>
</MkSwitch>
<MkSwitch v-model="gtagConsentFunctionality">
{{ i18n.ts.gtagConsentFunctionality }}
<template #caption>{{ i18n.ts.gtagConsentFunctionalityDescription }}</template>
</MkSwitch>
<MkSwitch v-model="gtagConsentPersonalization">
{{ i18n.ts.gtagConsentPersonalization }}
<template #caption>{{ i18n.ts.gtagConsentPersonalizationDescription }}</template>
</MkSwitch>
</div>
</MkFolder>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { signinRequired } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import * as os from '@/os.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { miLocalStorage } from '@/local-storage.js';
import { instance } from '@/instance.js';
import { host } from '@/config.js';
import { GtagConsent, GtagConsentParams } from 'vue-gtag';
const $i = signinRequired();
@ -100,6 +129,81 @@ const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNote
const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
const keepCw = computed(defaultStore.makeGetterSetter('keepCw'));
const gaConsentInternal = ref(miLocalStorage.getItem('gaConsent') === 'true');
const gaConsent = computed({
get: () => gaConsentInternal.value,
set: (value: boolean) => {
miLocalStorage.setItem('gaConsent', value ? 'true' : 'false');
gaConsentInternal.value = value;
},
});
const gtagConsentInternal = ref(
(miLocalStorage.getItemAsJson('gtagConsent') as GtagConsentParams) ?? {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted',
},
);
const gtagConsent = computed({
get: () => gtagConsentInternal.value,
set: (value: GtagConsentParams) => {
miLocalStorage.setItemAsJson('gtagConsent', value);
gtagConsentInternal.value = value;
},
});
const gtagConsentAnalytics = computed({
get: () => gtagConsent.value.analytics_storage === 'granted',
set: (value: boolean) => {
gtagConsent.value = {
...gtagConsent.value,
ad_storage: value ? 'granted' : 'denied',
ad_user_data: value ? 'granted' : 'denied',
analytics_storage: value ? 'granted' : 'denied',
};
},
});
const gtagConsentFunctionality = computed({
get: () => gtagConsent.value.functionality_storage === 'granted',
set: (value: boolean) => {
gtagConsent.value = {
...gtagConsent.value,
functionality_storage: value ? 'granted' : 'denied',
};
},
});
const gtagConsentPersonalization = computed({
get: () => gtagConsent.value.personalization_storage === 'granted',
set: (value: boolean) => {
gtagConsent.value = {
...gtagConsent.value,
ad_personalization: value ? 'granted' : 'denied',
personalization_storage: value ? 'granted' : 'denied',
};
},
});
async function reloadAsk() {
const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.reloadToApplySetting,
});
if (canceled) return;
unisonReload();
}
watch(gaConsent, async () => {
await reloadAsk();
});
watch(gtagConsent, async () => {
if (typeof window['gtag'] === 'function') (window['gtag'] as GtagConsent)('consent', 'update', gtagConsent.value);
});
function save() {
misskeyApi('i/update', {
isLocked: !!isLocked.value,

View File

@ -1,100 +1,88 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div v-if="$i && !loading">
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<div :class="$style.buttons">
<MkButton @click="onCancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton primary @click="onAccept">{{ i18n.ts.accept }}</MkButton>
<div>
<MkAnimBg style="position: fixed; top: 0;"/>
<div :class="$style.formContainer">
<div :class="$style.form">
<MkAuthConfirm
ref="authRoot"
:name="name"
@accept="onAccept"
@deny="onDeny"
/>
</div>
</div>
<div v-else-if="$i && loading">
<div>{{ i18n.ts._auth.callback }}</div>
<MkLoading class="loading"/>
<div style="display: none">
<form ref="postBindingForm" method="post" :action="actionUrl" autocomplete="off">
<input v-for="(value, key) in actionContext" :key="key" :name="key" :value="value" type="hidden"/>
</form>
</div>
</div>
<div v-else>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
<MkSignin @login="onLogin"/>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted } from 'vue';
import MkSignin from '@/components/MkSignin.vue';
import MkButton from '@/components/MkButton.vue';
import { $i, login } from '@/account.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkAnimBg from '@/components/MkAnimBg.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
import { nextTick, onMounted, useTemplateRef } from "vue";
import { $i } from "@/account.js";
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:transaction-id"]');
if (transactionIdMeta) {
transactionIdMeta.remove();
}
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:service-name"]')?.content;
const kind = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:kind"]')?.content;
const prompt = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:prompt"]')?.content;
const loading = ref(false);
const postBindingForm = ref<HTMLFormElement | null>(null);
const actionUrl = ref<string | undefined>(undefined);
const actionContext = ref<Record<string, string> | null>(null);
const authRoot = useTemplateRef('authRoot');
function onLogin(res): void {
login(res.i);
}
function onCancel(): void {
if (history.length > 1) history.back();
else location.href = '/';
}
function onAccept(): void {
loading.value = true;
os.promiseDialog(authorize());
}
async function authorize(): Promise<void> {
const res = await fetch(`/sso/${kind}/authorize`, {
async function onAccept(token: string) {
const result = await fetch(`/sso/${kind}/authorize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
transaction_id: transactionIdMeta?.content,
login_token: $i!.token,
login_token: token,
}),
}).then(res => res.json()).catch(() => {
authRoot.value?.showUI('failed');
});
const json = await res.json();
if (json.binding === 'post') {
actionUrl.value = json.action;
actionContext.value = json.context;
if (!result) return;
authRoot.value?.showUI('success');
if (result.binding === 'post') {
const form = document.createElement('form');
form.style.display = 'none';
form.action = result.action;
form.method = 'post';
form.acceptCharset = 'utf-8';
for (const [name, value] of Object.entries(result.context)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value as string;
form.appendChild(input);
}
nextTick(() => {
postBindingForm.value?.submit();
document.body.appendChild(form);
form.submit();
});
} else {
location.href = json.action;
location.href = result.action;
}
}
function onDeny(token: string) {
authRoot.value?.showUI('denied');
}
onMounted(() => {
if ($i && prompt === 'none') {
onAccept();
}
nextTick(() => {
if ($i && prompt === 'none') {
onAccept($i.token);
}
});
});
definePageMetadata(() => ({
@ -104,15 +92,24 @@ definePageMetadata(() => ({
</script>
<style lang="scss" module>
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
.formContainer {
min-height: 100svh;
padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px;
box-sizing: border-box;
display: grid;
place-content: center;
}
.loginMessage {
text-align: center;
margin: 8px 0 24px;
.form {
position: relative;
z-index: 10;
border-radius: var(--radius);
background-color: var(--panel);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
overflow: clip;
max-width: 500px;
width: calc(100vw - 64px);
height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px)));
overflow-y: scroll;
}
</style>

View File

@ -5,7 +5,7 @@
import { ShallowRef } from 'vue';
import { EventEmitter } from 'eventemitter3';
import { IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js';
import { AfterNavigationHook, IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js';
function getMainRouter(): IRouter {
const router = mainRouterHolder;
@ -40,6 +40,10 @@ class MainRouterProxy implements IRouter {
this.supplier = supplier;
}
get options(): { [key: string]: any } {
return {};
}
get current(): Resolved {
return this.supplier().current;
}
@ -60,6 +64,10 @@ class MainRouterProxy implements IRouter {
this.supplier().navHook = value;
}
isReady(): Promise<boolean> {
return this.supplier().isReady();
}
getCurrentKey(): string {
return this.supplier().getCurrentKey();
}
@ -68,6 +76,10 @@ class MainRouterProxy implements IRouter {
return this.supplier().getCurrentPath();
}
afterEach(hook: AfterNavigationHook): AfterNavigationHook | undefined {
return this.supplier().afterEach(hook);
}
push(path: string, flag?: any): void {
this.supplier().push(path, flag);
}

View File

@ -59,7 +59,7 @@ export function createAiScriptEnv(opts) {
}
const actualToken: string|null = token?.value ?? opts.token ?? null;
if (!rateLimiter.hit(ep.value)) return values.ERROR('rate_limited', values.NULL);
return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => {
return misskeyApi(ep.value, utils.valToJs(param), actualToken, undefined, 'aiscript').then(res => {
return utils.jsToVal(res);
}, err => {
return values.ERROR('request_failed', utils.jsToVal(err));

View File

@ -7,8 +7,39 @@ import * as Misskey from 'misskey-js';
import { ref } from 'vue';
import { apiUrl } from '@/config.js';
import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
export const pendingApiRequestsCount = ref(0);
let id: string | null = miLocalStorage.getItem('id');
export function generateClientTransactionId(initiator: string) {
if (id === null) {
id = crypto.randomUUID();
miLocalStorage.setItem('id', id);
}
return `${id}-${initiator}-${crypto.randomUUID()}`;
}
function handleResponse<_ResT>(
resolve: (value: (_ResT | PromiseLike<_ResT>)) => void,
reject: (reason?: any) => void,
): ((value: Response) => (void | PromiseLike<void>)) {
return async (res) => {
if (res.ok && res.status !== 204) {
const body = await res.json();
resolve(body);
} else if (res.status === 204) {
resolve(undefined as _ResT); // void -> undefined
} else {
// エラー応答で JSON.parse に失敗した場合は HTTP ステータスコードとメッセージを返す
const body = await res
.json()
.catch(() => ({ statusCode: res.status, message: res.statusText }));
reject(typeof body.error === 'object' ? body.error : body);
}
};
}
// Implements Misskey.api.ApiClient.request
export function misskeyApi<
ResT = void,
@ -20,6 +51,7 @@ export function misskeyApi<
data: P = {} as any,
token?: string | null | undefined,
signal?: AbortSignal,
initiator: string = 'misskey',
): Promise<_ResT> {
if (endpoint.includes('://')) throw new Error('invalid endpoint');
pendingApiRequestsCount.value++;
@ -41,20 +73,10 @@ export function misskeyApi<
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
'X-Client-Transaction-Id': generateClientTransactionId(initiator),
},
signal,
}).then(async (res) => {
if (res.ok && res.status !== 204) {
const body = await res.json();
resolve(body);
} else if (res.status === 204) {
resolve(undefined as _ResT); // void -> undefined
} else {
// エラー応答で JSON.parse に失敗した場合は HTTP ステータスコードとメッセージを返す
const body = await res.json().catch(() => ({ statusCode: res.status, message: res.statusText }));
reject(typeof body.error === 'object' ? body.error : body);
}
}).catch(reject);
}).then(handleResponse(resolve, reject)).catch(reject);
});
promise.then(onFinally, onFinally);
@ -71,6 +93,7 @@ export function misskeyApiGet<
>(
endpoint: E,
data: P = {} as any,
initiator: string = 'misskey',
): Promise<_ResT> {
pendingApiRequestsCount.value++;
@ -86,17 +109,10 @@ export function misskeyApiGet<
method: 'GET',
credentials: 'omit',
cache: 'default',
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
if (res.status === 200) {
resolve(body);
} else if (res.status === 204) {
resolve(undefined as _ResT); // void -> undefined
} else {
reject(body.error);
}
}).catch(reject);
headers: {
'X-Client-Transaction-Id': generateClientTransactionId(initiator),
},
}).then(handleResponse(resolve, reject)).catch(reject);
});
promise.then(onFinally, onFinally);

View File

@ -0,0 +1,61 @@
/* eslint-disable id-denylist */
// buffering usage report data for 1 minute, then sending it to the server
// POST /api/usage [ { t: number, e: string, i: string, a: string } ]
// t: timestamp
// e: event type
// i: event initiator
// a: action
import { generateClientTransactionId } from '@/scripts/misskey-api.js';
import { miLocalStorage } from '@/local-storage.js';
import { GtagConsentParams } from 'vue-gtag';
import { instance } from '@/instance.js';
export interface UsageReport {
t: number;
e: string;
i: string;
a: string;
}
let disableUsageReport = !instance.googleAnalyticsId;
const usageReportBuffer: UsageReport[] = [];
let usageReportBufferTimer: number | null = null;
export function usageReport(data: UsageReport) {
if (disableUsageReport) return;
if (usageReportBuffer.length > 0) {
const last = usageReportBuffer[usageReportBuffer.length - 1];
if (last.t === data.t && last.e === data.e && last.a === data.a) return;
}
usageReportBuffer.push(data);
if (usageReportBufferTimer === null) {
usageReportBufferTimer = window.setTimeout(() => {
sendUsageReport();
}, 60 * 1000);
}
}
export function sendUsageReport() {
if (usageReportBuffer.length === 0) return;
const data = usageReportBuffer.splice(0, usageReportBuffer.length);
usageReportBufferTimer = null;
if ((miLocalStorage.getItemAsJson('gtagConsent') as GtagConsentParams)?.ad_user_data !== 'granted') {
console.log('Usage report is not sent because the user has not consented to sharing data about ad interactions.');
disableUsageReport = true;
return;
}
window.fetch('/api/usage', {
method: 'POST',
body: JSON.stringify(data),
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
'X-Client-Transaction-Id': generateClientTransactionId('misskey'),
},
});
}

View File

@ -1,6 +1,6 @@
/* eslint-disable */
declare module "*.vue" {
import { defineComponent } from "vue";
import { defineComponent } from 'vue';
const component: ReturnType<typeof defineComponent>;
export default component;
}

View File

@ -26,7 +26,7 @@
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/matter-js": "0.19.7",
"@types/node": "22.7.8",
"@types/node": "22.9.0",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",

View File

@ -5,6 +5,7 @@
```ts
import { EventEmitter } from 'eventemitter3';
import _ReconnectingWebsocket from 'reconnecting-websocket';
// Warning: (ae-forgotten-export) The symbol "components" needs to be exported by the entry point index.d.ts
//
@ -118,6 +119,9 @@ type AdminAnnouncementsUpdateRequest = operations['admin___announcements___updat
// @public (undocumented)
type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminAvatarDecorationsCreateResponse = operations['admin___avatar-decorations___create']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-decorations___delete']['requestBody']['content']['application/json'];
@ -1241,6 +1245,7 @@ declare namespace entities {
AdminAbuseReportResolverDeleteRequest,
AdminAbuseReportResolverUpdateRequest,
AdminAvatarDecorationsCreateRequest,
AdminAvatarDecorationsCreateResponse,
AdminAvatarDecorationsDeleteRequest,
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
@ -3008,7 +3013,7 @@ export class Stream extends EventEmitter<StreamEvents> {
constructor(origin: string, user: {
token: string;
} | null, options?: {
WebSocket?: any;
WebSocket?: _ReconnectingWebsocket.Options['WebSocket'];
});
// (undocumented)
close(): void;

View File

@ -9,14 +9,14 @@
"devDependencies": {
"@misskey-dev/eslint-plugin": "^1.0.0",
"@readme/openapi-parser": "2.6.0",
"@types/node": "22.7.8",
"@types/node": "22.9.0",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"eslint": "8.57.1",
"openapi-types": "12.1.3",
"openapi-typescript": "6.7.6",
"ts-case-convert": "2.1.0",
"tsx": "4.19.1",
"tsx": "4.19.2",
"typescript": "5.6.3"
},
"files": [

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2024.5.0-host.3d",
"version": "2024.5.0-host.4",
"description": "Misskey SDK for JavaScript",
"types": "./built/dts/index.d.ts",
"exports": {
@ -37,9 +37,9 @@
"devDependencies": {
"@microsoft/api-extractor": "7.47.11",
"@misskey-dev/eslint-plugin": "1.0.0",
"@swc/jest": "0.2.36",
"@types/jest": "29.5.13",
"@types/node": "22.7.8",
"@swc/jest": "0.2.37",
"@types/jest": "29.5.14",
"@types/node": "22.9.0",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"eslint": "8.57.1",
@ -58,8 +58,8 @@
"built/dts"
],
"dependencies": {
"@swc/cli": "0.4.0",
"@swc/core": "1.7.39",
"@swc/cli": "0.5.0",
"@swc/core": "1.8.0",
"eventemitter3": "5.0.1",
"reconnecting-websocket": "4.4.0"
}

View File

@ -31,6 +31,7 @@ import type {
AdminAbuseReportResolverDeleteRequest,
AdminAbuseReportResolverUpdateRequest,
AdminAvatarDecorationsCreateRequest,
AdminAvatarDecorationsCreateResponse,
AdminAvatarDecorationsDeleteRequest,
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
@ -607,7 +608,7 @@ export type Endpoints = {
'admin/abuse-report-resolver/list': { req: AdminAbuseReportResolverListRequest; res: AdminAbuseReportResolverListResponse };
'admin/abuse-report-resolver/delete': { req: AdminAbuseReportResolverDeleteRequest; res: EmptyResponse };
'admin/abuse-report-resolver/update': { req: AdminAbuseReportResolverUpdateRequest; res: EmptyResponse };
'admin/avatar-decorations/create': { req: AdminAvatarDecorationsCreateRequest; res: EmptyResponse };
'admin/avatar-decorations/create': { req: AdminAvatarDecorationsCreateRequest; res: AdminAvatarDecorationsCreateResponse };
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };

View File

@ -34,6 +34,7 @@ export type AdminAbuseReportResolverListResponse = operations['admin___abuse-rep
export type AdminAbuseReportResolverDeleteRequest = operations['admin___abuse-report-resolver___delete']['requestBody']['content']['application/json'];
export type AdminAbuseReportResolverUpdateRequest = operations['admin___abuse-report-resolver___update']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsCreateResponse = operations['admin___avatar-decorations___create']['responses']['200']['content']['application/json'];
export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-decorations___delete']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json'];

View File

@ -5095,6 +5095,7 @@ export type components = {
recaptchaSiteKey: string | null;
enableTurnstile: boolean;
turnstileSiteKey: string | null;
googleAnalyticsId: string | null;
swPublickey: string | null;
/** @default /assets/ai.png */
mascotImageUrl: string;
@ -5239,6 +5240,7 @@ export type operations = {
recaptchaSiteKey: string | null;
enableTurnstile: boolean;
turnstileSiteKey: string | null;
googleAnalyticsId: string | null;
swPublickey: string | null;
/** @default /assets/ai.png */
mascotImageUrl: string | null;
@ -6556,9 +6558,22 @@ export type operations = {
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
/** @description OK (with results) */
200: {
content: {
'application/json': {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string | null;
name: string;
description: string;
url: string;
roleIdsThatCanBeUsedThisDecoration: string[];
};
};
};
/** @description Client error */
400: {
@ -10036,6 +10051,7 @@ export type operations = {
enableTurnstile?: boolean;
turnstileSiteKey?: string | null;
turnstileSecretKey?: string | null;
googleAnalyticsId?: string | null;
/** @enum {string} */
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
/** @enum {string} */

View File

@ -34,7 +34,7 @@ export default class Stream extends EventEmitter<StreamEvents> {
private idCounter = 0;
constructor(origin: string, user: { token: string; } | null, options?: {
WebSocket?: any;
WebSocket?: _ReconnectingWebsocket.Options['WebSocket'];
}) {
super();

View File

@ -25,7 +25,7 @@
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/node": "22.7.8",
"@types/node": "22.9.0",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"eslint": "8.57.1",

View File

@ -15,7 +15,7 @@
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/serviceworker": "0.0.101",
"@types/serviceworker": "0.0.102",
"@typescript-eslint/parser": "7.10.0",
"eslint": "8.57.1",
"eslint-plugin-import": "2.31.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
extends: [
'../../packages/shared/.eslintrc.js',
],
};

View File

@ -1,3 +0,0 @@
node_modules
coverage
.idea

View File

@ -1,24 +0,0 @@
{
"name": "changelog-checker",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"run": "vite-node src/index.ts",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@types/mdast": "4.0.3",
"@types/node": "20.10.7",
"@vitest/coverage-v8": "1.1.3",
"mdast-util-to-string": "4.0.0",
"remark": "15.0.1",
"remark-parse": "11.0.0",
"typescript": "5.6.3",
"unified": "11.0.4",
"vite": "5.0.12",
"vite-node": "1.1.3",
"vitest": "1.1.3"
}
}

View File

@ -1,92 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Release } from './parser.js';
export class Result {
public readonly success: boolean;
public readonly message?: string;
private constructor(success: boolean, message?: string) {
this.success = success;
this.message = message;
}
static ofSuccess(): Result {
return new Result(true);
}
static ofFailed(message?: string): Result {
return new Result(false, message);
}
}
/**
* develop -> masterまたはrelease -> masterを想定したパターン
* base側の先頭とhead側で追加された分のリリースより1つ前のバージョンが等価であるかチェックする
*/
export function checkNewRelease(base: Release[], head: Release[]): Result {
const releaseCountDiff = head.length - base.length;
if (releaseCountDiff <= 0) {
return Result.ofFailed('Invalid release count.');
}
const baseLatest = base[0];
const headPrevious = head[releaseCountDiff];
if (baseLatest.releaseName !== headPrevious.releaseName) {
return Result.ofFailed('Contains unexpected releases.');
}
return Result.ofSuccess();
}
/**
* topic -> developまたはtopic -> masterを想定したパターン
* head側の最新リリース配下に書き加えられているかをチェックする
*/
export function checkNewTopic(base: Release[], head: Release[]): Result {
if (head.length !== base.length) {
return Result.ofFailed('Invalid release count.');
}
const headLatest = head[0];
for (let relIdx = 0; relIdx < base.length; relIdx++) {
const baseItem = base[relIdx];
const headItem = head[relIdx];
if (baseItem.releaseName !== headItem.releaseName) {
// リリースの順番が変わってると成立しないのでエラーにする
return Result.ofFailed(`Release is different. base:${baseItem.releaseName}, head:${headItem.releaseName}`);
}
if (baseItem.categories.length !== headItem.categories.length) {
// カテゴリごと書き加えられたパターン
if (headLatest.releaseName !== headItem.releaseName) {
// 最新リリース以外に追記されていた場合
return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`);
}
} else {
// カテゴリ数の変動はないのでリスト項目の数をチェック
for (let catIdx = 0; catIdx < baseItem.categories.length; catIdx++) {
const baseCategory = baseItem.categories[catIdx];
const headCategory = headItem.categories[catIdx];
if (baseCategory.categoryName !== headCategory.categoryName) {
// カテゴリの順番が変わっていると成立しないのでエラーにする
return Result.ofFailed(`Category is different. base:${baseCategory.categoryName}, head:${headCategory.categoryName}`);
}
if (baseCategory.items.length !== headCategory.items.length) {
if (headLatest.releaseName !== headItem.releaseName) {
// 最新リリース以外に追記されていた場合
return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`);
}
}
}
}
}
return Result.ofSuccess();
}

View File

@ -1,37 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as process from 'process';
import * as fs from 'fs';
import { parseChangeLog } from './parser.js';
import { checkNewRelease, checkNewTopic } from './checker.js';
function abort(message?: string) {
if (message) {
console.error(message);
}
process.exit(1);
}
function main() {
if (!fs.existsSync('./CHANGELOG-base.md') || !fs.existsSync('./CHANGELOG-head.md')) {
console.error('CHANGELOG-base.md or CHANGELOG-head.md is missing.');
return;
}
const base = parseChangeLog('./CHANGELOG-base.md');
const head = parseChangeLog('./CHANGELOG-head.md');
const result = (base.length < head.length)
? checkNewRelease(base, head)
: checkNewTopic(base, head);
if (!result.success) {
abort(result.message);
}
}
main();

View File

@ -1,67 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import { Heading, List, Node } from 'mdast';
import { toString } from 'mdast-util-to-string';
export class Release {
public readonly releaseName: string;
public readonly categories: ReleaseCategory[];
constructor(releaseName: string, categories: ReleaseCategory[] = []) {
this.releaseName = releaseName;
this.categories = [...categories];
}
}
export class ReleaseCategory {
public readonly categoryName: string;
public readonly items: string[];
constructor(categoryName: string, items: string[] = []) {
this.categoryName = categoryName;
this.items = [...items];
}
}
function isHeading(node: Node): node is Heading {
return node.type === 'heading';
}
function isList(node: Node): node is List {
return node.type === 'list';
}
export function parseChangeLog(path: string): Release[] {
const input = fs.readFileSync(path, { encoding: 'utf8' });
const processor = unified().use(remarkParse);
const releases: Release[] = [];
const root = processor.parse(input);
let release: Release | null = null;
let category: ReleaseCategory | null = null;
for (const it of root.children) {
if (isHeading(it) && it.depth === 2) {
// リリース
release = new Release(toString(it));
releases.push(release);
} else if (isHeading(it) && it.depth === 3 && release) {
// リリース配下のカテゴリ
category = new ReleaseCategory(toString(it));
release.categories.push(category);
} else if (isList(it) && category) {
for (const listItem of it.children) {
// カテゴリ配下のリスト項目
category.items.push(toString(listItem));
}
}
}
return releases;
}

View File

@ -1,419 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {expect, suite, test} from "vitest";
import {Release, ReleaseCategory} from "../src/parser";
import {checkNewRelease, checkNewTopic} from "../src/checker";
suite('checkNewRelease', () => {
test('headに新しいリリースがある1', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.1'), new Release('2024.12.0')]
const result = checkNewRelease(base, head)
expect(result.success).toBe(true)
})
test('headに新しいリリースがある2', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')]
const result = checkNewRelease(base, head)
expect(result.success).toBe(true)
})
test('リリースの数が同じ', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.0')]
const result = checkNewRelease(base, head)
console.log(result.message)
expect(result.success).toBe(false)
})
test('baseにあるリリースがheadにない', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.2'), new Release('2024.12.1')]
const result = checkNewRelease(base, head)
console.log(result.message)
expect(result.success).toBe(false)
})
})
suite('checkNewTopic', () => {
test('追記なし', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
const result = checkNewTopic(base, head)
expect(result.success).toBe(true)
})
test('最新バージョンにカテゴリを追加したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
const result = checkNewTopic(base, head)
expect(result.success).toBe(true)
})
test('最新バージョンからカテゴリを削除したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
const result = checkNewTopic(base, head)
expect(result.success).toBe(true)
})
test('最新バージョンに追記したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
'feat3',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const result = checkNewTopic(base, head)
expect(result.success).toBe(true)
})
test('最新バージョンから削除したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const result = checkNewTopic(base, head)
expect(result.success).toBe(true)
})
test('古いバージョンにカテゴリを追加したときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat1',
'feat2',
]),
])
]
const result = checkNewTopic(base, head)
console.log(result.message)
expect(result.success).toBe(false)
})
test('古いバージョンからカテゴリを削除したときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
])
]
const result = checkNewTopic(base, head)
console.log(result.message)
expect(result.success).toBe(false)
})
test('古いバージョンに追記したときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
'feat3',
]),
])
]
const result = checkNewTopic(base, head)
console.log(result.message)
expect(result.success).toBe(false)
})
test('古いバージョンから削除したときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
])
]
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
]),
])
]
const result = checkNewTopic(base, head)
console.log(result.message)
expect(result.success).toBe(false)
})
})

View File

@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"typeRoots": [
"./node_modules/@types"
],
"lib": [
"esnext"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"test/**/*"
]
}

View File

@ -1,6 +0,0 @@
import {defineConfig} from 'vite';
const config = defineConfig({});
export default config;