Merge branch 'develop' into pr/ThatOneCalculator/8764
This commit is contained in:
commit
338a076c5f
1318 changed files with 41983 additions and 46058 deletions
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
|
@ -8,7 +8,7 @@ on:
|
|||
pull_request:
|
||||
|
||||
jobs:
|
||||
mocha:
|
||||
jest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
|
@ -47,7 +47,12 @@ jobs:
|
|||
- name: Build
|
||||
run: yarn build
|
||||
- name: Test
|
||||
run: yarn mocha
|
||||
run: yarn jest-and-coverage
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -24,6 +24,9 @@ packages/sw/.yarn/cache
|
|||
cypress/screenshots
|
||||
cypress/videos
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
|
||||
# config
|
||||
/.config/*
|
||||
!/.config/example.yml
|
||||
|
|
|
@ -9,10 +9,17 @@
|
|||
You should also include the user name that made the change.
|
||||
-->
|
||||
|
||||
## 12.x.x (unreleased)
|
||||
## 12.119.0 (2022/09/10)
|
||||
|
||||
### Improvements
|
||||
- Client: Add following badge to user preview popup @nvisser
|
||||
- Client: mobile twitter url can be used as widget @caipira113
|
||||
- Client: Improve clock widget @syuilo
|
||||
|
||||
### Bugfixes
|
||||
- マイグレーションに失敗する問題を修正
|
||||
- Server: 他人の通知を既読にできる可能性があるのを修正 @syuilo
|
||||
- Client: アクセストークン管理画面、アカウント管理画面表示できないのを修正 @futchitwo
|
||||
|
||||
## 12.118.1 (2022/08/08)
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ yarn test
|
|||
|
||||
#### Run specify test
|
||||
```
|
||||
TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" yarn dlx mocha test/foo.ts --require ts-node/register
|
||||
yarn jest -- foo.ts
|
||||
```
|
||||
|
||||
### e2e tests
|
||||
|
|
|
@ -14,7 +14,12 @@ This is the phase we are at now. We need to make a high-maintenance environment
|
|||
- Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986
|
||||
- Add more tests
|
||||
- May need to implement a mechanism that allows for DI
|
||||
- https://github.com/misskey-dev/misskey/pull/9085
|
||||
- Measure coverage
|
||||
- https://github.com/misskey-dev/misskey/pull/9081
|
||||
- Improve documentation
|
||||
- Refactoring
|
||||
- Extract the logic of each endpoint definition into a service and just call it
|
||||
|
||||
## (2) Improve functionality
|
||||
Once Phase 1 is complete and an environment conducive to the development of a stable system is in place, the implementation of new functions can begin gradually.
|
||||
|
|
|
@ -206,6 +206,7 @@ instanceFollowers: "Následovníci na instanci"
|
|||
instanceUsers: "Uživatelé této instance"
|
||||
changePassword: "Změnit heslo"
|
||||
security: "Zabezpečení"
|
||||
retypedNotMatch: "Zadané údaje se neshodují."
|
||||
currentPassword: "Současné heslo"
|
||||
newPassword: "Nové heslo"
|
||||
newPasswordRetype: "Nové heslo (znovu)"
|
||||
|
@ -268,6 +269,7 @@ addFile: "Přidat soubor"
|
|||
emptyFolder: "Tato složka je prázdná"
|
||||
unableToDelete: "Nelze smazat"
|
||||
inputNewFileName: "Zadejte nový název"
|
||||
inputNewFolderName: "Zadejte název nové složky"
|
||||
copyUrl: "Kopírovat URL"
|
||||
rename: "Přejmenovat"
|
||||
avatar: "Avatar"
|
||||
|
@ -310,9 +312,11 @@ pinnedUsers: "Připnutí uživatelé"
|
|||
pinnedNotes: "Připnutá poznámka"
|
||||
hcaptcha: "hCaptcha"
|
||||
enableHcaptcha: "Aktivovat hCaptchu"
|
||||
hcaptchaSiteKey: "Klíč stránky"
|
||||
hcaptchaSecretKey: "Tajný Klíč (Secret Key)"
|
||||
recaptcha: "reCAPTCHA"
|
||||
enableRecaptcha: "Zapnout ReCAPTCHu"
|
||||
recaptchaSiteKey: "Klíč stránky"
|
||||
recaptchaSecretKey: "Tajný Klíč (Secret Key)"
|
||||
antennas: "Antény"
|
||||
manageAntennas: "Spravovat Antény"
|
||||
|
@ -321,6 +325,10 @@ antennaSource: "Zdroj Antény"
|
|||
enableServiceworker: "Povolit ServiceWorker"
|
||||
caseSensitive: "Rozlišuje malá a velká písmena"
|
||||
connectedTo: "Následující účty jsou připojeny"
|
||||
notesAndReplies: "Poznámky a odpovědi"
|
||||
withFiles: "Včetně souborů"
|
||||
popularUsers: "Populární uživatelé"
|
||||
recentlyUpdatedUsers: "Nedávno aktívni uživatelé"
|
||||
popularTags: "Populární tagy"
|
||||
userList: "Seznamy"
|
||||
about: "Informace"
|
||||
|
@ -365,10 +373,14 @@ next: "Další"
|
|||
retype: "Zadejte znovu"
|
||||
noteOf: "{user} poznámky"
|
||||
inviteToGroup: "Pozvat do skupiny"
|
||||
quoteAttached: "Citace"
|
||||
quoteQuestion: "Přiložit jako citaci?"
|
||||
noMessagesYet: "Zatím tu nejsou žádné zprávy"
|
||||
newMessageExists: "Máte novou zprávu"
|
||||
onlyOneFileCanBeAttached: "Ke zprávě můžete přiložit jenom jeden soubor"
|
||||
signinRequired: "Přihlašte se, prosím"
|
||||
invitations: "Pozvat"
|
||||
invitationCode: "Kód pozvánky"
|
||||
checking: "Ověřuji"
|
||||
available: "K dispozici"
|
||||
unavailable: "Není k dispozici"
|
||||
|
@ -382,6 +394,7 @@ passwordMatched: "Hesla se schodují"
|
|||
passwordNotMatched: "Hesla se neschodují"
|
||||
signinWith: "Přihlásit se s {x}"
|
||||
signinFailed: "Nelze se přihlásit. Zkontrolujte prosím své uživatelské jméno a heslo."
|
||||
tapSecurityKey: "Ťukněte na bezpečnostní klíč"
|
||||
or: "Nebo"
|
||||
language: "Jazyk"
|
||||
uiLanguage: "Jazyk uživatelského rozhraní"
|
||||
|
@ -411,9 +424,20 @@ accountSettings: "Nastavení účtu"
|
|||
promotion: "Propagace"
|
||||
promote: "Propagovat"
|
||||
numberOfDays: "Počet dní"
|
||||
objectStorageBaseUrl: "Base URL"
|
||||
objectStorageBucket: "Bucket"
|
||||
objectStoragePrefix: "Předpona"
|
||||
objectStorageEndpoint: "Endpoint"
|
||||
objectStorageRegion: "Región"
|
||||
objectStorageUseSSL: "Použít SSL"
|
||||
deleteAll: "Smazat vše"
|
||||
showFixedPostForm: "Zobrazit formulář pro nové příspěvky nad časovou osou"
|
||||
listen: "Poslouchat"
|
||||
showInPage: "Zobrazit na stránce"
|
||||
popout: "Pop-out"
|
||||
volume: "Hlasitost"
|
||||
masterVolume: "Celková hlasitost"
|
||||
details: "Detaily"
|
||||
chooseEmoji: "Vybrat emotikon"
|
||||
unableToProcess: "Operace nebyla dokončena."
|
||||
recentUsed: "Naposledy použité"
|
||||
|
@ -434,13 +458,20 @@ deleteAllFiles: "Smazat všechny soubory"
|
|||
deleteAllFilesConfirm: "Jste si jistí že chcete smazat všechny soubory?"
|
||||
userSuspended: "Tomuto uživateli byl pozastaven účet."
|
||||
menu: "Menu"
|
||||
divider: "Dělící čára"
|
||||
addItem: "Přidat položku"
|
||||
relays: "Relay"
|
||||
addRelay: "Přidat Relay"
|
||||
inboxUrl: "Inbox URL"
|
||||
deletedNote: "Odstraněné příspěvky"
|
||||
invisibleNote: "Skryté příspěvky"
|
||||
description: "Popis"
|
||||
author: "Autor"
|
||||
manage: "Administrace"
|
||||
width: "Šířka"
|
||||
height: "Výška"
|
||||
large: "Velké"
|
||||
medium: "Střední"
|
||||
small: "Malé"
|
||||
generateAccessToken: "Vygenerovat přístupový token"
|
||||
permission: "Oprávnění"
|
||||
|
@ -458,11 +489,16 @@ smtpPort: "Port"
|
|||
smtpUser: "Uživatelské jméno"
|
||||
smtpPass: "Heslo"
|
||||
smtpSecureInfo: "Toto vypněte pokud používáte STARTTLS"
|
||||
testEmail: "Otestovat doručení emailů"
|
||||
makeActive: "Aktivovat"
|
||||
display: "Zobrazit"
|
||||
copy: "Kopírovat"
|
||||
metrics: "Metriky"
|
||||
overview: "Shrnutí"
|
||||
logs: "Logy"
|
||||
delayed: "Prodleva"
|
||||
database: "Databáze"
|
||||
channel: "Kanály"
|
||||
create: "Vytvořit"
|
||||
notificationSetting: "Nastavení oznámení"
|
||||
useGlobalSetting: "Použít globální nastavení"
|
||||
|
@ -470,79 +506,415 @@ other: "Ostatní"
|
|||
fileIdOrUrl: "ID nebo URL souboru"
|
||||
behavior: "Chování"
|
||||
sample: "Ukázka"
|
||||
send: "Odeslat"
|
||||
openInNewTab: "Otevřít v nové kartě"
|
||||
random: "Náhodně"
|
||||
system: "Systém"
|
||||
desktop: "Plocha"
|
||||
clip: "Oříznout"
|
||||
createNew: "Vytvořit nový"
|
||||
optional: "Volitelné"
|
||||
yes: "Ano"
|
||||
no: "Ne"
|
||||
notSet: "Není nastaveno"
|
||||
emailVerified: "Váš e-mail byl ověřen"
|
||||
contact: "Kontakt"
|
||||
useSystemFont: "Použít výchozí font systému"
|
||||
clips: "Oříznout"
|
||||
experimentalFeatures: "Experimentální funkce"
|
||||
developer: "Vývojář"
|
||||
duplicate: "Duplikovat"
|
||||
left: "Vlevo"
|
||||
center: "Uprostřed"
|
||||
wide: "Široké"
|
||||
narrow: "Úzké"
|
||||
clearCache: "Vyprázdnit mezipaměť"
|
||||
nUsers: "{n} užívatelů"
|
||||
nNotes: "{n} poznámek"
|
||||
myTheme: "Moje vzhledy"
|
||||
backgroundColor: "Pozadí"
|
||||
accentColor: "Akcent"
|
||||
textColor: "Barva textu"
|
||||
saveAs: "Uložit jako…"
|
||||
advanced: "Pokročilé"
|
||||
value: "Hodnota"
|
||||
createdAt: "Vytvořeno"
|
||||
updatedAt: "Upraveno"
|
||||
saveConfirm: "Uložit změny?"
|
||||
deleteConfirm: "Opravdu smazat?"
|
||||
invalidValue: "Neplatná hodnota."
|
||||
registry: "Registr"
|
||||
info: "Informace"
|
||||
unknown: "Neznámý"
|
||||
onlineStatus: "Online status"
|
||||
hideOnlineStatus: "Skrýt Váš online status"
|
||||
hideOnlineStatusDescription: "Skrytí vašeho online stavu může snížit funkcionalitu některých funkcí, například vyhledávání."
|
||||
online: "Online"
|
||||
active: "Aktivní"
|
||||
offline: "Offline"
|
||||
notRecommended: "Nedoporučuje se"
|
||||
botProtection: "Bot ochrana"
|
||||
instanceBlocking: "Blokované instance"
|
||||
selectAccount: "Vybrat účet"
|
||||
switchAccount: "Přepnout účet"
|
||||
enabled: "Zapnuto"
|
||||
disabled: "Vypnuto"
|
||||
quickAction: "Rychlé akce"
|
||||
user: "Uživatelé"
|
||||
administration: "Administrace"
|
||||
accounts: "Účty"
|
||||
switch: "Přepnout"
|
||||
configure: "Nastavit"
|
||||
gallery: "Galerie"
|
||||
recentPosts: "Poslední příspěvky"
|
||||
ads: "Reklamy"
|
||||
memo: "Memo"
|
||||
priority: "Priorita"
|
||||
high: "Vysoká"
|
||||
middle: "Střední"
|
||||
low: "Nízká"
|
||||
emailNotConfiguredWarning: "E-mailová adresa není nastavena."
|
||||
ratio: "Poměr"
|
||||
global: "Globální"
|
||||
sent: "Odeslat"
|
||||
hashtags: "Hashtagy"
|
||||
troubleshooting: "Poradce při potížích"
|
||||
whatIsNew: "Zobrazit změny"
|
||||
translate: "Přeložit"
|
||||
hide: "Skrýt"
|
||||
smartphone: "Telefon"
|
||||
tablet: "Tablet"
|
||||
auto: "Auto"
|
||||
size: "Velikost"
|
||||
numberOfColumn: "Počet sloupců"
|
||||
searchByGoogle: "Vyhledávání"
|
||||
indefinitely: "Navždy"
|
||||
tenMinutes: "10 minut"
|
||||
oneHour: "1 hodina"
|
||||
oneDay: "1 den"
|
||||
oneWeek: "1 týden"
|
||||
reflectMayTakeTime: "Může trvat nějakou dobu, než se projeví změny."
|
||||
cropImage: "Oříznout obrázek"
|
||||
file: "Soubor(ů)"
|
||||
recentNHours: "Posledních {n} hodin"
|
||||
recentNDays: "Posledních {n} dnů"
|
||||
recommended: "Doporučeno"
|
||||
deleteAccount: "Odstranit účet"
|
||||
document: "Dokumentace"
|
||||
logoutConfirm: "Opravdu se chcete odhlásit?"
|
||||
pleaseSelect: "Vybrat možnost"
|
||||
reverse: "Otočit"
|
||||
colored: "Barevné"
|
||||
type: "Typ"
|
||||
speed: "Rychlost"
|
||||
slow: "Pomalá"
|
||||
fast: "Rychlá"
|
||||
account: "Účty"
|
||||
_ad:
|
||||
back: "Zpět"
|
||||
_gallery:
|
||||
my: "Moje galerie"
|
||||
_email:
|
||||
_follow:
|
||||
title: "Máte nového následovníka"
|
||||
_plugin:
|
||||
install: "Instalovat plugin"
|
||||
manage: "Správce pluginů"
|
||||
_preferencesBackups:
|
||||
list: "Vytvořit backup"
|
||||
loadFile: "Načíst ze souboru"
|
||||
save: "Uložit změny"
|
||||
_registry:
|
||||
scope: "Rozsah"
|
||||
key: "Klíč"
|
||||
keys: "Klíče"
|
||||
domain: "Doména"
|
||||
createKey: "Vytvořit klíč"
|
||||
_aboutMisskey:
|
||||
allContributors: "Všichni přispěvatelé"
|
||||
source: "Zdrojový kód"
|
||||
_mfm:
|
||||
mention: "Zmínění"
|
||||
hashtag: "Hashtag"
|
||||
link: "Odkaz"
|
||||
bold: "Tučně"
|
||||
quote: "Citovat"
|
||||
emoji: "Vlastní emoji"
|
||||
search: "Vyhledávání"
|
||||
flip: "Otočit"
|
||||
tada: "Animace (tadá)"
|
||||
blur: "Rozmazání"
|
||||
font: "Font"
|
||||
rainbow: "Duha"
|
||||
_channel:
|
||||
featured: "Trendy"
|
||||
_menuDisplay:
|
||||
top: "Nahoru"
|
||||
hide: "Skrýt"
|
||||
_theme:
|
||||
install: "Nainstalovat vzhled"
|
||||
manage: "Správa vzhledů"
|
||||
code: "Kód vzhledu"
|
||||
description: "Popis"
|
||||
installedThemes: "Nainstalované vzhledy"
|
||||
constant: "Konstanta"
|
||||
defaultValue: "Výchozí hodnota"
|
||||
color: "Barva"
|
||||
key: "Klíč"
|
||||
func: "Funkce "
|
||||
keys:
|
||||
shadow: "Stín"
|
||||
header: "Nadpis"
|
||||
link: "Odkaz"
|
||||
hashtag: "Hashtag"
|
||||
mention: "Zmínění"
|
||||
renote: "Přeposlat"
|
||||
divider: "Dělící čára"
|
||||
_sfx:
|
||||
note: "Poznámky"
|
||||
notification: "Oznámení"
|
||||
chat: "Zprávy"
|
||||
_ago:
|
||||
future: "Budoucí"
|
||||
justNow: "Teď"
|
||||
_time:
|
||||
second: "Sekund"
|
||||
minute: "Minut"
|
||||
hour: "Hodin"
|
||||
_2fa:
|
||||
registerDevice: "Přidat zařízení"
|
||||
registerKey: "Přidat bezpečnostní klíč"
|
||||
_weekday:
|
||||
sunday: "Neděle"
|
||||
monday: "Pondělí"
|
||||
tuesday: "Úterý"
|
||||
wednesday: "Středa"
|
||||
thursday: "Čtvrtek"
|
||||
friday: "Pátek"
|
||||
saturday: "Sobota"
|
||||
_widgets:
|
||||
notifications: "Oznámení"
|
||||
timeline: "Časová osa"
|
||||
calendar: "Kalendář"
|
||||
trends: "Trendy"
|
||||
clock: "Hodiny"
|
||||
rss: "RSS čtečka"
|
||||
activity: "Aktivita"
|
||||
photos: "Fotky"
|
||||
digitalClock: "Digitální hodiny"
|
||||
federation: "Federace"
|
||||
slideshow: "Prezentace"
|
||||
button: "Tlačítko"
|
||||
onlineUsers: "Online uživatelé"
|
||||
jobQueue: "Fronta úloh"
|
||||
aiscript: "AiScript conzole"
|
||||
aichan: "Ai"
|
||||
_cw:
|
||||
hide: "Skrýt"
|
||||
show: "Zobrazit více"
|
||||
_poll:
|
||||
noMore: "Více už přidat nemůžete"
|
||||
infinite: "Nikdy"
|
||||
deadlineDate: "Datum ukončení"
|
||||
deadlineTime: "Hodin"
|
||||
duration: "Trvání"
|
||||
_visibility:
|
||||
home: "Domů"
|
||||
followers: "Sledující"
|
||||
_postForm:
|
||||
_placeholders:
|
||||
f: "Čekám, až něco napíšete..."
|
||||
_profile:
|
||||
name: "Jméno"
|
||||
username: "Uživatelské jméno"
|
||||
description: "O mně"
|
||||
youCanIncludeHashtags: "V popisku o Vás můžete použít i hastagy."
|
||||
metadata: "Doplňující informace"
|
||||
metadataContent: "Obsah"
|
||||
_exportOrImport:
|
||||
allNotes: "Všechny poznámky"
|
||||
followingList: "Sledovaní"
|
||||
muteList: "Ztlumit"
|
||||
blockingList: "Zablokovat"
|
||||
userLists: "Seznamy"
|
||||
_charts:
|
||||
federation: "Federace"
|
||||
apRequest: "Požadavek"
|
||||
usersTotal: "Celkem uživatelů"
|
||||
activeUsers: "Aktivní uživatelé"
|
||||
notesTotal: "Celkový počet poznámek"
|
||||
_timelines:
|
||||
home: "Domů"
|
||||
global: "Globální"
|
||||
_pages:
|
||||
newPage: "Vytvořit novou stránku"
|
||||
editPage: "Upravit stránku"
|
||||
created: "Stránka byla úspěšně vytvořena"
|
||||
updated: "Stránka byla úspěšně aktualizována"
|
||||
deleted: "Stránka byla úspěšně smazána"
|
||||
pageSetting: "Nastavení stránky"
|
||||
invalidNameText: "Ujistěte se že jméno stránky je vyplněno"
|
||||
contents: "Obsah"
|
||||
fontSerif: "Serif"
|
||||
fontSansSerif: "Sans Serif"
|
||||
chooseBlock: "Přidat blok"
|
||||
selectType: "Vyberte typ"
|
||||
contentBlocks: "Obsah"
|
||||
inputBlocks: "Vstup"
|
||||
specialBlocks: "Speciální"
|
||||
blocks:
|
||||
text: "Text"
|
||||
textarea: "Textové pole"
|
||||
section: "Sekce"
|
||||
image: "Obrázky"
|
||||
button: "Tlačítko"
|
||||
if: "Pokud"
|
||||
_if:
|
||||
variable: "Proměnná"
|
||||
_post:
|
||||
text: "Obsah"
|
||||
canvasId: "Canvas ID"
|
||||
_textInput:
|
||||
name: "Jméno proměnné"
|
||||
text: "Titulek"
|
||||
default: "Výchozí hodnota"
|
||||
_textareaInput:
|
||||
name: "Jméno proměnné"
|
||||
text: "Titulek"
|
||||
default: "Výchozí hodnota"
|
||||
_numberInput:
|
||||
name: "Jméno proměnné"
|
||||
text: "Titulek"
|
||||
default: "Výchozí hodnota"
|
||||
canvas: "Canvas"
|
||||
_canvas:
|
||||
id: "Canvas ID"
|
||||
width: "Šířka"
|
||||
height: "Výška"
|
||||
_switch:
|
||||
name: "Jméno proměnné"
|
||||
text: "Titulek"
|
||||
default: "Výchozí hodnota"
|
||||
_counter:
|
||||
name: "Jméno proměnné"
|
||||
text: "Titulek"
|
||||
inc: "Krok"
|
||||
_button:
|
||||
text: "Titulek"
|
||||
colored: "Barevné"
|
||||
_action:
|
||||
_dialog:
|
||||
content: "Obsah"
|
||||
_radioButton:
|
||||
name: "Jméno proměnné"
|
||||
default: "Výchozí hodnota"
|
||||
script:
|
||||
categories:
|
||||
list: "Seznamy"
|
||||
blocks:
|
||||
text: "Text"
|
||||
_strLen:
|
||||
arg1: "Text"
|
||||
_strPick:
|
||||
arg1: "Text"
|
||||
_strReplace:
|
||||
arg1: "Text"
|
||||
_strReverse:
|
||||
arg1: "Text"
|
||||
_join:
|
||||
arg1: "Seznamy"
|
||||
_subtract:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_multiply:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_divide:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_mod:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
round: "Zaokrouhlení zlomku"
|
||||
_round:
|
||||
arg1: "Číselná hodnota"
|
||||
eq: "A a B jsou stejné"
|
||||
_eq:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
notEq: "A a B jsou odlišné"
|
||||
_notEq:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_and:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_or:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_lt:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_gt:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_ltEq:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
_gtEq:
|
||||
arg1: "A"
|
||||
arg2: "B"
|
||||
if: "Větev"
|
||||
_if:
|
||||
arg1: "Pokud"
|
||||
arg2: "Potom"
|
||||
arg3: "Nebo"
|
||||
random: "Náhodně"
|
||||
_random:
|
||||
arg1: "Pravděpodobnost"
|
||||
rannum: "Náhodné číslo"
|
||||
_rannum:
|
||||
arg1: "Minimální hodnota"
|
||||
arg2: "Maximální hodnota"
|
||||
_randomPick:
|
||||
arg1: "Seznamy"
|
||||
_dailyRandom:
|
||||
arg1: "Pravděpodobnost"
|
||||
_dailyRannum:
|
||||
arg1: "Minimální hodnota"
|
||||
arg2: "Maximální hodnota"
|
||||
_dailyRandomPick:
|
||||
arg1: "Seznamy"
|
||||
_seedRandom:
|
||||
arg2: "Pravděpodobnost"
|
||||
_seedRannum:
|
||||
arg2: "Minimální hodnota"
|
||||
arg3: "Maximální hodnota"
|
||||
_seedRandomPick:
|
||||
arg2: "Seznamy"
|
||||
_pick:
|
||||
arg1: "Seznamy"
|
||||
_listLen:
|
||||
arg1: "Seznamy"
|
||||
number: "Číselná hodnota"
|
||||
_stringToNumber:
|
||||
arg1: "Text"
|
||||
_numberToString:
|
||||
arg1: "Číselná hodnota"
|
||||
_splitStrByLine:
|
||||
arg1: "Text"
|
||||
types:
|
||||
string: "Text"
|
||||
number: "Číselná hodnota"
|
||||
array: "Seznamy"
|
||||
_notification:
|
||||
youWereFollowed: "Máte nového následovníka"
|
||||
youWereInvitedToGroup: "Pozvat do skupiny"
|
||||
_types:
|
||||
all: "Vše"
|
||||
follow: "Sledovaní"
|
||||
mention: "Zmínění"
|
||||
reply: "Odpovědi"
|
||||
renote: "Přeposlat"
|
||||
quote: "Citovat"
|
||||
reaction: "Reakce"
|
||||
|
|
|
@ -2,6 +2,7 @@ _lang_: "日本語"
|
|||
|
||||
headlineMisskey: "ノートでつながるネットワーク"
|
||||
introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マイクロブログサービスです。\n「ノート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀"
|
||||
poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォーム<b>Misskey</b>を使ったサービス(Misskeyインスタンスと呼ばれます)のひとつです。"
|
||||
monthAndDay: "{month}月 {day}日"
|
||||
search: "検索"
|
||||
notifications: "通知"
|
||||
|
|
|
@ -562,6 +562,7 @@ author: "작성자"
|
|||
leaveConfirm: "저장하지 않은 변경사항이 있습니다. 취소하시겠습니까?"
|
||||
manage: "관리"
|
||||
plugins: "플러그인"
|
||||
preferencesBackups: "환경설정 백업"
|
||||
deck: "덱"
|
||||
undeck: "덱 해제"
|
||||
useBlurEffectForModal: "모달에 흐림 효과 사용"
|
||||
|
@ -612,7 +613,7 @@ create: "생성"
|
|||
notificationSetting: "알림 설정"
|
||||
notificationSettingDesc: "표시할 알림의 종류를 선택해 주세요."
|
||||
useGlobalSetting: "글로벌 설정을 사용하기"
|
||||
useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용되니다. 비활성화하면 개별적으로 설정할 수 있게 됩니다."
|
||||
useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용됩니다. 비활성화하면 개별적으로 설정할 수 있게 됩니다."
|
||||
other: "기타"
|
||||
regenerateLoginToken: "로그인 토큰을 재생성"
|
||||
regenerateLoginTokenDescription: "로그인할 때 사용되는 내부 토큰을 재생성합니다. 일반적으로 이 작업을 실행할 필요는 없습니다. 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃됩니다."
|
||||
|
@ -941,6 +942,24 @@ _plugin:
|
|||
install: "플러그인 설치"
|
||||
installWarn: "신뢰할 수 없는 플러그인은 설치하지 않는 것이 좋습니다."
|
||||
manage: "플러그인 관리"
|
||||
_preferencesBackups:
|
||||
list: "생성한 백업"
|
||||
saveNew: "새 백업 만들기"
|
||||
loadFile: "파일 가져오기"
|
||||
apply: "이 기기에 적용"
|
||||
save: "현재 설정으로 덮어쓰기"
|
||||
inputName: "백업 이름을 입력하세요"
|
||||
cannotSave: "저장하지 못했습니다"
|
||||
nameAlreadyExists: "\"{name}\" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오."
|
||||
applyConfirm: "\"{name}\" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다."
|
||||
saveConfirm: "{name} 을 덮어쓰시겠습니까?"
|
||||
deleteConfirm: "{name} 을(를) 삭제하시겠습니까?"
|
||||
renameConfirm: "\"{old}\" 백업을 \"{new}\"(으)로 바꾸시겠습니까?"
|
||||
noBackups: "저장된 백업이 없습니다. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다."
|
||||
createdAt: "생성 날짜: {date} {time}"
|
||||
updatedAt: "갱신 날짜: {date} {time}"
|
||||
cannotLoad: "가져오기에 실패했습니다"
|
||||
invalidFile: "파일 형식이 올바르지 않습니다."
|
||||
_registry:
|
||||
scope: "범위"
|
||||
key: "키"
|
||||
|
@ -1258,6 +1277,7 @@ _widgets:
|
|||
activity: "활동"
|
||||
photos: "사진"
|
||||
digitalClock: "디지털 시계"
|
||||
unixClock: "UNIX 시계"
|
||||
federation: "연합"
|
||||
instanceCloud: "인스턴스 구름"
|
||||
postForm: "글 입력란"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
_lang_: "język polski"
|
||||
_lang_: "Polski"
|
||||
headlineMisskey: "Sieć połączona wpisami"
|
||||
introMisskey: "Misskey jest serwisem mikroblogowym typu open source.\nMisskey to opensource'owy serwis mikroblogowy, w którym możesz tworzyć \"notatki\", aby dzielić się tym, co się dzieje i opowiadać wszystkim o sobie.\nMożesz również użyć funkcji \"Reakcje\", aby szybko dodać własne reakcje do notatek innych użytkowników👍.\nOdkrywaj nowy świat🚀!"
|
||||
monthAndDay: "{month}-{day}"
|
||||
|
@ -88,7 +88,7 @@ enterListName: "Nazwa listy"
|
|||
privacy: "Prywatność"
|
||||
makeFollowManuallyApprove: "Prośby o możliwość obserwacji wymagają zatwierdzenia"
|
||||
defaultNoteVisibility: "Domyślna widoczność"
|
||||
follow: "Obserwowani"
|
||||
follow: "Obserwuj"
|
||||
followRequest: "Poproś o możliwość obserwacji"
|
||||
followRequests: "Prośby o możliwość obserwacji"
|
||||
unfollow: "Przestań obserwować"
|
||||
|
@ -127,7 +127,7 @@ unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?"
|
|||
selectList: "Wybierz listę"
|
||||
selectAntenna: "Wybierz Antennę"
|
||||
selectWidget: "Wybierz widżet"
|
||||
editWidgets: "Edytuj widżet"
|
||||
editWidgets: "Edytuj widżety"
|
||||
editWidgetsExit: "Gotowe"
|
||||
customEmojis: "Niestandardowe emoji"
|
||||
emoji: "Emoji"
|
||||
|
@ -142,6 +142,7 @@ flagAsBot: "To konto jest botem"
|
|||
flagAsBotDescription: "Jeżeli ten kanał jest kontrolowany przez jakiś program, ustaw tę opcję. Jeżeli włączona, będzie działać jako flaga informująca innych programistów, aby zapobiegać nieskończonej interakcji z różnymi botami i dostosowywać wewnętrzne systemy Misskey, traktując konto jako bota."
|
||||
flagAsCat: "To konto jest kotem"
|
||||
flagAsCatDescription: "Przełącz tę opcję, aby konto było oznaczone jako kot."
|
||||
flagShowTimelineReplies: "Pokazuj odpowiedzi na osi czasu"
|
||||
autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników, których obserwujesz"
|
||||
addAccount: "Dodaj konto"
|
||||
loginFailed: "Nie udało się zalogować"
|
||||
|
@ -201,6 +202,7 @@ done: "Gotowe"
|
|||
processing: "Przetwarzanie"
|
||||
preview: "Podgląd"
|
||||
default: "Domyślne"
|
||||
defaultValueIs: "Domyślne: {value}"
|
||||
noCustomEmojis: "Brak emoji"
|
||||
noJobs: "Brak zadań"
|
||||
federating: "Federowanie"
|
||||
|
@ -235,6 +237,7 @@ resetAreYouSure: "Czy na pewno chcesz zresetować?"
|
|||
saved: "Zapisano"
|
||||
messaging: "Wiadomości"
|
||||
upload: "Wyślij"
|
||||
keepOriginalUploading: "Zachowaj oryginalny obraz"
|
||||
fromDrive: "Z dysku"
|
||||
fromUrl: "Z adresu URL"
|
||||
uploadFromUrl: "Wyślij z adresu URL"
|
||||
|
@ -377,6 +380,7 @@ administrator: "Admin"
|
|||
token: "Token"
|
||||
twoStepAuthentication: "Uwierzytelnianie dwuskładnikowe"
|
||||
moderator: "Moderator"
|
||||
moderation: "Moderacja"
|
||||
nUsersMentioned: "{n} wspomnianych użytkowników"
|
||||
securityKey: "Klucz bezpieczeństwa"
|
||||
securityKeyName: "Nazwa klucza"
|
||||
|
@ -444,11 +448,13 @@ uiLanguage: "Język wyświetlania UI"
|
|||
groupInvited: "Zaproszony(-a) do grupy"
|
||||
aboutX: "O {x}"
|
||||
useOsNativeEmojis: "Używaj natywnych Emoji systemu"
|
||||
disableDrawer: "Nie używaj menu w stylu szuflady"
|
||||
youHaveNoGroups: "Nie masz żadnych grup"
|
||||
joinOrCreateGroup: "Uzyskaj zaproszenie do dołączenia do grupy lub utwórz własną grupę."
|
||||
noHistory: "Brak historii"
|
||||
signinHistory: "Historia logowania"
|
||||
disableAnimatedMfm: "Wyłącz MFM z animacją"
|
||||
doing: "Przetwarzanie..."
|
||||
category: "Kategoria"
|
||||
tags: "Tagi"
|
||||
docSource: "Źródło tego dokumentu"
|
||||
|
@ -523,6 +529,9 @@ deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?"
|
|||
removeAllFollowingDescription: "Przestań obserwować wszystkie konta z {host}. Wykonaj to, jeżeli instancja już nie istnieje."
|
||||
userSuspended: "To konto zostało zawieszone."
|
||||
userSilenced: "Ten użytkownik został wyciszony."
|
||||
yourAccountSuspendedTitle: "To konto jest zawieszone"
|
||||
yourAccountSuspendedDescription: "To konto zostało zawieszone z powodu złamania regulaminu serwera lub innych podobnych. Skontaktuj się z administratorem, jeśli chciałbyś poznać bardziej szczegółowy powód. Proszę nie zakładać nowego konta."
|
||||
menu: "Menu"
|
||||
divider: "Rozdzielacz"
|
||||
addItem: "Dodaj element"
|
||||
relays: "Przekaźniki"
|
||||
|
@ -541,7 +550,7 @@ disablePlayer: "Zamknij odtwarzacz wideo"
|
|||
expandTweet: "Rozwiń tweet"
|
||||
themeEditor: "Edytor motywu"
|
||||
description: "Opis"
|
||||
describeFile: "dodaj podpis"
|
||||
describeFile: "Dodaj podpis"
|
||||
enterFileDescription: "Wprowadź napis"
|
||||
author: "Autor"
|
||||
leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?"
|
||||
|
@ -578,6 +587,7 @@ emptyToDisableSmtpAuth: "Pozostaw adres e-mail i hasło puste, aby wyłączyć w
|
|||
smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS"
|
||||
testEmail: "Przetestuj dostarczanie wiadomości e-mail"
|
||||
wordMute: "Wyciszenie słowa"
|
||||
instanceMute: "Wyciszone instancje"
|
||||
userSaysSomething: "{name} powiedział(-a) coś"
|
||||
makeActive: "Aktywuj"
|
||||
display: "Wyświetlanie"
|
||||
|
@ -607,6 +617,7 @@ fillAbuseReportDescription: "Wypełnij szczegóły zgłoszenia. Jeżeli dotyczy
|
|||
abuseReported: "Twoje zgłoszenie zostało wysłane. Dziękujemy."
|
||||
reporteeOrigin: "Pochodzenie zgłoszonego"
|
||||
reporterOrigin: "Pochodzenie zgłaszającego"
|
||||
forwardReport: "Przekaż zgłoszenie do innej instancji"
|
||||
send: "Wyślij"
|
||||
abuseMarkAsResolved: "Oznacz zgłoszenie jako rozwiązane"
|
||||
openInNewTab: "Otwórz w nowej karcie"
|
||||
|
@ -619,8 +630,12 @@ random: "Losowe"
|
|||
system: "System"
|
||||
switchUi: "Przełącz interfejs użytkownika"
|
||||
desktop: "Pulpit"
|
||||
clip: "Klip"
|
||||
createNew: "Utwórz nowy"
|
||||
optional: "Nieobowiązkowe"
|
||||
createNewClip: "Utwórz nowy klip"
|
||||
unclip: "Odczep"
|
||||
confirmToUnclipAlreadyClippedNote: "Ten wpis jest już częścią klipu \"{name}\". Czy chcesz ją usunąć z tego klipu?"
|
||||
public: "Publiczny"
|
||||
i18nInfo: "Misskey jest tłumaczone na wiele języków przez wolontariuszy. Możesz pomóc na {link}."
|
||||
manageAccessTokens: "Zarządzaj tokenami dostępu"
|
||||
|
@ -654,6 +669,7 @@ pageLikesCount: "Liczba otrzymanych polubień stron"
|
|||
pageLikedCount: "Liczba polubionych stron"
|
||||
contact: "Kontakt"
|
||||
useSystemFont: "Używaj domyślnej czcionki systemu"
|
||||
clips: "Klipy"
|
||||
experimentalFeatures: "Eksperymentalne funkcje"
|
||||
developer: "Programista"
|
||||
makeExplorable: "Pokazuj konto na stronie „Eksploruj”"
|
||||
|
@ -725,6 +741,7 @@ notRecommended: "Nie zalecane"
|
|||
botProtection: "Zabezpieczenie przed botami"
|
||||
instanceBlocking: "Zablokowane instancje"
|
||||
selectAccount: "Wybierz konto"
|
||||
switchAccount: "Przełącz konto"
|
||||
enabled: "Właczono"
|
||||
disabled: "Wyłączono"
|
||||
quickAction: "Szybkie działania"
|
||||
|
@ -756,22 +773,103 @@ global: "Globalna"
|
|||
squareAvatars: "Wyświetlaj kwadratowe awatary"
|
||||
sent: "Wyślij"
|
||||
received: "Otrzymane"
|
||||
searchResult: "Wyniki wyszukiwania"
|
||||
hashtags: "Hashtag"
|
||||
troubleshooting: "Rozwiązywanie problemów"
|
||||
useBlurEffect: "Użyj efektów rozmycia w UI"
|
||||
learnMore: "Dowiedz się więcej"
|
||||
misskeyUpdated: "Misskey zostało zaktualizowane!"
|
||||
whatIsNew: "Pokaż zmiany"
|
||||
translate: "Przetłumacz"
|
||||
translatedFrom: "Przetłumaczone z {x}"
|
||||
accountDeletionInProgress: "Trwa usuwanie konta"
|
||||
usernameInfo: "Nazwa, która identyfikuje Twoje konto spośród innych na tym serwerze. Możesz użyć alfabetu (a~z, A~Z), cyfr (0~9) lub podkreślników (_). Nazwy użytkownika nie mogą być później zmieniane."
|
||||
aiChanMode: "Tryb Ai"
|
||||
keepCw: "Zostaw ostrzeżenia o zawartości"
|
||||
pubSub: "Konta Pub/Sub"
|
||||
resolved: "Rozwiązane"
|
||||
unresolved: "Nierozwiązane"
|
||||
breakFollow: "Usuń obserwującego"
|
||||
itsOn: "Włączone"
|
||||
itsOff: "Wyłączone"
|
||||
unread: "Nieodczytane"
|
||||
filter: "Filtr"
|
||||
controlPanel: "Panel sterowania"
|
||||
manageAccounts: "Zarządzaj kontami"
|
||||
makeReactionsPublic: "Ustawić historię reakcji jako publiczną"
|
||||
makeReactionsPublicDescription: "To spowoduje, że lista wszystkich Twoich dotychczasowych reakcji będzie publicznie widoczna."
|
||||
classic: "Klasyczny"
|
||||
muteThread: "Wycisz wątek"
|
||||
unmuteThread: "Wyłącz wyciszenie wątku"
|
||||
ffVisibility: "Widoczność obserwowanych/obserwujących"
|
||||
ffVisibilityDescription: "Pozwala skonfigurować, kto może zobaczyć, kogo obserwujesz i kto Cię obserwuje."
|
||||
continueThread: "Pokaż kontynuację wątku"
|
||||
deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?"
|
||||
incorrectPassword: "Nieprawidłowe hasło."
|
||||
voteConfirm: "Potwierdzić swój głos na \"{choice}\"?"
|
||||
hide: "Ukryj"
|
||||
leaveGroup: "Opuść grupę"
|
||||
leaveGroupConfirm: "Czy na pewno chcesz opuścić \"{name}\"?"
|
||||
useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych"
|
||||
welcomeBackWithName: "Witaj z powrotem, {name}"
|
||||
clickToFinishEmailVerification: "Kliknij [{ok}], aby zakończyć weryfikację e-mail."
|
||||
overridedDeviceKind: "Typ urządzenia"
|
||||
smartphone: "Smartfon"
|
||||
tablet: "Tablet"
|
||||
auto: "Automatycznie"
|
||||
size: "Rozmiar"
|
||||
numberOfColumn: "Liczba kolumn"
|
||||
searchByGoogle: "Szukaj"
|
||||
indefinitely: "Nigdy"
|
||||
file: "Pliki"
|
||||
logoutConfirm: "Czy na pewno chcesz się wylogować?"
|
||||
lastActiveDate: "Ostatnio użyte w"
|
||||
statusbar: "Pasek stanu"
|
||||
pleaseSelect: "Wybierz opcję"
|
||||
reverse: "Odwróć"
|
||||
colored: "Kolorowe"
|
||||
label: "Etykieta"
|
||||
type: "Typ"
|
||||
speed: "Prędkość"
|
||||
localOnly: "Lokalne tylko"
|
||||
failedToUpload: "Przesyłanie nie powiodło się"
|
||||
cannotUploadBecauseInappropriate: "Nie można przesłać tego pliku, ponieważ jego części zostały wykryte jako potencjalnie nieodpowiednie."
|
||||
cannotUploadBecauseNoFreeSpace: "Przesyłanie nie powiodło się z powodu braku miejsca na dysku."
|
||||
beta: "Beta"
|
||||
enableAutoSensitive: "Automatyczne oznaczanie NSFW"
|
||||
enableAutoSensitiveDescription: "Umożliwia automatyczne wykrywanie i oznaczanie zawartości NSFW za pomocą uczenia maszynowego. Nawet jeśli ta opcja jest wyłączona, może być włączona w całej instancji."
|
||||
navbar: "Pasek nawigacyjny"
|
||||
account: "Konta"
|
||||
move: "Przenieś"
|
||||
_sensitiveMediaDetection:
|
||||
description: "Zmniejsza wysiłek związany z moderacją serwera dzięki automatycznemu rozpoznawaniu zawartości NSFW za pomocą uczenia maszynowego. To nieznacznie zwiększy obciążenie serwera."
|
||||
setSensitiveFlagAutomatically: "Oznacz jako NSFW"
|
||||
_emailUnavailable:
|
||||
used: "Ten adres e-mail jest już używany"
|
||||
format: "Format tego adresu e-mail jest nieprawidłowy"
|
||||
disposable: "Nie można używać jednorazowych adresów e-mail"
|
||||
mx: "Ten serwer e-mail jest nieprawidłowy"
|
||||
smtp: "Ten serwer e-mail nie odpowiada"
|
||||
_ffVisibility:
|
||||
public: "Publikuj"
|
||||
public: "Publiczne"
|
||||
followers: "Widoczne tylko dla obserwujących"
|
||||
private: "Prywatne"
|
||||
_signup:
|
||||
almostThere: "Prawie na miejscu"
|
||||
emailAddressInfo: "Podaj swój adres e-mail. Nie zostanie on upubliczniony."
|
||||
emailSent: "E-mail z potwierdzeniem został wysłany na Twój adres e-mail ({email}). Kliknij dołączony link, aby dokończyć tworzenie konta."
|
||||
_accountDelete:
|
||||
accountDelete: "Usuń konto"
|
||||
mayTakeTime: "Ponieważ usuwanie konta jest procesem wymagającym dużej ilości zasobów, jego ukończenie może zająć trochę czasu, w zależności od ilości utworzonej zawartości i liczby przesłanych plików."
|
||||
sendEmail: "Po zakończeniu usuwania konta na adres e-mail zarejestrowany na tym koncie zostanie wysłana wiadomość e-mail."
|
||||
requestAccountDelete: "Poproś o usunięcie konta"
|
||||
started: "Usuwanie się rozpoczęło."
|
||||
inProgress: "Usuwanie jest obecnie w toku"
|
||||
_ad:
|
||||
back: "Wróć"
|
||||
reduceFrequencyOfThisAd: "Pokazuj tę reklamę rzadziej"
|
||||
_forgotPassword:
|
||||
enterEmail: "Wpisz adres e-mail użyty do rejestracji. Zostanie do niego wysłany link, za pomocą którego możesz zresetować hasło."
|
||||
ifNoEmail: "Jeżeli nie podano adresu e-mail podczas rejestracji, skontaktuj się z administratorem zamiast tego."
|
||||
contactAdmin: "Jeżeli Twoja instancja nie obsługuje adresów e-mail, skontaktuj się zamiast tego z administratorem, aby zresetować hasło."
|
||||
_gallery:
|
||||
|
@ -788,6 +886,23 @@ _plugin:
|
|||
install: "Zainstaluj wtyczki"
|
||||
installWarn: "Nie instaluj niezaufanych wtyczek."
|
||||
manage: "Zarządzanie wtyczkami"
|
||||
_preferencesBackups:
|
||||
list: "Utworzone kopie zapasowe"
|
||||
saveNew: "Zapisz nową kopię zapasową"
|
||||
loadFile: "Załaduj z pliku"
|
||||
apply: "Zastosuj do tego urządzenia"
|
||||
save: "Zapisz zmiany"
|
||||
inputName: "Proszę podać nazwę dla tej kopii zapasowej"
|
||||
cannotSave: "Zapisanie nie powiodło się"
|
||||
nameAlreadyExists: "Kopia zapasowa o nazwie \"{name}\" już istnieje. Proszę podać inną nazwę."
|
||||
applyConfirm: "Czy na pewno chcesz zastosować kopię zapasową \"{name}\" na tym urządzeniu? Istniejące ustawienia tego urządzenia zostaną nadpisane."
|
||||
saveConfirm: "Zapisać kopię zapasową jako {name}?"
|
||||
deleteConfirm: "Usunąć kopię zapasową {name}?"
|
||||
renameConfirm: "Zmienić nazwę kopii zapasowej z \"{old}\" na \"{new}\"?"
|
||||
createdAt: "Utworzony w: {date} {time}"
|
||||
updatedAt: "Zaktualizowano w: {date} {time}"
|
||||
cannotLoad: "Ładowanie nie powiodło się"
|
||||
invalidFile: "Nieprawidłowy format pliku"
|
||||
_registry:
|
||||
scope: "Zakres"
|
||||
key: "Klucz"
|
||||
|
@ -822,10 +937,13 @@ _mfm:
|
|||
bold: "Pogrubienie"
|
||||
boldDescription: "Wyróżnia litery pogrubiając je."
|
||||
small: "Małe"
|
||||
smallDescription: "Wyświetla treść jako małą i cienką."
|
||||
center: "Wyśrodkowanie"
|
||||
centerDescription: "Wyśrodkowuje zawartość."
|
||||
inlineCode: "Kod (w wierszu)"
|
||||
blockCode: "Kod (blok)"
|
||||
blockCodeDescription: "Wyświetla kod z podświetlaną składnią składający się z wielu linii."
|
||||
blockMath: "Matematyka (Blok)"
|
||||
quote: "Cytuj"
|
||||
quoteDescription: "Wyświetla treść jako cytat."
|
||||
emoji: "Niestandardowe emoji"
|
||||
|
@ -834,6 +952,20 @@ _mfm:
|
|||
searchDescription: "Wyświetla pole wyszukiwania z wcześniej wpisanym tekstem."
|
||||
flip: "Odwróć"
|
||||
flipDescription: "Przerzuca treść poziomo lub pionowo."
|
||||
jelly: "Animacja (Galaretka)"
|
||||
jellyDescription: "Nadaje treści galaretowatą animację."
|
||||
tada: "Animation (Tada)"
|
||||
tadaDescription: "Nadaje treści animację podobną do \"Tada!\"."
|
||||
jump: "Animacja (Skok)"
|
||||
jumpDescription: "Nadaje treści animację skakania."
|
||||
bounce: "Animacja (Odbijanie)"
|
||||
bounceDescription: "Nadaje treści animację odbijania się."
|
||||
shake: "Animacja (Wstrząsanie)"
|
||||
shakeDescription: "Nadaje treści animację wstrząsania."
|
||||
twitch: "Animacja (Drganie)"
|
||||
twitchDescription: "Nadaje treści mocno drgającą animację."
|
||||
spin: "Animacja (Obrót)"
|
||||
spinDescription: "Nadaje treści animację obracania."
|
||||
x2: "Duże"
|
||||
x2Description: "Czyni treść większą."
|
||||
x3: "Bardzo duże"
|
||||
|
@ -841,9 +973,17 @@ _mfm:
|
|||
x4: "Ogromne"
|
||||
x4Description: "Czyni treść jeszcze większą niż jeszcze większa."
|
||||
blur: "Rozmycie"
|
||||
blurDescription: "Rozmywa treść. Zostanie wyraźnie wyświetlona po najechaniu."
|
||||
font: "Czcionka"
|
||||
fontDescription: "Wybiera czcionkę do wyświetlania treści."
|
||||
rainbow: "Tęcza"
|
||||
rainbowDescription: "Sprawia, że zawartość pojawia się w kolorach tęczy."
|
||||
sparkle: "Blask"
|
||||
sparkleDescription: "Nadaje zawartości efekt lśniącego brokatu."
|
||||
rotate: "Obróć"
|
||||
rotateDescription: "Obraca zawartość o określony kąt."
|
||||
plain: "Zwyczajny"
|
||||
plainDescription: "Wyłącza efekty wszystkich MFM zawartych w tym efekcie MFM."
|
||||
_instanceTicker:
|
||||
none: "Nigdy nie pokazuj"
|
||||
remote: "Pokaż dla zdalnych użytkowników"
|
||||
|
@ -863,6 +1003,7 @@ _channel:
|
|||
usersCount: "{n} uczestnicy"
|
||||
notesCount: "{n} wpisy"
|
||||
_menuDisplay:
|
||||
top: "Góra"
|
||||
hide: "Ukryj"
|
||||
_wordMute:
|
||||
muteWords: "Słowo do wyciszenia"
|
||||
|
@ -870,6 +1011,9 @@ _wordMute:
|
|||
soft: "Łagodny"
|
||||
hard: "Twardy"
|
||||
mutedNotes: "Wyciszone wpisy"
|
||||
_instanceMute:
|
||||
title: "Ukrywa wpisy z wymienionych instancji."
|
||||
heading: "Lista instancji do wyciszenia"
|
||||
_theme:
|
||||
explore: "Przeglądaj motywy"
|
||||
install: "Zainstaluj motyw"
|
||||
|
@ -950,6 +1094,7 @@ _sfx:
|
|||
notification: "Powiadomienia"
|
||||
chat: "Wiadomości"
|
||||
chatBg: "Rozmowy (tło)"
|
||||
antenna: "Anteny"
|
||||
channel: "Powiadomienia kanału"
|
||||
_ago:
|
||||
future: "W przyszłości"
|
||||
|
@ -969,12 +1114,30 @@ _time:
|
|||
_tutorial:
|
||||
title: "Jak korzystać z Misskey"
|
||||
step1_1: "Witaj!"
|
||||
step1_2: "Ta strona nazywa się „oś czasu”. Pokazuje chronologicznie uporządkowane wpisy osób, które „śledzisz”."
|
||||
step1_3: "Twoja oś czasu jest jeszcze pusta, ponieważ nie opublikowałeś(-aś) jeszcze żadnych wpisów i nie obserwujesz jeszcze nikogo."
|
||||
step2_1: "Ukończmy konfigurację profilu zanim utworzymy wpis lub zaczniemy kogoś obserwować."
|
||||
step2_2: "Podanie pewnych informacji o tym, kim jesteś, ułatwi innym określenie, czy chcą widzieć Twoje wpisy lub Cię obserwować."
|
||||
step3_1: "Zakończyłeś(-aś) konfigurację profilu?"
|
||||
step3_2: "Następnie spróbujmy opublikować wpis. Możesz to zrobić, naciskając przycisk z ikoną ołówka na ekranie."
|
||||
step3_3: "Wypełnij pole i kliknij przycisk w prawym górnym rogu by wysłać post."
|
||||
step3_4: "Nie masz nic do powiedzenia? Spróbuj \"ustawiam swój misskey\"!"
|
||||
step4_1: "Zakończyłeś publikowanie pierwszego wpisu?"
|
||||
step4_2: "Hurra! Teraz Twój pierwszy wpis powinien być wyświetlany na Twojej osi czasu."
|
||||
step5_1: "Teraz spróbujmy ożywić Twoją oś czasu, przez zaobserwowanie innych ludzi."
|
||||
step5_2: "{featured} pokaże Ci popularne wpisy na tej instancji. {explore} pozwoli Ci znaleźć popularnych użytkowników. Spróbuj znaleźć tam osoby, które chcesz obserwować!"
|
||||
step5_3: "Aby obserwować innych użytkowników, kliknij ich ikonę i naciśnij przycisk \"Obserwuj\" na ich profilu."
|
||||
step5_4: "Jeśli inny użytkownik ma ikonę kłódki obok swojej nazwy, może minąć trochę czasu, zanim ten użytkownik ręcznie zatwierdzi Twoją prośbę o obserwowanie."
|
||||
step6_1: "Powinieneś teraz widzieć wpisy innych użytkowników na swojej osi czasu."
|
||||
step6_2: "Możesz także umieścić „reakcje” na wpisach innych osób, aby szybko na nie odpowiedzieć."
|
||||
step6_3: "Aby dodać \"reakcję\", naciśnij znak \"+\" na wpisie innego użytkownika i wybierz emotikonę, którą chcesz zareagować."
|
||||
step7_1: "Gratulacje! Ukończyłeś podstawowy samouczek Misskey."
|
||||
step7_2: "Jeśli chcesz dowiedzieć się więcej o Misskey, wypróbuj sekcję {help}."
|
||||
step7_3: "A teraz powodzenia i baw się dobrze z Misskey! 🚀"
|
||||
_2fa:
|
||||
alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego."
|
||||
registerDevice: "Zarejestruj nowe urządzenie"
|
||||
registerKey: "Zarejestruj klucz bezpieczeństwa"
|
||||
step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b}) na swoim urządzeniu."
|
||||
step2: "Następnie, zeskanuje kod QR z ekranu."
|
||||
step3: "Wprowadź token podany w aplikacji, aby ukończyć konfigurację."
|
||||
|
@ -990,6 +1153,7 @@ _permissions:
|
|||
"write:favorites": "Edycja Twojej listy ulubionych."
|
||||
"read:following": "Wyświetlanie informacji o obserwowanych"
|
||||
"write:following": "Obserwowanie lub cofanie obserwacji innych kont"
|
||||
"read:messaging": "Zobacz swoje czaty"
|
||||
"read:mutes": "Wyświetlanie listy osób, które wyciszyłeś(-aś)"
|
||||
"write:mutes": "Edycja listy osób, które wyciszyłeś(-aś)"
|
||||
"read:notifications": "Wyświetlanie powiadomień"
|
||||
|
@ -1003,6 +1167,10 @@ _permissions:
|
|||
"write:page-likes": "Edycja polubień na stronach"
|
||||
"read:user-groups": "Wyświetlanie grup użytkownika"
|
||||
"write:user-groups": "Edycja lub usuwanie grup użytkownika"
|
||||
"read:channels": "Zobacz swoje kanały"
|
||||
"write:channels": "Edytuj swoje kanały"
|
||||
"read:gallery": "Zobacz swoją galerię"
|
||||
"write:gallery": "Edytuj swoją galerię"
|
||||
_auth:
|
||||
shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?"
|
||||
permissionAsk: "Ta aplikacja wymaga następujących uprawnień:"
|
||||
|
@ -1021,12 +1189,21 @@ _widgets:
|
|||
calendar: "Kalendarz"
|
||||
trends: "Na czasie"
|
||||
clock: "Zegar"
|
||||
rss: "Czytnik RSS"
|
||||
activity: "Aktywność"
|
||||
photos: "Zdjęcia"
|
||||
digitalClock: "Zegar cyfrowy"
|
||||
unixClock: "Zegar UNIX"
|
||||
federation: "Federacja"
|
||||
postForm: "Utwórz wpis"
|
||||
instanceCloud: "Chmura instancji"
|
||||
postForm: "Formularz tworzenia wpisu"
|
||||
slideshow: "Pokaz slajdów"
|
||||
button: "Przycisk"
|
||||
onlineUsers: "Użytkownicy online"
|
||||
jobQueue: "Kolejka zadań"
|
||||
serverMetric: "Metryka serwera"
|
||||
aiscript: "Konsola AiScript"
|
||||
aichan: "Ai"
|
||||
_cw:
|
||||
hide: "Ukryj"
|
||||
show: "Załaduj więcej"
|
||||
|
@ -1393,9 +1570,11 @@ _notification:
|
|||
youReceivedFollowRequest: "Otrzymałeś(-aś) prośbę o możliwość obserwacji"
|
||||
yourFollowRequestAccepted: "Twoja prośba o możliwość obserwacji została przyjęta"
|
||||
youWereInvitedToGroup: "Zaproszony(-a) do grupy"
|
||||
pollEnded: "Wyniki ankiety stały się dostępne"
|
||||
emptyPushNotificationMessage: "Powiadomienia push zostały zaktualizowane"
|
||||
_types:
|
||||
all: "Wszystkie"
|
||||
follow: "Obserwowani"
|
||||
follow: "Nowi obserwujący"
|
||||
mention: "Wspomnij"
|
||||
reply: "Odpowiedzi"
|
||||
renote: "Udostępnij"
|
||||
|
@ -1407,12 +1586,14 @@ _notification:
|
|||
groupInvited: "Zaproszono do grup"
|
||||
app: "Powiadomienia z aplikacji"
|
||||
_actions:
|
||||
followBack: "zaobserwował cię z powrotem"
|
||||
reply: "Odpowiedz"
|
||||
renote: "Udostępnij"
|
||||
_deck:
|
||||
alwaysShowMainColumn: "Zawsze pokazuj główną kolumnę"
|
||||
columnAlign: "Wyrównaj kolumny"
|
||||
addColumn: "Dodaj kolumnę"
|
||||
configureColumn: "Ustawienia kolumny"
|
||||
swapLeft: "Przesuń w lewo"
|
||||
swapRight: "Przesuń w prawo"
|
||||
swapUp: "Zamień z powyższym"
|
||||
|
@ -1420,6 +1601,9 @@ _deck:
|
|||
stackLeft: "Przypnij do lewej"
|
||||
popRight: "Odepnij w prawo"
|
||||
profile: "Profil"
|
||||
newProfile: "Nowy profil"
|
||||
deleteProfile: "Usuń profil"
|
||||
widgetsIntroduction: "Wybierz \"Edytuj widżety\" w menu kolumny i dodaj widżet."
|
||||
_columns:
|
||||
main: "Główna"
|
||||
widgets: "Widżety"
|
||||
|
|
|
@ -562,6 +562,7 @@ author: "Автор"
|
|||
leaveConfirm: "Вы не сохранили изменения. Хотите выйти и потерять их?"
|
||||
manage: "Управление"
|
||||
plugins: "Расширения"
|
||||
preferencesBackups: "Резервная копия"
|
||||
deck: "Пульт"
|
||||
undeck: "Покинуть пульт"
|
||||
useBlurEffectForModal: "Размывка под формой поверх всего"
|
||||
|
|
|
@ -562,6 +562,7 @@ author: "ผู้เขียน"
|
|||
leaveConfirm: "คุณมีการเปลี่ยนแปลงที่ไม่ได้บันทึกนะ นายต้องการทิ้งการเปลี่ยนแปลงเหล่านั้นหรอ?"
|
||||
manage: "การจัดการ"
|
||||
plugins: "ปลั๊กอิน"
|
||||
preferencesBackups: "ตั้งค่าการสำรองข้อมูล"
|
||||
deck: "เด็ค"
|
||||
undeck: "ออกจากเด็ค"
|
||||
useBlurEffectForModal: "ใช้เอฟเฟกต์เบลอสำหรับโมดอล"
|
||||
|
@ -598,7 +599,7 @@ wordMute: "ปิดเสียงคำ"
|
|||
regexpError: "ข้อผิดพลาดของนิพจน์ทั่วไป"
|
||||
regexpErrorDescription: "เกิดข้อผิดพลาดในนิพจน์ทั่วไปในบรรทัดที่ {line} ของการปิดเสียงคำ {tab} ของคุณ:"
|
||||
instanceMute: "ปิดเสียง อินสแตนซ์"
|
||||
userSaysSomething: "{ชื่อ} พูดอะไรบางอย่าง"
|
||||
userSaysSomething: "{name} พูดอะไรบางอย่าง"
|
||||
makeActive: "เปิดใช้งาน"
|
||||
display: "แสดงผล"
|
||||
copy: "คัดลอก"
|
||||
|
@ -913,13 +914,89 @@ _ffVisibility:
|
|||
_signup:
|
||||
almostThere: "เกือบจะมี"
|
||||
emailAddressInfo: "โปรดกรอกอีเมลของคุณ มันจะไม่เปิดเผยต่อสาธารณะ"
|
||||
emailSent: "เราได้ส่งอีเมลยืนยันไปยังที่อยู่อีเมลของคุณแล้วนะ ({email}) โปรดคลิกลิงก์ที่รวมไว้เพื่อสร้างบัญชีให้เสร็จสิ้น"
|
||||
_accountDelete:
|
||||
accountDelete: "ลบบัญชีผู้ใช้"
|
||||
mayTakeTime: "เนื่องจากการลบบัญชีนี้จะเป็นกระบวนการที่ต้องใช้ทรัพยากรมาก จึงอาจจะต้องใช้เวลาสักครู่ถึงจะเสร็จสมบูรณ์ ทั้งนี้ขึ้นอยู่กับจำนวนเนื้อหาที่คุณสร้างและจำนวนไฟล์ที่คุณอัปโหลดนะ"
|
||||
sendEmail: "เมื่อการลบบัญชีนี้เสร็จสิ้น เราอาจจะส่งอีเมลไปยังที่อยู่อีเมลของคุณที่เคยลงทะเบียนไว้กับบัญชีนี้นะ"
|
||||
requestAccountDelete: "ร้องขอให้ลบบัญชี"
|
||||
started: "การลบได้เริ่มต้นขึ้น"
|
||||
inProgress: "ปัจจุบันกำลังดำเนินการลบอยู่"
|
||||
_ad:
|
||||
back: "ย้อนกลับ"
|
||||
reduceFrequencyOfThisAd: "แสดงโฆษณานี้ให้น้อยลง"
|
||||
_forgotPassword:
|
||||
enterEmail: "ป้อนที่อยู่อีเมลที่คุณเคยใช้ในการลงทะเบียนไว้ ลิงก์ที่คุณสามารถรีเซ็ตรหัสผ่านได้นั้นจะถูกส่งไปนะ"
|
||||
ifNoEmail: "ถ้าหากคุณไม่ได้ใช้อีเมลระหว่างการลงทะเบียน กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์แทนนะ"
|
||||
contactAdmin: "อินสแตนซ์นี้ไม่รองรับการใช้งานที่อยู่อีเมลนี้ กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์เพื่อรีเซ็ตรหัสผ่านของคุณแทน"
|
||||
_gallery:
|
||||
my: "แกลลอรี่ของฉัน"
|
||||
liked: "โพสต์ที่ถูกใจ"
|
||||
like: "ชื่นชอบ"
|
||||
unlike: "ลบไลค์"
|
||||
_email:
|
||||
_follow:
|
||||
title: "ได้ติดตามคุณ"
|
||||
_receiveFollowRequest:
|
||||
title: "คุณได้รับคำขอติดตาม"
|
||||
_plugin:
|
||||
install: "ติดตั้งปลั๊กอิน"
|
||||
installWarn: "กรุณาอย่าติดตั้งปลั๊กอินที่ไม่น่าเชื่อถือนะคะ"
|
||||
manage: "จัดการปลั๊กอิน"
|
||||
_preferencesBackups:
|
||||
list: "สร้างการสำรองข้อมูล"
|
||||
saveNew: "บันทึกใหม่"
|
||||
loadFile: "โหลดจากไฟล์"
|
||||
apply: "นำไปใช้กับอุปกรณ์นี้"
|
||||
save: "บันทึก"
|
||||
inputName: "กรุณาป้อนชื่อสำหรับข้อมูลสำรองนี้"
|
||||
cannotSave: "การบันทึกล้มเหลว"
|
||||
nameAlreadyExists: "มีข้อมูลสำรองชื่อ \"{name}\" นี้อยู่แล้ว กรุณาป้อนชื่ออื่นนะ"
|
||||
applyConfirm: "คุณต้องการใช้ข้อมูลสำรอง \"{name}\" กับอุปกรณ์นี้อย่างงั้นจริงหรอ การตั้งค่าที่มีอยู่ของอุปกรณ์นี้จะถูกเขียนทับนะ"
|
||||
saveConfirm: "บันทึกข้อมูลสำรองเป็น {name} มั้ย?"
|
||||
deleteConfirm: "ลบข้อมูลสำรอง {name} มั้ย?"
|
||||
renameConfirm: "เปลี่ยนชื่อข้อมูลสำรองนี้จาก \"{old}\" เป็น \"{new}\" หรือป่าว"
|
||||
noBackups: "ไม่มีข้อมูลสำรองนะ คุณสามารถสำรองข้อมูลการตั้งค่าไคลเอนต์ของคุณบนเซิร์ฟเวอร์นี้โดยใช้ \"สร้างการสำรองข้อมูลใหม่\"ได้นะ"
|
||||
createdAt: "สร้างเมื่อ: {date} {time}"
|
||||
updatedAt: "อัปเดตเมื่อ: {date} {time}"
|
||||
cannotLoad: "การโหลดล้มเหลว"
|
||||
invalidFile: "รูปแบบไฟล์ไม่ถูกต้องนะ"
|
||||
_registry:
|
||||
scope: "สโคป"
|
||||
key: "คีย์"
|
||||
keys: "คีย์"
|
||||
domain: "โดเมน"
|
||||
createKey: "สร้างคีย์"
|
||||
_aboutMisskey:
|
||||
about: "Misskey เป็นซอฟต์แวร์โอเพ่นซอร์สที่ถูกพัฒนาโดย Syuilo ตั้งแต่ปี 2014"
|
||||
contributors: "ผู้สนับสนุนหลัก"
|
||||
allContributors: "ผู้มีส่วนร่วมทั้งหมด"
|
||||
source: "ซอร์สโค้ด"
|
||||
translation: "รับแปลภาษา Misskey"
|
||||
donate: "บริจาคให้กับ Misskey"
|
||||
morePatrons: "เราขอขอบคุณสำหรับความช่วยเหลือจากผู้ช่วยอื่นๆ ที่ไม่ได้ระบุไว้ที่นี่นะ ขอขอบคุณ! 🥰"
|
||||
patrons: "สมาชิกพันธมิตร"
|
||||
_nsfw:
|
||||
respect: "ซ่อนสื่อ NSFW"
|
||||
ignore: "อย่าซ่อนสื่อ NSFW"
|
||||
force: "ซ่อนสื่อทั้งหมด"
|
||||
_mfm:
|
||||
cheatSheet: "โค้ด MFM Cheat Sheet"
|
||||
intro: "MFM เป็นภาษามาร์กอัปพิเศษเฉพาะของ Misskey ที่สามารถใช้ได้ในหลายที่ คุณยังสามารถดูรายการไวยากรณ์ MFM ที่มีอยู่ทั้งหมดได้ที่นี่นะ"
|
||||
dummy: "Misskey ขยายโลกของ Fediverse"
|
||||
mention: "กล่าวถึง"
|
||||
mentionDescription: "คุณสามารถระบุผู้ใช้โดยใช้ At-Symbol และชื่อผู้ใช้ได้นะ"
|
||||
hashtag: "แฮชแท็ก"
|
||||
hashtagDescription: "คุณสามารถระบุชื่อแฮชแท็กได้โดยใช้เครื่องหมายตัวเลขและข้อความได้นะ"
|
||||
url: "URL"
|
||||
urlDescription: "สามารถแสดง URL ได้นะ"
|
||||
link: "ลิงก์"
|
||||
linkDescription: "เจาะจงเฉพาะ ส่วนของข้อความที่สามารถแสดงเป็น URL ได้"
|
||||
bold: "ตัวหนา"
|
||||
boldDescription: "ไฮไลท์ตัวอักษรโดยทำให้หนาขึ้น"
|
||||
small: "ขนาดเล็ก"
|
||||
smallDescription: "แสดงผลเนื้อหาขนาดเล็กและบาง"
|
||||
center: "เซ็นเตอร์"
|
||||
centerDescription: "แสดงผลเนื้อหาเป็นศูนย์กลาง"
|
||||
inlineCode: "โค้ด (อินไลน์)"
|
||||
inlineCodeDescription: "แสดงผลการเน้นไวยากรณ์แบบอินไลน์สำหรับโค้ด (โปรแกรม)"
|
||||
|
@ -936,9 +1013,117 @@ _mfm:
|
|||
search: "ค้นหา"
|
||||
searchDescription: "แสดงผลกล่องค้นหาพร้อมกับข้อความที่ป้อนไว้ล่วงหน้า"
|
||||
flip: "พลิก"
|
||||
flipDescription: "พลิกเนื้อหาในแนวนอนหรือแนวตั้ง"
|
||||
jelly: "แอนิเมชั่น (เยลลี่)"
|
||||
jellyDescription: "ให้เนื้อหาเป็นแอนิเมชั่นเหมือนเยลลี่"
|
||||
tada: "แอนิเมชั่น (ธาดา)"
|
||||
tadaDescription: "ให้เนื้อหาเป็นแอนิเมชั่นเหมือน \"ทาด้า!\""
|
||||
jump: "อนิเมชั่น (กระโดด)"
|
||||
jumpDescription: "ให้เนื้อหามีภาพเคลื่อนไหวแบบกระโดด"
|
||||
bounce: "อนิเมชั่น (เด้ง)"
|
||||
bounceDescription: "ให้เนื้อหามีอนิเมชั่นเด้ง"
|
||||
shake: "อนิเมชั่น (เขย่า)"
|
||||
shakeDescription: "ให้เนื้อหามีภาพเคลื่อนไหวสั่น"
|
||||
twitch: "แอนิเมชั่น (Twitch)"
|
||||
twitchDescription: "ให้เนื้อหามีแอนิเมชั่นกระตุกอย่างแรง"
|
||||
spin: "แอนิเมชั่น (สปิน)"
|
||||
spinDescription: "ให้เนื้อหาเป็นภาพเคลื่อนไหวแบบหมุน"
|
||||
x2: "ขนาดใหญ่"
|
||||
x2Description: "แสดงเนื้อหาที่ใหญ่ขึ้น"
|
||||
x3: "ใหญ่มาก"
|
||||
x3Description: "แสดงเนื้อหาอีเว้นท์ที่ใหญ่ขึ้น"
|
||||
x4: "ใหญ่อย่างไม่น่าเชื่อ"
|
||||
x4Description: "แสดงผลเนื้อหาที่ใหญ่กว่าใหญ่กว่าขนาดใหญ่"
|
||||
blur: "เบลอ"
|
||||
blurDescription: "เบลอเนื้อหา จะแสดงผลอย่างชัดเจนต่อเมื่อวางเมาส์เหนือ"
|
||||
font: "ตัวอักษร"
|
||||
fontDescription: "ตั้งค่าตัวอักษรเพื่อแสดงเนื้อหาใน"
|
||||
rainbow: "สายรุ้ง"
|
||||
rainbowDescription: "ทำให้เนื้อหานั้นปรากฏเป็นสีรุ้ง"
|
||||
sparkle: "กลิตเตอร์"
|
||||
sparkleDescription: "ให้เนื้อหานั้นมีเอฟเฟกต์แบบอนุภาคประกาย"
|
||||
rotate: "หมุนหน้าจอ"
|
||||
rotateDescription: "เปลี่ยนเนื้อหาตามด้วยมุมที่ระบุไว้"
|
||||
plain: "เรียบง่าย"
|
||||
plainDescription: "ปิดการใช้งานเอฟเฟกต์ของ MFM ทั้งหมดที่มีอยู่ในเอฟเฟกต์ MFM นี้"
|
||||
_instanceTicker:
|
||||
none: "ไม่ต้องแสดง"
|
||||
remote: "แสดงสำหรับผู้ใช้ระยะไกล"
|
||||
always: "แสดงเสมอ"
|
||||
_serverDisconnectedBehavior:
|
||||
reload: "โหลดใหม่โดยอัตโนมัติ"
|
||||
dialog: "แสดงกล่องโต้ตอบคำเตือน"
|
||||
quiet: "แสดงคำเตือนที่ไม่เป็นการรบกวน"
|
||||
_channel:
|
||||
create: "สร้างแชนแนลใหม่"
|
||||
edit: "แก้ไขแชนแนล"
|
||||
setBanner: "เซตแบนเนอร์"
|
||||
removeBanner: "ลบแบนเนอร์"
|
||||
featured: "เทรนด์"
|
||||
owned: "เจ้าของ"
|
||||
following: "ติดตามแล้ว"
|
||||
usersCount: "{n} ผู้เข้าร่วม"
|
||||
notesCount: "{n} โน้ต"
|
||||
_menuDisplay:
|
||||
sideFull: "ด้านข้าง"
|
||||
sideIcon: "ด้านข้าง (ไอคอน)"
|
||||
top: "ท็อป"
|
||||
hide: "ซ่อน"
|
||||
_wordMute:
|
||||
muteWords: "ปิดเสียงคำ"
|
||||
muteWordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR นะ"
|
||||
muteWordsDescription2: "ล้อมรอบคีย์เวิร์ดด้วยเครื่องหมายทับเพื่อใช้นิพจน์ทั่วไป"
|
||||
softDescription: "ซ่อนโน้ตให้ตรงตามเงื่อนไขที่ตั้งไว้จากไทม์ไลน์"
|
||||
hardDescription: "ป้องกันไม่ให้โน้ตย่อที่ตรงตามเงื่อนไขที่ตั้งไว้ไม่ให้ถูกเพิ่มลงในไทม์ไลน์ นอกจากนี้ โน้ตเหล่านี้จะไม่ถูกเพิ่มลงในไทม์ไลน์แม้ว่าจะมีการเปลี่ยนแปลงเงื่อนไขยังไงก็ตาม"
|
||||
soft: "ซอฟ"
|
||||
hard: "ยาก"
|
||||
mutedNotes: "ปิดเสียงโน้ต"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "การดำเนินการนี้จะปิดเสียง\"โน้ต/รีโน้ต\"จากอินสแตนซ์ที่อยู่ในรายการ รวมถึงบันทึกของผู้ใช้ที่ตอบกลับผู้ใช้จากอินสแตนซ์ที่ปิดเสียง"
|
||||
instanceMuteDescription2: "คั่นด้วยการขึ้นบรรทัดใหม่"
|
||||
title: "ซ่อนโน้ตจากอินสแตนซ์ที่มีอยู่ในรายการ"
|
||||
heading: "รายชื่ออินสแตนซ์ที่ถูกปิดเสียง"
|
||||
_theme:
|
||||
explore: "สำรวจธีม"
|
||||
install: "ติดตั้งธีม"
|
||||
manage: "จัดการธีม"
|
||||
code: "โค้ดธีม"
|
||||
description: "รายละเอียด"
|
||||
installed: "{name} ได้รับการติดตั้ง"
|
||||
installedThemes: "ธีมที่ติดตั้ง"
|
||||
builtinThemes: "ธีมในตัว"
|
||||
alreadyInstalled: "ธีมนี้ได้รับการติดตั้งแล้ว"
|
||||
invalid: "รูปแบบของธีมนี้ไม่ถูกต้องนะ"
|
||||
make: "ทำธีม"
|
||||
base: "ฐาน"
|
||||
addConstant: "เพิ่มค่าคงที่"
|
||||
constant: "ตัวแปร"
|
||||
defaultValue: "ค่าเริ่มต้น"
|
||||
color: "สี"
|
||||
refProp: "อ้างอิงคุณสมบัติ"
|
||||
refConst: "อ้างอิงค่าคงที่"
|
||||
key: "คีย์"
|
||||
func: "ฟังก์ชัน"
|
||||
funcKind: "ประเภทฟังก์ชัน"
|
||||
argument: "อากิวเม้นต์"
|
||||
basedProp: "ทรัพย์สินอ้างอิง"
|
||||
alpha: "ความทึบแสง"
|
||||
darken: "มืดลง"
|
||||
lighten: "สว่าง"
|
||||
inputConstantName: "ป้อนชื่อสำหรับค่าคงที่นี้"
|
||||
importInfo: "ถ้าหากต้องการป้อนโค้ดที่นี่ คุณยังสามารถนำเข้าไปยังโปรแกรมแก้ไขธีมได้"
|
||||
deleteConstantConfirm: "คุณต้องการลบค่าคงที่ {const} หรือป่าว?"
|
||||
keys:
|
||||
accent: "เน้น"
|
||||
bg: "ภาพพื้นหลัง"
|
||||
fg: "ข้อความ"
|
||||
focus: "โฟกัส"
|
||||
indicator: "ตัวบ่งชี้"
|
||||
panel: "แผงควบคุม"
|
||||
shadow: "เงา"
|
||||
header: "ส่วนหัว"
|
||||
navBg: "พื้นหลังแถบด้านข้าง"
|
||||
navFg: "ข้อความแถบด้านข้าง"
|
||||
mention: "กล่าวถึง"
|
||||
renote: "รีโน้ต"
|
||||
divider: "ตัวแบ่ง"
|
||||
|
|
|
@ -141,7 +141,7 @@ cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远
|
|||
flagAsBot: "这是一个机器人账号"
|
||||
flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。"
|
||||
flagAsCat: "将这个账户设定为一只猫"
|
||||
flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。"
|
||||
flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。"
|
||||
flagShowTimelineReplies: "在时间线上显示帖子的回复"
|
||||
flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。"
|
||||
autoAcceptFollowed: "自动允许关注者的关注"
|
||||
|
@ -252,7 +252,7 @@ messageRead: "已读"
|
|||
noMoreHistory: "没有更多的历史记录"
|
||||
startMessaging: "添加聊天"
|
||||
nUsersRead: "{n}人已读"
|
||||
agreeTo: "{0}勾选则表示已阅读并同意"
|
||||
agreeTo: "勾选则表示已阅读并同意{0}"
|
||||
tos: "服务条款"
|
||||
start: "开始"
|
||||
home: "首页"
|
||||
|
@ -668,7 +668,7 @@ yes: "是"
|
|||
no: "否"
|
||||
driveFilesCount: "网盘的文件数"
|
||||
driveUsage: "网盘的空间用量"
|
||||
noCrawle: "要求搜索引擎不索引该站点"
|
||||
noCrawle: "要求搜索引擎不索引该用户"
|
||||
noCrawleDescription: "要求搜索引擎不要收录(索引)您的用户页面,帖子,页面等。"
|
||||
lockedAccountInfo: "即使通过了关注请求,只要您不将帖子可见范围设置成“关注者”,任何人都可以看到您的帖子。"
|
||||
alwaysMarkSensitive: "默认将媒体文件标记为敏感内容"
|
||||
|
@ -747,7 +747,7 @@ userInfo: "用户信息"
|
|||
unknown: "未知"
|
||||
onlineStatus: "在线状态"
|
||||
hideOnlineStatus: "隐藏在线状态"
|
||||
hideOnlineStatusDescription: "隐藏在线状态后,可能会降低例如搜索等功能的便利性。"
|
||||
hideOnlineStatusDescription: "隐藏在线状态后,可能会降低搜索等功能的便利性。"
|
||||
online: "在线"
|
||||
active: "活动"
|
||||
offline: "离线"
|
||||
|
|
|
@ -883,8 +883,8 @@ sensitiveMediaDetection: "敏感性媒體的檢測"
|
|||
localOnly: "僅限本地"
|
||||
remoteOnly: "僅限遠端"
|
||||
failedToUpload: "上傳失敗"
|
||||
cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上船。"
|
||||
cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上船>"
|
||||
cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。"
|
||||
cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。"
|
||||
beta: "Beta"
|
||||
enableAutoSensitive: "自動NSFW判定"
|
||||
enableAutoSensitiveDescription: "如果可用,請利用機器學習在媒體上自動設置 NSFW 旗標。 即使關閉此功能,依實例而定也可能會自動設置。"
|
||||
|
@ -917,7 +917,7 @@ _signup:
|
|||
emailSent: "已將確認郵件發送至您輸入的電子郵件地址 ({email})。請開啟電子郵件中的連結以完成帳戶創建。"
|
||||
_accountDelete:
|
||||
accountDelete: "刪除帳戶"
|
||||
mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶產生的內容數量上船的檔案數量較多的話,就需要花费一段時間才能完成。"
|
||||
mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶產生的內容數量上傳的檔案數量較多的話,就需要花费一段時間才能完成。"
|
||||
sendEmail: "帳戶删除完成後,將向註冊地電子郵件地址發送通知。"
|
||||
requestAccountDelete: "刪除帳戶請求"
|
||||
started: "已開始刪除作業。"
|
||||
|
|
26
package.json
26
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "12.118.1",
|
||||
"version": "12.120.0-alpha.4",
|
||||
"codename": "indigo",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -14,11 +14,12 @@
|
|||
],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn workspaces foreach run build && yarn run gulp",
|
||||
"start": "yarn workspace backend run start",
|
||||
"start:test": "yarn workspace backend run start:test",
|
||||
"postinstall": "node ./scripts/install-packages.js",
|
||||
"build": "node ./scripts/build.js",
|
||||
"start": "cd packages/backend && node --experimental-json-modules ./built/boot/index.js",
|
||||
"start:test": "cd packages/backend && cross-env NODE_ENV=test node --experimental-json-modules ./built/boot/index.js",
|
||||
"init": "yarn migrate",
|
||||
"migrate": "yarn workspace backend run migrate",
|
||||
"migrate": "cd packages/backend && npx typeorm migration:run -d ormconfig.js",
|
||||
"migrateandstart": "yarn migrate && yarn start",
|
||||
"gulp": "gulp build",
|
||||
"watch": "yarn dev",
|
||||
|
@ -27,8 +28,10 @@
|
|||
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||
"cy:run": "cypress run",
|
||||
"e2e": "start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"mocha": "yarn workspace backend run mocha",
|
||||
"test": "yarn mocha",
|
||||
"jest": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --detectOpenHandles --runInBand",
|
||||
"jest-and-coverage": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --detectOpenHandles --runInBand",
|
||||
"test": "yarn jest",
|
||||
"test-and-coverage": "yarn jest-and-coverage",
|
||||
"format": "gulp format",
|
||||
"clean": "node ./scripts/clean.js",
|
||||
"clean-all": "node ./scripts/clean-all.js",
|
||||
|
@ -52,13 +55,10 @@
|
|||
"@types/gulp": "4.0.9",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "latest",
|
||||
"@typescript-eslint/parser": "5.33.0",
|
||||
"@typescript-eslint/parser": "5.38.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "10.4.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-vue": "latest",
|
||||
"cypress": "10.8.0",
|
||||
"start-server-and-test": "1.14.0",
|
||||
"typescript": "4.7.4",
|
||||
"vue-eslint-parser": "^9.0.2"
|
||||
"typescript": "4.8.3"
|
||||
}
|
||||
}
|
||||
|
|
3
packages/backend/.madgerc
Normal file
3
packages/backend/.madgerc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"tsConfig": "./tsconfig.json"
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extension": ["ts","js","cjs","mjs"],
|
||||
"node-option": [
|
||||
"experimental-specifier-resolution=node",
|
||||
"loader=./test/loader.js"
|
||||
],
|
||||
"slow": 1000,
|
||||
"timeout": 30000,
|
||||
"exit": true
|
||||
}
|
14
packages/backend/jest-resolver.cjs
Normal file
14
packages/backend/jest-resolver.cjs
Normal file
|
@ -0,0 +1,14 @@
|
|||
// https://github.com/facebook/jest/issues/12270#issuecomment-1194746382
|
||||
|
||||
const nativeModule = require('node:module');
|
||||
|
||||
function resolver(module, options) {
|
||||
const { basedir, defaultResolver } = options;
|
||||
try {
|
||||
return defaultResolver(module, options);
|
||||
} catch (error) {
|
||||
return nativeModule.createRequire(basedir).resolve(module);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = resolver;
|
214
packages/backend/jest.config.cjs
Normal file
214
packages/backend/jest.config.cjs
Normal file
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
collectCoverageFrom: ['src/**/*.ts'],
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
globals: {
|
||||
"ts-jest": {
|
||||
"useESM": true,
|
||||
tsconfig: "test/tsconfig.json",
|
||||
diagnostics: {
|
||||
exclude: ['**'],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
"^@/(.*?).js": "<rootDir>/src/$1.ts",
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
preset: "ts-jest/presets/js-with-ts-esm",
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
resolver: './jest-resolver.cjs',
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: [
|
||||
"<rootDir>"
|
||||
],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
"<rootDir>/test/unit/**/*.ts",
|
||||
//"<rootDir>/test/e2e/**/*.ts"
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"<regex_match_files>": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\",
|
||||
// "\\.pnp\\.[^\\\\]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
};
|
|
@ -1,6 +1,8 @@
|
|||
import { DataSource } from 'typeorm';
|
||||
import config from './built/config/index.js';
|
||||
import { entities } from './built/db/postgre.js';
|
||||
import { loadConfig } from './built/config.js';
|
||||
import { entities } from './built/postgre.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
export default new DataSource({
|
||||
type: 'postgres',
|
||||
|
|
|
@ -10,32 +10,42 @@
|
|||
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
|
||||
"watch": "node watch.mjs",
|
||||
"lint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"mocha": "NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha",
|
||||
"test": "yarn mocha"
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --detectOpenHandles --runInBand",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --detectOpenHandles --runInBand",
|
||||
"test": "yarn jest",
|
||||
"test-and-coverage": "yarn jest-and-coverage"
|
||||
},
|
||||
"resolutions": {
|
||||
"chokidar": "^3.3.1",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-node": "3.19.0"
|
||||
"@tensorflow/tfjs-node": "3.20.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "4.2.2",
|
||||
"@bull-board/koa": "4.2.2",
|
||||
"@bull-board/ui": "4.2.2",
|
||||
"@bull-board/api": "4.3.1",
|
||||
"@bull-board/koa": "4.3.1",
|
||||
"@bull-board/ui": "4.3.1",
|
||||
"@discordapp/twemoji": "14.0.2",
|
||||
"@elastic/elasticsearch": "7.17.0",
|
||||
"@koa/cors": "3.3.0",
|
||||
"@koa/multer": "3.0.0",
|
||||
"@koa/router": "9.0.1",
|
||||
"@nestjs/common": "9.1.1",
|
||||
"@nestjs/core": "9.1.1",
|
||||
"@nestjs/testing": "9.1.1",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "9.1.2",
|
||||
"@syuilo/aiscript": "0.11.1",
|
||||
"@types/pg": "8.6.5",
|
||||
"ajv": "8.11.0",
|
||||
"archiver": "5.3.1",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"autwh": "0.1.0",
|
||||
"aws-sdk": "2.1194.0",
|
||||
"aws-sdk": "2.1213.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "1.1.5",
|
||||
"bull": "4.8.5",
|
||||
"bull": "4.9.0",
|
||||
"cacheable-lookup": "6.1.0",
|
||||
"cbor": "8.1.0",
|
||||
"chalk": "5.0.1",
|
||||
|
@ -44,14 +54,14 @@
|
|||
"cli-highlight": "2.1.11",
|
||||
"color-convert": "2.0.1",
|
||||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.29.1",
|
||||
"date-fns": "2.29.3",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"escape-regexp": "0.0.1",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "17.1.6",
|
||||
"file-type": "18.0.0",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
"got": "12.3.1",
|
||||
"hpagent": "0.1.2",
|
||||
"got": "12.5.0",
|
||||
"hpagent": "1.0.0",
|
||||
"ioredis": "4.28.5",
|
||||
"ip-cidr": "3.0.10",
|
||||
"is-svg": "4.3.2",
|
||||
|
@ -59,8 +69,8 @@
|
|||
"jsdom": "20.0.0",
|
||||
"json5": "2.2.1",
|
||||
"json5-loader": "4.0.1",
|
||||
"jsonld": "6.0.0",
|
||||
"jsrsasign": "10.5.26",
|
||||
"jsonld": "8.1.0",
|
||||
"jsrsasign": "10.5.27",
|
||||
"koa": "2.13.4",
|
||||
"koa-bodyparser": "4.3.0",
|
||||
"koa-favicon": "2.1.0",
|
||||
|
@ -73,17 +83,15 @@
|
|||
"mfm-js": "0.23.0",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "0.0.14",
|
||||
"mocha": "10.0.0",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"multer": "1.4.4",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.2.10",
|
||||
"nodemailer": "6.7.8",
|
||||
"nsfwjs": "2.4.1",
|
||||
"oauth": "^0.9.15",
|
||||
"nsfwjs": "2.4.2",
|
||||
"os-utils": "0.0.14",
|
||||
"parse5": "7.0.0",
|
||||
"pg": "8.7.3",
|
||||
"parse5": "7.1.1",
|
||||
"pg": "8.8.0",
|
||||
"private-ip": "2.3.4",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
|
@ -99,8 +107,9 @@
|
|||
"rename": "1.0.4",
|
||||
"rndstr": "1.0.0",
|
||||
"rss-parser": "3.12.0",
|
||||
"rxjs": "7.5.6",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.7.1",
|
||||
"sanitize-html": "2.7.2",
|
||||
"semver": "7.3.7",
|
||||
"sharp": "0.30.6",
|
||||
"speakeasy": "2.0.0",
|
||||
|
@ -108,36 +117,37 @@
|
|||
"stringz": "2.1.0",
|
||||
"summaly": "2.7.0",
|
||||
"syslog-pro": "1.0.0",
|
||||
"systeminformation": "5.12.5",
|
||||
"systeminformation": "5.12.6",
|
||||
"tinycolor2": "1.4.2",
|
||||
"tmp": "0.2.1",
|
||||
"ts-loader": "9.3.1",
|
||||
"ts-loader": "9.4.0",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.7.0",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.7",
|
||||
"typeorm": "0.3.10",
|
||||
"ulid": "2.3.0",
|
||||
"unzipper": "0.10.11",
|
||||
"uuid": "8.3.2",
|
||||
"uuid": "9.0.0",
|
||||
"web-push": "3.5.0",
|
||||
"websocket": "1.0.34",
|
||||
"ws": "8.8.1",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redocly/openapi-core": "1.0.0-beta.106",
|
||||
"@redocly/openapi-core": "1.0.0-beta.108",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/bull": "3.15.9",
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/fluent-ffmpeg": "2.1.20",
|
||||
"@types/jest": "29.0.3",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jsdom": "20.0.0",
|
||||
"@types/jsonld": "1.5.6",
|
||||
"@types/jsrsasign": "10.5.2",
|
||||
"@types/jsrsasign": "10.5.3",
|
||||
"@types/koa": "2.13.5",
|
||||
"@types/koa-bodyparser": "4.3.7",
|
||||
"@types/koa-bodyparser": "4.3.8",
|
||||
"@types/koa-cors": "0.0.2",
|
||||
"@types/koa-favicon": "2.0.21",
|
||||
"@types/koa-logger": "3.1.2",
|
||||
|
@ -147,14 +157,13 @@
|
|||
"@types/koa__cors": "3.3.0",
|
||||
"@types/koa__multer": "2.0.4",
|
||||
"@types/koa__router": "8.0.11",
|
||||
"@types/mocha": "9.1.1",
|
||||
"@types/node": "18.7.2",
|
||||
"@types/node": "18.7.18",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.5",
|
||||
"@types/nodemailer": "6.4.6",
|
||||
"@types/oauth": "0.9.1",
|
||||
"@types/pug": "2.0.6",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/qrcode": "1.4.2",
|
||||
"@types/qrcode": "1.5.0",
|
||||
"@types/random-seed": "0.3.3",
|
||||
"@types/ratelimiter": "3.4.3",
|
||||
"@types/redis": "4.0.11",
|
||||
|
@ -170,13 +179,14 @@
|
|||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.33.0",
|
||||
"@typescript-eslint/parser": "5.33.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.38.0",
|
||||
"@typescript-eslint/parser": "5.38.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.21.0",
|
||||
"eslint": "8.23.1",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"execa": "6.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"typescript": "4.7.4"
|
||||
"jest": "29.0.3",
|
||||
"ts-jest": "29.0.1",
|
||||
"typescript": "4.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
declare module '@peertube/http-signature' {
|
||||
import { IncomingMessage, ClientRequest } from 'node:http';
|
||||
import type { IncomingMessage, ClientRequest } from 'node:http';
|
||||
|
||||
interface ISignature {
|
||||
keyId: string;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
declare module 'koa-json-body' {
|
||||
import { Middleware } from 'koa';
|
||||
import type { Middleware } from 'koa';
|
||||
|
||||
interface IKoaJsonBodyOptions {
|
||||
strict: boolean;
|
||||
|
|
2
packages/backend/src/@types/koa-slow.d.ts
vendored
2
packages/backend/src/@types/koa-slow.d.ts
vendored
|
@ -1,5 +1,5 @@
|
|||
declare module 'koa-slow' {
|
||||
import { Middleware } from 'koa';
|
||||
import type { Middleware } from 'koa';
|
||||
|
||||
interface ISlowOptions {
|
||||
url?: RegExp;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
declare module 'probe-image-size' {
|
||||
import { ReadStream } from 'node:fs';
|
||||
import type { ReadStream } from 'node:fs';
|
||||
|
||||
type ProbeOptions = {
|
||||
retries: 1;
|
||||
|
|
13
packages/backend/src/AppModule.ts
Normal file
13
packages/backend/src/AppModule.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ServerModule } from '@/server/ServerModule.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
ServerModule,
|
||||
QueueProcessorModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
63
packages/backend/src/GlobalModule.ts
Normal file
63
packages/backend/src/GlobalModule.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { Global, Inject, Module } from '@nestjs/common';
|
||||
import { Redis } from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { createRedisConnection } from '@/redis.js';
|
||||
import { DI } from './di-symbols.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { createPostgreDataSource } from './postgre.js';
|
||||
import { RepositoryModule } from './RepositoryModule.js';
|
||||
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const $config: Provider = {
|
||||
provide: DI.config,
|
||||
useValue: config,
|
||||
};
|
||||
|
||||
const $db: Provider = {
|
||||
provide: DI.db,
|
||||
useFactory: async () => {
|
||||
const db = createPostgreDataSource();
|
||||
return await db.initialize();
|
||||
},
|
||||
};
|
||||
|
||||
const $redis: Provider = {
|
||||
provide: DI.redis,
|
||||
useFactory: () => {
|
||||
const redisClient = createRedisConnection();
|
||||
return redisClient;
|
||||
},
|
||||
};
|
||||
|
||||
const $redisSubscriber: Provider = {
|
||||
provide: DI.redisSubscriber,
|
||||
useFactory: () => {
|
||||
const redisSubscriber = createRedisConnection();
|
||||
redisSubscriber.subscribe(config.host);
|
||||
return redisSubscriber;
|
||||
},
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RepositoryModule],
|
||||
providers: [$config, $db, $redis, $redisSubscriber],
|
||||
exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(DI.db) private db: DataSource,
|
||||
@Inject(DI.redis) private redisClient: Redis,
|
||||
@Inject(DI.redisSubscriber) private redisSubscriber: Redis,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
await Promise.all([
|
||||
this.db.destroy(),
|
||||
this.redisClient.disconnect(),
|
||||
this.redisSubscriber.disconnect(),
|
||||
]);
|
||||
}
|
||||
}
|
519
packages/backend/src/RepositoryModule.ts
Normal file
519
packages/backend/src/RepositoryModule.ts
Normal file
|
@ -0,0 +1,519 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest } from './models/index.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
const $usersRepository: Provider = {
|
||||
provide: DI.usersRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(User),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $notesRepository: Provider = {
|
||||
provide: DI.notesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Note),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $announcementsRepository: Provider = {
|
||||
provide: DI.announcementsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Announcement),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $announcementReadsRepository: Provider = {
|
||||
provide: DI.announcementReadsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AnnouncementRead),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $appsRepository: Provider = {
|
||||
provide: DI.appsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(App),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteFavoritesRepository: Provider = {
|
||||
provide: DI.noteFavoritesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteFavorite),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteThreadMutingsRepository: Provider = {
|
||||
provide: DI.noteThreadMutingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteThreadMuting),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteReactionsRepository: Provider = {
|
||||
provide: DI.noteReactionsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteReaction),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteUnreadsRepository: Provider = {
|
||||
provide: DI.noteUnreadsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteUnread),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pollsRepository: Provider = {
|
||||
provide: DI.pollsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Poll),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pollVotesRepository: Provider = {
|
||||
provide: DI.pollVotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PollVote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userProfilesRepository: Provider = {
|
||||
provide: DI.userProfilesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserProfile),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userKeypairsRepository: Provider = {
|
||||
provide: DI.userKeypairsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserKeypair),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userPendingsRepository: Provider = {
|
||||
provide: DI.userPendingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserPending),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $attestationChallengesRepository: Provider = {
|
||||
provide: DI.attestationChallengesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AttestationChallenge),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userSecurityKeysRepository: Provider = {
|
||||
provide: DI.userSecurityKeysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserSecurityKey),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userPublickeysRepository: Provider = {
|
||||
provide: DI.userPublickeysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserPublickey),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userListsRepository: Provider = {
|
||||
provide: DI.userListsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserList),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userListJoiningsRepository: Provider = {
|
||||
provide: DI.userListJoiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userGroupsRepository: Provider = {
|
||||
provide: DI.userGroupsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserGroup),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userGroupJoiningsRepository: Provider = {
|
||||
provide: DI.userGroupJoiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserGroupJoining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userGroupInvitationsRepository: Provider = {
|
||||
provide: DI.userGroupInvitationsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserGroupInvitation),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userNotePiningsRepository: Provider = {
|
||||
provide: DI.userNotePiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserNotePining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userIpsRepository: Provider = {
|
||||
provide: DI.userIpsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserIp),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $usedUsernamesRepository: Provider = {
|
||||
provide: DI.usedUsernamesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UsedUsername),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $followingsRepository: Provider = {
|
||||
provide: DI.followingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Following),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $followRequestsRepository: Provider = {
|
||||
provide: DI.followRequestsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(FollowRequest),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $instancesRepository: Provider = {
|
||||
provide: DI.instancesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Instance),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $emojisRepository: Provider = {
|
||||
provide: DI.emojisRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Emoji),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $driveFilesRepository: Provider = {
|
||||
provide: DI.driveFilesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(DriveFile),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $driveFoldersRepository: Provider = {
|
||||
provide: DI.driveFoldersRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(DriveFolder),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $notificationsRepository: Provider = {
|
||||
provide: DI.notificationsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Notification),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $metasRepository: Provider = {
|
||||
provide: DI.metasRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Meta),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $mutingsRepository: Provider = {
|
||||
provide: DI.mutingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Muting),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $blockingsRepository: Provider = {
|
||||
provide: DI.blockingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Blocking),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $swSubscriptionsRepository: Provider = {
|
||||
provide: DI.swSubscriptionsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(SwSubscription),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $hashtagsRepository: Provider = {
|
||||
provide: DI.hashtagsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Hashtag),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $abuseUserReportsRepository: Provider = {
|
||||
provide: DI.abuseUserReportsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AbuseUserReport),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $registrationTicketsRepository: Provider = {
|
||||
provide: DI.registrationTicketsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(RegistrationTicket),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $authSessionsRepository: Provider = {
|
||||
provide: DI.authSessionsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AuthSession),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $accessTokensRepository: Provider = {
|
||||
provide: DI.accessTokensRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AccessToken),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $signinsRepository: Provider = {
|
||||
provide: DI.signinsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Signin),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $messagingMessagesRepository: Provider = {
|
||||
provide: DI.messagingMessagesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MessagingMessage),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pagesRepository: Provider = {
|
||||
provide: DI.pagesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Page),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pageLikesRepository: Provider = {
|
||||
provide: DI.pageLikesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PageLike),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $galleryPostsRepository: Provider = {
|
||||
provide: DI.galleryPostsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(GalleryPost),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $galleryLikesRepository: Provider = {
|
||||
provide: DI.galleryLikesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(GalleryLike),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $moderationLogsRepository: Provider = {
|
||||
provide: DI.moderationLogsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ModerationLog),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $clipsRepository: Provider = {
|
||||
provide: DI.clipsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Clip),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $clipNotesRepository: Provider = {
|
||||
provide: DI.clipNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ClipNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $antennasRepository: Provider = {
|
||||
provide: DI.antennasRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Antenna),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $antennaNotesRepository: Provider = {
|
||||
provide: DI.antennaNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AntennaNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $promoNotesRepository: Provider = {
|
||||
provide: DI.promoNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PromoNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $promoReadsRepository: Provider = {
|
||||
provide: DI.promoReadsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PromoRead),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $relaysRepository: Provider = {
|
||||
provide: DI.relaysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Relay),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $mutedNotesRepository: Provider = {
|
||||
provide: DI.mutedNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MutedNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelsRepository: Provider = {
|
||||
provide: DI.channelsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Channel),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelFollowingsRepository: Provider = {
|
||||
provide: DI.channelFollowingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ChannelFollowing),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelNotePiningsRepository: Provider = {
|
||||
provide: DI.channelNotePiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ChannelNotePining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $registryItemsRepository: Provider = {
|
||||
provide: DI.registryItemsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(RegistryItem),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $webhooksRepository: Provider = {
|
||||
provide: DI.webhooksRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Webhook),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $adsRepository: Provider = {
|
||||
provide: DI.adsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Ad),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $passwordResetRequestsRepository: Provider = {
|
||||
provide: DI.passwordResetRequestsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PasswordResetRequest),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
],
|
||||
providers: [
|
||||
$usersRepository,
|
||||
$notesRepository,
|
||||
$announcementsRepository,
|
||||
$announcementReadsRepository,
|
||||
$appsRepository,
|
||||
$noteFavoritesRepository,
|
||||
$noteThreadMutingsRepository,
|
||||
$noteReactionsRepository,
|
||||
$noteUnreadsRepository,
|
||||
$pollsRepository,
|
||||
$pollVotesRepository,
|
||||
$userProfilesRepository,
|
||||
$userKeypairsRepository,
|
||||
$userPendingsRepository,
|
||||
$attestationChallengesRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userListsRepository,
|
||||
$userListJoiningsRepository,
|
||||
$userGroupsRepository,
|
||||
$userGroupJoiningsRepository,
|
||||
$userGroupInvitationsRepository,
|
||||
$userNotePiningsRepository,
|
||||
$userIpsRepository,
|
||||
$usedUsernamesRepository,
|
||||
$followingsRepository,
|
||||
$followRequestsRepository,
|
||||
$instancesRepository,
|
||||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$blockingsRepository,
|
||||
$swSubscriptionsRepository,
|
||||
$hashtagsRepository,
|
||||
$abuseUserReportsRepository,
|
||||
$registrationTicketsRepository,
|
||||
$authSessionsRepository,
|
||||
$accessTokensRepository,
|
||||
$signinsRepository,
|
||||
$messagingMessagesRepository,
|
||||
$pagesRepository,
|
||||
$pageLikesRepository,
|
||||
$galleryPostsRepository,
|
||||
$galleryLikesRepository,
|
||||
$moderationLogsRepository,
|
||||
$clipsRepository,
|
||||
$clipNotesRepository,
|
||||
$antennasRepository,
|
||||
$antennaNotesRepository,
|
||||
$promoNotesRepository,
|
||||
$promoReadsRepository,
|
||||
$relaysRepository,
|
||||
$mutedNotesRepository,
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelNotePiningsRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$adsRepository,
|
||||
$passwordResetRequestsRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
$notesRepository,
|
||||
$announcementsRepository,
|
||||
$announcementReadsRepository,
|
||||
$appsRepository,
|
||||
$noteFavoritesRepository,
|
||||
$noteThreadMutingsRepository,
|
||||
$noteReactionsRepository,
|
||||
$noteUnreadsRepository,
|
||||
$pollsRepository,
|
||||
$pollVotesRepository,
|
||||
$userProfilesRepository,
|
||||
$userKeypairsRepository,
|
||||
$userPendingsRepository,
|
||||
$attestationChallengesRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userListsRepository,
|
||||
$userListJoiningsRepository,
|
||||
$userGroupsRepository,
|
||||
$userGroupJoiningsRepository,
|
||||
$userGroupInvitationsRepository,
|
||||
$userNotePiningsRepository,
|
||||
$userIpsRepository,
|
||||
$usedUsernamesRepository,
|
||||
$followingsRepository,
|
||||
$followRequestsRepository,
|
||||
$instancesRepository,
|
||||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$blockingsRepository,
|
||||
$swSubscriptionsRepository,
|
||||
$hashtagsRepository,
|
||||
$abuseUserReportsRepository,
|
||||
$registrationTicketsRepository,
|
||||
$authSessionsRepository,
|
||||
$accessTokensRepository,
|
||||
$signinsRepository,
|
||||
$messagingMessagesRepository,
|
||||
$pagesRepository,
|
||||
$pageLikesRepository,
|
||||
$galleryPostsRepository,
|
||||
$galleryLikesRepository,
|
||||
$moderationLogsRepository,
|
||||
$clipsRepository,
|
||||
$clipNotesRepository,
|
||||
$antennasRepository,
|
||||
$antennaNotesRepository,
|
||||
$promoNotesRepository,
|
||||
$promoReadsRepository,
|
||||
$relaysRepository,
|
||||
$mutedNotesRepository,
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelNotePiningsRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$adsRepository,
|
||||
$passwordResetRequestsRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {}
|
|
@ -1,44 +1,28 @@
|
|||
|
||||
/**
|
||||
* Misskey Entry Point!
|
||||
*/
|
||||
|
||||
import cluster from 'node:cluster';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import chalk from 'chalk';
|
||||
import Xev from 'xev';
|
||||
|
||||
import Logger from '@/services/logger.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { envOption } from '../env.js';
|
||||
|
||||
// for typeorm
|
||||
import 'reflect-metadata';
|
||||
import { masterMain } from './master.js';
|
||||
import { workerMain } from './worker.js';
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
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 ev = new Xev();
|
||||
|
||||
/**
|
||||
* Init process
|
||||
*/
|
||||
export default async function() {
|
||||
process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
|
||||
|
||||
if (cluster.isPrimary || envOption.disableClustering) {
|
||||
await masterMain();
|
||||
|
||||
if (cluster.isPrimary) {
|
||||
ev.mount();
|
||||
}
|
||||
}
|
||||
|
||||
if (cluster.isWorker || envOption.disableClustering) {
|
||||
await workerMain();
|
||||
}
|
||||
|
||||
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
|
||||
// それ以外のときは process.send は使えないので弾く
|
||||
if (process.send) {
|
||||
process.send('ok');
|
||||
}
|
||||
}
|
||||
|
||||
//#region Events
|
||||
|
||||
// Listen new workers
|
||||
|
@ -77,3 +61,21 @@ process.on('exit', code => {
|
|||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
if (cluster.isPrimary || envOption.disableClustering) {
|
||||
await masterMain();
|
||||
|
||||
if (cluster.isPrimary) {
|
||||
ev.mount();
|
||||
}
|
||||
}
|
||||
|
||||
if (cluster.isWorker || envOption.disableClustering) {
|
||||
await workerMain();
|
||||
}
|
||||
|
||||
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
|
||||
// それ以外のときは process.send は使えないので弾く
|
||||
if (process.send) {
|
||||
process.send('ok');
|
||||
}
|
||||
|
|
|
@ -6,14 +6,17 @@ import cluster from 'node:cluster';
|
|||
import chalk from 'chalk';
|
||||
import chalkTemplate from 'chalk-template';
|
||||
import semver from 'semver';
|
||||
|
||||
import Logger from '@/services/logger.js';
|
||||
import loadConfig from '@/config/load.js';
|
||||
import { Config } from '@/config/types.js';
|
||||
import { lessThan } from '@/prelude/array.js';
|
||||
import { envOption } from '../env.js';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import Logger from '@/logger.js';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { lessThan } from '@/misc/prelude/array.js';
|
||||
import { showMachineInfo } from '@/misc/show-machine-info.js';
|
||||
import { db, initDb } from '../db/postgre.js';
|
||||
import { DaemonModule } from '@/daemons/DaemonModule.js';
|
||||
import { JanitorService } from '@/daemons/JanitorService.js';
|
||||
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
|
||||
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
|
||||
import { envOption } from '../env.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
@ -60,7 +63,7 @@ export async function masterMain() {
|
|||
await showMachineInfo(bootLogger);
|
||||
showNodejsVersion();
|
||||
config = loadConfigBoot();
|
||||
await connectDb();
|
||||
//await connectDb();
|
||||
} catch (e) {
|
||||
bootLogger.error('Fatal error occurred during initialization', null, true);
|
||||
process.exit(1);
|
||||
|
@ -75,9 +78,11 @@ export async function masterMain() {
|
|||
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
|
||||
|
||||
if (!envOption.noDaemons) {
|
||||
import('../daemons/server-stats.js').then(x => x.default());
|
||||
import('../daemons/queue-stats.js').then(x => x.default());
|
||||
import('../daemons/janitor.js').then(x => x.default());
|
||||
const daemons = await NestFactory.createApplicationContext(DaemonModule);
|
||||
daemons.enableShutdownHooks();
|
||||
daemons.get(JanitorService).start();
|
||||
daemons.get(QueueStatsService).start();
|
||||
daemons.get(ServerStatsService).start();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,6 +132,7 @@ function loadConfigBoot(): Config {
|
|||
return config;
|
||||
}
|
||||
|
||||
/*
|
||||
async function connectDb(): Promise<void> {
|
||||
const dbLogger = bootLogger.createSubLogger('db');
|
||||
|
||||
|
@ -136,14 +142,15 @@ async function connectDb(): Promise<void> {
|
|||
await initDb();
|
||||
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
|
||||
dbLogger.succ(`Connected: v${v}`);
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
dbLogger.error('Cannot connect', null, true);
|
||||
dbLogger.error(e);
|
||||
dbLogger.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
async function spawnWorkers(limit: number = 1) {
|
||||
async function spawnWorkers(limit = 1) {
|
||||
const workers = Math.min(limit, os.cpus().length);
|
||||
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
|
||||
await Promise.all([...Array(workers)].map(spawnWorker));
|
||||
|
@ -155,7 +162,7 @@ function spawnWorker(): Promise<void> {
|
|||
const worker = cluster.fork();
|
||||
worker.on('message', message => {
|
||||
if (message === 'listenFailed') {
|
||||
bootLogger.error(`The server Listen failed due to the previous error.`);
|
||||
bootLogger.error('The server Listen failed due to the previous error.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (message !== 'ready') return;
|
||||
|
|
|
@ -1,17 +1,29 @@
|
|||
import cluster from 'node:cluster';
|
||||
import { initDb } from '../db/postgre.js';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { envOption } from '@/env.js';
|
||||
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
|
||||
import { ServerService } from '@/server/ServerService.js';
|
||||
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
|
||||
import { AppModule } from '../AppModule.js';
|
||||
|
||||
/**
|
||||
* Init worker process
|
||||
*/
|
||||
export async function workerMain() {
|
||||
await initDb();
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// start server
|
||||
await import('../server/index.js').then(x => x.default());
|
||||
const serverService = app.get(ServerService);
|
||||
serverService.launch();
|
||||
|
||||
// start job queue
|
||||
import('../queue/index.js').then(x => x.default());
|
||||
if (!envOption.onlyServer) {
|
||||
const queueProcessorService = app.get(QueueProcessorService);
|
||||
queueProcessorService.start();
|
||||
}
|
||||
|
||||
app.get(ChartManagementService).run();
|
||||
|
||||
if (cluster.isWorker) {
|
||||
// Send a 'ready' message to parent process
|
||||
|
|
149
packages/backend/src/config.ts
Normal file
149
packages/backend/src/config.ts
Normal file
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Config loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
/**
|
||||
* ユーザーが設定する必要のある情報
|
||||
*/
|
||||
export type Source = {
|
||||
repository_url?: string;
|
||||
feedback_url?: string;
|
||||
url: string;
|
||||
port: number;
|
||||
disableHsts?: boolean;
|
||||
db: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
elasticsearch: {
|
||||
host: string;
|
||||
port: number;
|
||||
ssl?: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
index?: string;
|
||||
};
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
accesslog?: string;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
id: string;
|
||||
|
||||
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
|
||||
|
||||
deliverJobConcurrency?: number;
|
||||
inboxJobConcurrency?: number;
|
||||
deliverJobPerSec?: number;
|
||||
inboxJobPerSec?: number;
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
syslog: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
|
||||
*/
|
||||
export type Mixin = {
|
||||
version: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
wsScheme: string;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
clientEntry: string;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
/**
|
||||
* Path of configuration directory
|
||||
*/
|
||||
const dir = `${_dirname}/../../../.config`;
|
||||
|
||||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.NODE_ENV === 'test'
|
||||
? `${dir}/test.yml`
|
||||
: `${dir}/default.yml`;
|
||||
|
||||
export function loadConfig() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_client_dist_/manifest.json`, 'utf-8'));
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
|
||||
const url = tryCreateUrl(config.url);
|
||||
|
||||
config.url = url.origin;
|
||||
|
||||
config.port = config.port ?? parseInt(process.env.PORT ?? '', 10);
|
||||
|
||||
mixin.version = meta.version;
|
||||
mixin.host = url.host;
|
||||
mixin.hostname = url.hostname;
|
||||
mixin.scheme = url.protocol.replace(/:$/, '');
|
||||
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
|
||||
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
|
||||
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
mixin.clientEntry = clientManifest['src/init.ts'];
|
||||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
throw `url="${url}" is not a valid URL.`;
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
import load from './load.js';
|
||||
|
||||
export default load();
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* Config loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { Source, Mixin } from './types.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
/**
|
||||
* Path of configuration directory
|
||||
*/
|
||||
const dir = `${_dirname}/../../../../.config`;
|
||||
|
||||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.NODE_ENV === 'test'
|
||||
? `${dir}/test.yml`
|
||||
: `${dir}/default.yml`;
|
||||
|
||||
export default function load() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8'));
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
|
||||
const url = tryCreateUrl(config.url);
|
||||
|
||||
config.url = url.origin;
|
||||
|
||||
config.port = config.port || parseInt(process.env.PORT || '', 10);
|
||||
|
||||
mixin.version = meta.version;
|
||||
mixin.host = url.host;
|
||||
mixin.hostname = url.hostname;
|
||||
mixin.scheme = url.protocol.replace(/:$/, '');
|
||||
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
|
||||
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
|
||||
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
mixin.clientEntry = clientManifest['src/init.ts'];
|
||||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
throw `url="${url}" is not a valid URL.`;
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
/**
|
||||
* ユーザーが設定する必要のある情報
|
||||
*/
|
||||
export type Source = {
|
||||
repository_url?: string;
|
||||
feedback_url?: string;
|
||||
url: string;
|
||||
port: number;
|
||||
disableHsts?: boolean;
|
||||
db: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
elasticsearch: {
|
||||
host: string;
|
||||
port: number;
|
||||
ssl?: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
index?: string;
|
||||
};
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
accesslog?: string;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
id: string;
|
||||
|
||||
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
|
||||
|
||||
deliverJobConcurrency?: number;
|
||||
inboxJobConcurrency?: number;
|
||||
deliverJobPerSec?: number;
|
||||
inboxJobPerSec?: number;
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
syslog: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
|
||||
*/
|
||||
export type Mixin = {
|
||||
version: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
wsScheme: string;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
clientEntry: string;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
38
packages/backend/src/core/AccountUpdateService.ts
Normal file
38
packages/backend/src/core/AccountUpdateService.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { ApDeliverManagerService } from '@/core/remote/activitypub/ApDeliverManagerService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccountUpdateService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private relayService: RelayService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async publishToFollowers(userId: User['id']) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
}
|
60
packages/backend/src/core/AiService.ts
Normal file
60
packages/backend/src/core/AiService.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as nsfw from 'nsfwjs';
|
||||
import si from 'systeminformation';
|
||||
import { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const REQUIRED_CPU_FLAGS = ['avx2', 'fma'];
|
||||
let isSupportedCpu: undefined | boolean = undefined;
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private model: nsfw.NSFWJS;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
|
||||
try {
|
||||
if (isSupportedCpu === undefined) {
|
||||
const cpuFlags = await this.getCpuFlags();
|
||||
isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required));
|
||||
}
|
||||
|
||||
if (!isSupportedCpu) {
|
||||
console.error('This CPU cannot use TensorFlow.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tf = await import('@tensorflow/tfjs-node');
|
||||
|
||||
if (this.model == null) this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
|
||||
|
||||
const buffer = await fs.promises.readFile(path);
|
||||
const image = await tf.node.decodeImage(buffer, 3) as any;
|
||||
try {
|
||||
const predictions = await this.model.classify(image);
|
||||
return predictions;
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getCpuFlags(): Promise<string[]> {
|
||||
const str = await si.cpuFlags();
|
||||
return str.split(/\s+/);
|
||||
}
|
||||
}
|
228
packages/backend/src/core/AntennaService.ts
Normal file
228
packages/backend/src/core/AntennaService.ts
Normal file
|
@ -0,0 +1,228 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import type { Antenna } from '@/models/entities/Antenna.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AntennaService implements OnApplicationShutdown {
|
||||
private antennasFetched: boolean;
|
||||
private antennas: Antenna[];
|
||||
private blockingCache: Cache<User['id'][]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.userGroupJoiningsRepository)
|
||||
private userGroupJoiningsRepository: UserGroupJoiningsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
) {
|
||||
this.antennasFetched = false;
|
||||
this.antennas = [];
|
||||
this.blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
|
||||
|
||||
this.redisSubscriber.on('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
private async onRedisMessage(_, data) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message;
|
||||
switch (type) {
|
||||
case 'antennaCreated':
|
||||
this.antennas.push(body);
|
||||
break;
|
||||
case 'antennaUpdated':
|
||||
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = body;
|
||||
break;
|
||||
case 'antennaDeleted':
|
||||
this.antennas = this.antennas.filter(a => a.id !== body.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> {
|
||||
// 通知しない設定になっているか、自分自身の投稿なら既読にする
|
||||
const read = !antenna.notify || (antenna.userId === noteUser.id);
|
||||
|
||||
this.antennaNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
antennaId: antenna.id,
|
||||
noteId: note.id,
|
||||
read: read,
|
||||
});
|
||||
|
||||
this.globalEventServie.publishAntennaStream(antenna.id, 'note', note);
|
||||
|
||||
if (!read) {
|
||||
const mutings = await this.mutingsRepository.find({
|
||||
where: {
|
||||
muterId: antenna.userId,
|
||||
},
|
||||
select: ['muteeId'],
|
||||
});
|
||||
|
||||
// Copy
|
||||
const _note: Note = {
|
||||
...note,
|
||||
};
|
||||
|
||||
if (note.replyId != null) {
|
||||
_note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId });
|
||||
}
|
||||
if (note.renoteId != null) {
|
||||
_note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||
}
|
||||
|
||||
if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2秒経っても既読にならなかったら通知
|
||||
setTimeout(async () => {
|
||||
const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false });
|
||||
if (unread) {
|
||||
this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||
|
||||
/**
|
||||
* noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい
|
||||
*/
|
||||
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
|
||||
if (note.visibility === 'specified') return false;
|
||||
|
||||
// アンテナ作成者がノート作成者にブロックされていたらスキップ
|
||||
const blockings = await this.blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
|
||||
if (blockings.some(blocking => blocking === antenna.userId)) return false;
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
|
||||
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
|
||||
}
|
||||
|
||||
if (!antenna.withReplies && note.replyId != null) return false;
|
||||
|
||||
if (antenna.src === 'home') {
|
||||
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
|
||||
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
|
||||
} else if (antenna.src === 'list') {
|
||||
const listUsers = (await this.userListJoiningsRepository.findBy({
|
||||
userListId: antenna.userListId!,
|
||||
})).map(x => x.userId);
|
||||
|
||||
if (!listUsers.includes(note.userId)) return false;
|
||||
} else if (antenna.src === 'group') {
|
||||
const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! });
|
||||
|
||||
const groupUsers = (await this.userGroupJoiningsRepository.findBy({
|
||||
userGroupId: joining.userGroupId,
|
||||
})).map(x => x.userId);
|
||||
|
||||
if (!groupUsers.includes(note.userId)) return false;
|
||||
} else if (antenna.src === 'users') {
|
||||
const accts = antenna.users.map(x => {
|
||||
const { username, host } = Acct.parse(x);
|
||||
return this.utilityService.getFullApAccount(username, host).toLowerCase();
|
||||
});
|
||||
if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
|
||||
}
|
||||
|
||||
const keywords = antenna.keywords
|
||||
// Clean up
|
||||
.map(xs => xs.filter(x => x !== ''))
|
||||
.filter(xs => xs.length > 0);
|
||||
|
||||
if (keywords.length > 0) {
|
||||
if (note.text == null) return false;
|
||||
|
||||
const matched = keywords.some(and =>
|
||||
and.every(keyword =>
|
||||
antenna.caseSensitive
|
||||
? note.text!.includes(keyword)
|
||||
: note.text!.toLowerCase().includes(keyword.toLowerCase()),
|
||||
));
|
||||
|
||||
if (!matched) return false;
|
||||
}
|
||||
|
||||
const excludeKeywords = antenna.excludeKeywords
|
||||
// Clean up
|
||||
.map(xs => xs.filter(x => x !== ''))
|
||||
.filter(xs => xs.length > 0);
|
||||
|
||||
if (excludeKeywords.length > 0) {
|
||||
if (note.text == null) return false;
|
||||
|
||||
const matched = excludeKeywords.some(and =>
|
||||
and.every(keyword =>
|
||||
antenna.caseSensitive
|
||||
? note.text!.includes(keyword)
|
||||
: note.text!.toLowerCase().includes(keyword.toLowerCase()),
|
||||
));
|
||||
|
||||
if (matched) return false;
|
||||
}
|
||||
|
||||
if (antenna.withFile) {
|
||||
if (note.fileIds && note.fileIds.length === 0) return false;
|
||||
}
|
||||
|
||||
// TODO: eval expression
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAntennas() {
|
||||
if (!this.antennasFetched) {
|
||||
this.antennas = await this.antennasRepository.find();
|
||||
this.antennasFetched = true;
|
||||
}
|
||||
|
||||
return this.antennas;
|
||||
}
|
||||
}
|
40
packages/backend/src/core/AppLockService.ts
Normal file
40
packages/backend/src/core/AppLockService.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { promisify } from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import redisLock from 'redis-lock';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
/**
|
||||
* Retry delay (ms) for lock acquisition
|
||||
*/
|
||||
const retryDelay = 100;
|
||||
|
||||
@Injectable()
|
||||
export class AppLockService {
|
||||
private lock: (key: string, timeout?: number) => Promise<() => void>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
) {
|
||||
this.lock = promisify(redisLock(this.redisClient, retryDelay));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AP Object lock
|
||||
* @param uri AP object ID
|
||||
* @param timeout Lock timeout (ms), The timeout releases previous lock.
|
||||
* @returns Unlock function
|
||||
*/
|
||||
public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> {
|
||||
return this.lock(`ap-object:${uri}`, timeout);
|
||||
}
|
||||
|
||||
public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> {
|
||||
return this.lock(`instance:${host}`, timeout);
|
||||
}
|
||||
|
||||
public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> {
|
||||
return this.lock(`chart-insert:${lockKey}`, timeout);
|
||||
}
|
||||
}
|
70
packages/backend/src/core/CaptchaService.ts
Normal file
70
packages/backend/src/core/CaptchaService.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
|
||||
type CaptchaResponse = {
|
||||
success: boolean;
|
||||
'error-codes'?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
private async getCaptchaResponse(url: string, secret: string, response: string): Promise<CaptchaResponse> {
|
||||
const params = new URLSearchParams({
|
||||
secret,
|
||||
response,
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
// TODO
|
||||
//timeout: 10 * 1000,
|
||||
agent: (url, bypassProxy) => this.httpRequestService.getAgentByUrl(url, bypassProxy),
|
||||
}).catch(err => {
|
||||
throw `${err.message ?? err}`;
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw `${res.status}`;
|
||||
}
|
||||
|
||||
return await res.json() as CaptchaResponse;
|
||||
}
|
||||
|
||||
public async verifyRecaptcha(secret: string, response: string): Promise<void> {
|
||||
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => {
|
||||
throw `recaptcha-request-failed: ${e}`;
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `recaptcha-failed: ${errorCodes}`;
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyHcaptcha(secret: string, response: string): Promise<void> {
|
||||
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => {
|
||||
throw `hcaptcha-request-failed: ${e}`;
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `hcaptcha-failed: ${errorCodes}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
706
packages/backend/src/core/CoreModule.ts
Normal file
706
packages/backend/src/core/CoreModule.ts
Normal file
|
@ -0,0 +1,706 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '../di-symbols.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
import { AppLockService } from './AppLockService.js';
|
||||
import { CaptchaService } from './CaptchaService.js';
|
||||
import { CreateNotificationService } from './CreateNotificationService.js';
|
||||
import { CreateSystemUserService } from './CreateSystemUserService.js';
|
||||
import { CustomEmojiService } from './CustomEmojiService.js';
|
||||
import { DeleteAccountService } from './DeleteAccountService.js';
|
||||
import { DownloadService } from './DownloadService.js';
|
||||
import { DriveService } from './DriveService.js';
|
||||
import { EmailService } from './EmailService.js';
|
||||
import { FederatedInstanceService } from './FederatedInstanceService.js';
|
||||
import { FetchInstanceMetadataService } from './FetchInstanceMetadataService.js';
|
||||
import { GlobalEventService } from './GlobalEventService.js';
|
||||
import { HashtagService } from './HashtagService.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
import { IdService } from './IdService.js';
|
||||
import { ImageProcessingService } from './ImageProcessingService.js';
|
||||
import { InstanceActorService } from './InstanceActorService.js';
|
||||
import { InternalStorageService } from './InternalStorageService.js';
|
||||
import { MessagingService } from './MessagingService.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
import { MfmService } from './MfmService.js';
|
||||
import { ModerationLogService } from './ModerationLogService.js';
|
||||
import { NoteCreateService } from './NoteCreateService.js';
|
||||
import { NoteDeleteService } from './NoteDeleteService.js';
|
||||
import { NotePiningService } from './NotePiningService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { PollService } from './PollService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
import { QueryService } from './QueryService.js';
|
||||
import { ReactionService } from './ReactionService.js';
|
||||
import { RelayService } from './RelayService.js';
|
||||
import { S3Service } from './S3Service.js';
|
||||
import { SignupService } from './SignupService.js';
|
||||
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
import { UserCacheService } from './UserCacheService.js';
|
||||
import { UserFollowingService } from './UserFollowingService.js';
|
||||
import { UserKeypairStoreService } from './UserKeypairStoreService.js';
|
||||
import { UserListService } from './UserListService.js';
|
||||
import { UserMutingService } from './UserMutingService.js';
|
||||
import { UserSuspendService } from './UserSuspendService.js';
|
||||
import { VideoProcessingService } from './VideoProcessingService.js';
|
||||
import { WebhookService } from './WebhookService.js';
|
||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import { FileInfoService } from './FileInfoService.js';
|
||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||
import FederationChart from './chart/charts/federation.js';
|
||||
import NotesChart from './chart/charts/notes.js';
|
||||
import UsersChart from './chart/charts/users.js';
|
||||
import ActiveUsersChart from './chart/charts/active-users.js';
|
||||
import InstanceChart from './chart/charts/instance.js';
|
||||
import PerUserNotesChart from './chart/charts/per-user-notes.js';
|
||||
import DriveChart from './chart/charts/drive.js';
|
||||
import PerUserReactionsChart from './chart/charts/per-user-reactions.js';
|
||||
import HashtagChart from './chart/charts/hashtag.js';
|
||||
import PerUserFollowingChart from './chart/charts/per-user-following.js';
|
||||
import PerUserDriveChart from './chart/charts/per-user-drive.js';
|
||||
import ApRequestChart from './chart/charts/ap-request.js';
|
||||
import { ChartManagementService } from './chart/ChartManagementService.js';
|
||||
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
|
||||
import { AntennaEntityService } from './entities/AntennaEntityService.js';
|
||||
import { AppEntityService } from './entities/AppEntityService.js';
|
||||
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
|
||||
import { BlockingEntityService } from './entities/BlockingEntityService.js';
|
||||
import { ChannelEntityService } from './entities/ChannelEntityService.js';
|
||||
import { ClipEntityService } from './entities/ClipEntityService.js';
|
||||
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
|
||||
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
|
||||
import { EmojiEntityService } from './entities/EmojiEntityService.js';
|
||||
import { FollowingEntityService } from './entities/FollowingEntityService.js';
|
||||
import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js';
|
||||
import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js';
|
||||
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
|
||||
import { HashtagEntityService } from './entities/HashtagEntityService.js';
|
||||
import { InstanceEntityService } from './entities/InstanceEntityService.js';
|
||||
import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js';
|
||||
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
|
||||
import { MutingEntityService } from './entities/MutingEntityService.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js';
|
||||
import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js';
|
||||
import { NotificationEntityService } from './entities/NotificationEntityService.js';
|
||||
import { PageEntityService } from './entities/PageEntityService.js';
|
||||
import { PageLikeEntityService } from './entities/PageLikeEntityService.js';
|
||||
import { SigninEntityService } from './entities/SigninEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { UserGroupEntityService } from './entities/UserGroupEntityService.js';
|
||||
import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js';
|
||||
import { UserListEntityService } from './entities/UserListEntityService.js';
|
||||
import { ApAudienceService } from './remote/activitypub/ApAudienceService.js';
|
||||
import { ApDbResolverService } from './remote/activitypub/ApDbResolverService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { ApInboxService } from './remote/activitypub/ApInboxService.js';
|
||||
import { ApLoggerService } from './remote/activitypub/ApLoggerService.js';
|
||||
import { ApMfmService } from './remote/activitypub/ApMfmService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { ApRequestService } from './remote/activitypub/ApRequestService.js';
|
||||
import { ApResolverService } from './remote/activitypub/ApResolverService.js';
|
||||
import { LdSignatureService } from './remote/activitypub/LdSignatureService.js';
|
||||
import { RemoteLoggerService } from './remote/RemoteLoggerService.js';
|
||||
import { ResolveUserService } from './remote/ResolveUserService.js';
|
||||
import { WebfingerService } from './remote/WebfingerService.js';
|
||||
import { ApImageService } from './remote/activitypub/models/ApImageService.js';
|
||||
import { ApMentionService } from './remote/activitypub/models/ApMentionService.js';
|
||||
import { ApNoteService } from './remote/activitypub/models/ApNoteService.js';
|
||||
import { ApPersonService } from './remote/activitypub/models/ApPersonService.js';
|
||||
import { ApQuestionService } from './remote/activitypub/models/ApQuestionService.js';
|
||||
import { QueueModule } from './queue/QueueModule.js';
|
||||
import { QueueService } from './QueueService.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
|
||||
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
|
||||
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
|
||||
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
|
||||
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
|
||||
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
|
||||
const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService };
|
||||
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
|
||||
const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService };
|
||||
const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService };
|
||||
const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService };
|
||||
const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService };
|
||||
const $EmailService: Provider = { provide: 'EmailService', useExisting: EmailService };
|
||||
const $FederatedInstanceService: Provider = { provide: 'FederatedInstanceService', useExisting: FederatedInstanceService };
|
||||
const $FetchInstanceMetadataService: Provider = { provide: 'FetchInstanceMetadataService', useExisting: FetchInstanceMetadataService };
|
||||
const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisting: GlobalEventService };
|
||||
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
|
||||
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
|
||||
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
|
||||
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
|
||||
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
|
||||
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
||||
const $MessagingService: Provider = { provide: 'MessagingService', useExisting: MessagingService };
|
||||
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
||||
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
|
||||
const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService };
|
||||
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
|
||||
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
|
||||
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
||||
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
|
||||
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
|
||||
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
|
||||
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
|
||||
const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService };
|
||||
const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService };
|
||||
const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService };
|
||||
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
||||
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
|
||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||
const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService };
|
||||
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
|
||||
const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService };
|
||||
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
||||
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
|
||||
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
||||
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
|
||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart };
|
||||
const $UsersChart: Provider = { provide: 'UsersChart', useExisting: UsersChart };
|
||||
const $ActiveUsersChart: Provider = { provide: 'ActiveUsersChart', useExisting: ActiveUsersChart };
|
||||
const $InstanceChart: Provider = { provide: 'InstanceChart', useExisting: InstanceChart };
|
||||
const $PerUserNotesChart: Provider = { provide: 'PerUserNotesChart', useExisting: PerUserNotesChart };
|
||||
const $DriveChart: Provider = { provide: 'DriveChart', useExisting: DriveChart };
|
||||
const $PerUserReactionsChart: Provider = { provide: 'PerUserReactionsChart', useExisting: PerUserReactionsChart };
|
||||
const $HashtagChart: Provider = { provide: 'HashtagChart', useExisting: HashtagChart };
|
||||
const $PerUserFollowingChart: Provider = { provide: 'PerUserFollowingChart', useExisting: PerUserFollowingChart };
|
||||
const $PerUserDriveChart: Provider = { provide: 'PerUserDriveChart', useExisting: PerUserDriveChart };
|
||||
const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRequestChart };
|
||||
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
|
||||
|
||||
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
|
||||
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
|
||||
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
|
||||
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
|
||||
const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService };
|
||||
const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService };
|
||||
const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService };
|
||||
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
|
||||
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
|
||||
const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService };
|
||||
const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService };
|
||||
const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService };
|
||||
const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService };
|
||||
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
|
||||
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
|
||||
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
|
||||
const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useExisting: MessagingMessageEntityService };
|
||||
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
|
||||
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
|
||||
const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService };
|
||||
const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService };
|
||||
const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService };
|
||||
const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService };
|
||||
const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting: PageEntityService };
|
||||
const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService };
|
||||
const $SigninEntityService: Provider = { provide: 'SigninEntityService', useExisting: SigninEntityService };
|
||||
const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting: UserEntityService };
|
||||
const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService };
|
||||
const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService };
|
||||
const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService };
|
||||
|
||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
|
||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
|
||||
const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useExisting: ApDeliverManagerService };
|
||||
const $ApInboxService: Provider = { provide: 'ApInboxService', useExisting: ApInboxService };
|
||||
const $ApLoggerService: Provider = { provide: 'ApLoggerService', useExisting: ApLoggerService };
|
||||
const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmService };
|
||||
const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService };
|
||||
const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService };
|
||||
const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService };
|
||||
const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService };
|
||||
const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService };
|
||||
const $ResolveUserService: Provider = { provide: 'ResolveUserService', useExisting: ResolveUserService };
|
||||
const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService };
|
||||
const $ApImageService: Provider = { provide: 'ApImageService', useExisting: ApImageService };
|
||||
const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting: ApMentionService };
|
||||
const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService };
|
||||
const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService };
|
||||
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService };
|
||||
//#endregion
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
QueueModule,
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
AntennaService,
|
||||
AppLockService,
|
||||
CaptchaService,
|
||||
CreateNotificationService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
DriveService,
|
||||
EmailService,
|
||||
FederatedInstanceService,
|
||||
FetchInstanceMetadataService,
|
||||
GlobalEventService,
|
||||
HashtagService,
|
||||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalStorageService,
|
||||
MessagingService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
ModerationLogService,
|
||||
NoteCreateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
RelayService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
UserBlockingService,
|
||||
UserCacheService,
|
||||
UserFollowingService,
|
||||
UserKeypairStoreService,
|
||||
UserListService,
|
||||
UserMutingService,
|
||||
UserSuspendService,
|
||||
VideoProcessingService,
|
||||
WebhookService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
ActiveUsersChart,
|
||||
InstanceChart,
|
||||
PerUserNotesChart,
|
||||
DriveChart,
|
||||
PerUserReactionsChart,
|
||||
HashtagChart,
|
||||
PerUserFollowingChart,
|
||||
PerUserDriveChart,
|
||||
ApRequestChart,
|
||||
ChartManagementService,
|
||||
AbuseUserReportEntityService,
|
||||
AntennaEntityService,
|
||||
AppEntityService,
|
||||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
EmojiEntityService,
|
||||
FollowingEntityService,
|
||||
FollowRequestEntityService,
|
||||
GalleryLikeEntityService,
|
||||
GalleryPostEntityService,
|
||||
HashtagEntityService,
|
||||
InstanceEntityService,
|
||||
MessagingMessageEntityService,
|
||||
ModerationLogEntityService,
|
||||
MutingEntityService,
|
||||
NoteEntityService,
|
||||
NoteFavoriteEntityService,
|
||||
NoteReactionEntityService,
|
||||
NotificationEntityService,
|
||||
PageEntityService,
|
||||
PageLikeEntityService,
|
||||
SigninEntityService,
|
||||
UserEntityService,
|
||||
UserGroupEntityService,
|
||||
UserGroupInvitationEntityService,
|
||||
UserListEntityService,
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
ApDeliverManagerService,
|
||||
ApInboxService,
|
||||
ApLoggerService,
|
||||
ApMfmService,
|
||||
ApRendererService,
|
||||
ApRequestService,
|
||||
ApResolverService,
|
||||
LdSignatureService,
|
||||
RemoteLoggerService,
|
||||
ResolveUserService,
|
||||
WebfingerService,
|
||||
ApImageService,
|
||||
ApMentionService,
|
||||
ApNoteService,
|
||||
ApPersonService,
|
||||
ApQuestionService,
|
||||
QueueService,
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$LoggerService,
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$AntennaService,
|
||||
$AppLockService,
|
||||
$CaptchaService,
|
||||
$CreateNotificationService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
$DriveService,
|
||||
$EmailService,
|
||||
$FederatedInstanceService,
|
||||
$FetchInstanceMetadataService,
|
||||
$GlobalEventService,
|
||||
$HashtagService,
|
||||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalStorageService,
|
||||
$MessagingService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
$ModerationLogService,
|
||||
$NoteCreateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
$RelayService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$UserBlockingService,
|
||||
$UserCacheService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairStoreService,
|
||||
$UserListService,
|
||||
$UserMutingService,
|
||||
$UserSuspendService,
|
||||
$VideoProcessingService,
|
||||
$WebhookService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
$ActiveUsersChart,
|
||||
$InstanceChart,
|
||||
$PerUserNotesChart,
|
||||
$DriveChart,
|
||||
$PerUserReactionsChart,
|
||||
$HashtagChart,
|
||||
$PerUserFollowingChart,
|
||||
$PerUserDriveChart,
|
||||
$ApRequestChart,
|
||||
$ChartManagementService,
|
||||
$AbuseUserReportEntityService,
|
||||
$AntennaEntityService,
|
||||
$AppEntityService,
|
||||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
$EmojiEntityService,
|
||||
$FollowingEntityService,
|
||||
$FollowRequestEntityService,
|
||||
$GalleryLikeEntityService,
|
||||
$GalleryPostEntityService,
|
||||
$HashtagEntityService,
|
||||
$InstanceEntityService,
|
||||
$MessagingMessageEntityService,
|
||||
$ModerationLogEntityService,
|
||||
$MutingEntityService,
|
||||
$NoteEntityService,
|
||||
$NoteFavoriteEntityService,
|
||||
$NoteReactionEntityService,
|
||||
$NotificationEntityService,
|
||||
$PageEntityService,
|
||||
$PageLikeEntityService,
|
||||
$SigninEntityService,
|
||||
$UserEntityService,
|
||||
$UserGroupEntityService,
|
||||
$UserGroupInvitationEntityService,
|
||||
$UserListEntityService,
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
$ApDeliverManagerService,
|
||||
$ApInboxService,
|
||||
$ApLoggerService,
|
||||
$ApMfmService,
|
||||
$ApRendererService,
|
||||
$ApRequestService,
|
||||
$ApResolverService,
|
||||
$LdSignatureService,
|
||||
$RemoteLoggerService,
|
||||
$ResolveUserService,
|
||||
$WebfingerService,
|
||||
$ApImageService,
|
||||
$ApMentionService,
|
||||
$ApNoteService,
|
||||
$ApPersonService,
|
||||
$ApQuestionService,
|
||||
//#endregion
|
||||
],
|
||||
exports: [
|
||||
QueueModule,
|
||||
LoggerService,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
AntennaService,
|
||||
AppLockService,
|
||||
CaptchaService,
|
||||
CreateNotificationService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
DriveService,
|
||||
EmailService,
|
||||
FederatedInstanceService,
|
||||
FetchInstanceMetadataService,
|
||||
GlobalEventService,
|
||||
HashtagService,
|
||||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalStorageService,
|
||||
MessagingService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
ModerationLogService,
|
||||
NoteCreateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
RelayService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
UserBlockingService,
|
||||
UserCacheService,
|
||||
UserFollowingService,
|
||||
UserKeypairStoreService,
|
||||
UserListService,
|
||||
UserMutingService,
|
||||
UserSuspendService,
|
||||
VideoProcessingService,
|
||||
WebhookService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
ActiveUsersChart,
|
||||
InstanceChart,
|
||||
PerUserNotesChart,
|
||||
DriveChart,
|
||||
PerUserReactionsChart,
|
||||
HashtagChart,
|
||||
PerUserFollowingChart,
|
||||
PerUserDriveChart,
|
||||
ApRequestChart,
|
||||
ChartManagementService,
|
||||
AbuseUserReportEntityService,
|
||||
AntennaEntityService,
|
||||
AppEntityService,
|
||||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
EmojiEntityService,
|
||||
FollowingEntityService,
|
||||
FollowRequestEntityService,
|
||||
GalleryLikeEntityService,
|
||||
GalleryPostEntityService,
|
||||
HashtagEntityService,
|
||||
InstanceEntityService,
|
||||
MessagingMessageEntityService,
|
||||
ModerationLogEntityService,
|
||||
MutingEntityService,
|
||||
NoteEntityService,
|
||||
NoteFavoriteEntityService,
|
||||
NoteReactionEntityService,
|
||||
NotificationEntityService,
|
||||
PageEntityService,
|
||||
PageLikeEntityService,
|
||||
SigninEntityService,
|
||||
UserEntityService,
|
||||
UserGroupEntityService,
|
||||
UserGroupInvitationEntityService,
|
||||
UserListEntityService,
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
ApDeliverManagerService,
|
||||
ApInboxService,
|
||||
ApLoggerService,
|
||||
ApMfmService,
|
||||
ApRendererService,
|
||||
ApRequestService,
|
||||
ApResolverService,
|
||||
LdSignatureService,
|
||||
RemoteLoggerService,
|
||||
ResolveUserService,
|
||||
WebfingerService,
|
||||
ApImageService,
|
||||
ApMentionService,
|
||||
ApNoteService,
|
||||
ApPersonService,
|
||||
ApQuestionService,
|
||||
QueueService,
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$LoggerService,
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$AntennaService,
|
||||
$AppLockService,
|
||||
$CaptchaService,
|
||||
$CreateNotificationService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
$DriveService,
|
||||
$EmailService,
|
||||
$FederatedInstanceService,
|
||||
$FetchInstanceMetadataService,
|
||||
$GlobalEventService,
|
||||
$HashtagService,
|
||||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalStorageService,
|
||||
$MessagingService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
$ModerationLogService,
|
||||
$NoteCreateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
$RelayService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$UserBlockingService,
|
||||
$UserCacheService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairStoreService,
|
||||
$UserListService,
|
||||
$UserMutingService,
|
||||
$UserSuspendService,
|
||||
$VideoProcessingService,
|
||||
$WebhookService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
$ActiveUsersChart,
|
||||
$InstanceChart,
|
||||
$PerUserNotesChart,
|
||||
$DriveChart,
|
||||
$PerUserReactionsChart,
|
||||
$HashtagChart,
|
||||
$PerUserFollowingChart,
|
||||
$PerUserDriveChart,
|
||||
$ApRequestChart,
|
||||
$ChartManagementService,
|
||||
$AbuseUserReportEntityService,
|
||||
$AntennaEntityService,
|
||||
$AppEntityService,
|
||||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
$EmojiEntityService,
|
||||
$FollowingEntityService,
|
||||
$FollowRequestEntityService,
|
||||
$GalleryLikeEntityService,
|
||||
$GalleryPostEntityService,
|
||||
$HashtagEntityService,
|
||||
$InstanceEntityService,
|
||||
$MessagingMessageEntityService,
|
||||
$ModerationLogEntityService,
|
||||
$MutingEntityService,
|
||||
$NoteEntityService,
|
||||
$NoteFavoriteEntityService,
|
||||
$NoteReactionEntityService,
|
||||
$NotificationEntityService,
|
||||
$PageEntityService,
|
||||
$PageLikeEntityService,
|
||||
$SigninEntityService,
|
||||
$UserEntityService,
|
||||
$UserGroupEntityService,
|
||||
$UserGroupInvitationEntityService,
|
||||
$UserListEntityService,
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
$ApDeliverManagerService,
|
||||
$ApInboxService,
|
||||
$ApLoggerService,
|
||||
$ApMfmService,
|
||||
$ApRendererService,
|
||||
$ApRequestService,
|
||||
$ApResolverService,
|
||||
$LdSignatureService,
|
||||
$RemoteLoggerService,
|
||||
$ResolveUserService,
|
||||
$WebfingerService,
|
||||
$ApImageService,
|
||||
$ApMentionService,
|
||||
$ApNoteService,
|
||||
$ApPersonService,
|
||||
$ApQuestionService,
|
||||
//#endregion
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
114
packages/backend/src/core/CreateNotificationService.ts
Normal file
114
packages/backend/src/core/CreateNotificationService.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Notification } from '@/models/entities/Notification.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotificationEntityService } from './entities/NotificationEntityService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateNotificationService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private notificationEntityService: NotificationEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async createNotification(
|
||||
notifieeId: User['id'],
|
||||
type: Notification['type'],
|
||||
data: Partial<Notification>,
|
||||
): Promise<Notification | null> {
|
||||
if (data.notifierId && (notifieeId === data.notifierId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
|
||||
|
||||
const isMuted = profile?.mutingNotificationTypes.includes(type);
|
||||
|
||||
// Create notification
|
||||
const notification = await this.notificationsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
notifieeId: notifieeId,
|
||||
type: type,
|
||||
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
|
||||
isRead: isMuted,
|
||||
...data,
|
||||
} as Partial<Notification>)
|
||||
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const packed = await this.notificationEntityService.pack(notification, {});
|
||||
|
||||
// Publish notification event
|
||||
this.globalEventServie.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
|
||||
if (fresh == null) return; // 既に削除されているかもしれない
|
||||
if (fresh.isRead) return;
|
||||
|
||||
//#region ただしミュートしているユーザーからの通知なら無視
|
||||
const mutings = await this.mutingsRepository.findBy({
|
||||
muterId: notifieeId,
|
||||
});
|
||||
if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.globalEventServie.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||
|
||||
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
|
||||
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
|
||||
}, 2000);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
// TODO
|
||||
//const locales = await import('../../../../locales/index.js');
|
||||
|
||||
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
|
||||
|
||||
private async emailNotificationFollow(userId: User['id'], follower: User) {
|
||||
/*
|
||||
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
|
||||
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return;
|
||||
const locale = locales[userProfile.lang ?? 'ja-JP'];
|
||||
const i18n = new I18n(locale);
|
||||
// TODO: render user information html
|
||||
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
|
||||
*/
|
||||
}
|
||||
|
||||
private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) {
|
||||
/*
|
||||
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
|
||||
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return;
|
||||
const locale = locales[userProfile.lang ?? 'ja-JP'];
|
||||
const i18n = new I18n(locale);
|
||||
// TODO: render user information html
|
||||
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
|
||||
*/
|
||||
}
|
||||
}
|
80
packages/backend/src/core/CreateSystemUserService.ts
Normal file
80
packages/backend/src/core/CreateSystemUserService.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IsNull, DataSource } from 'typeorm';
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { UsedUsername } from '@/models/entities/UsedUsername.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import generateNativeUserToken from '@/misc/generate-native-user-token.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateSystemUserService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async createSystemUser(username: string): Promise<User> {
|
||||
const password = uuid();
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
const hash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Generate secret
|
||||
const secret = generateNativeUserToken();
|
||||
|
||||
const keyPair = await genRsaKeyPair(4096);
|
||||
|
||||
let account!: User;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const exist = await transactionalEntityManager.findOneBy(User, {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (exist) throw new Error('the user is already exists');
|
||||
|
||||
account = await transactionalEntityManager.insert(User, {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
username: username,
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: null,
|
||||
token: secret,
|
||||
isAdmin: false,
|
||||
isLocked: true,
|
||||
isExplorable: false,
|
||||
isBot: true,
|
||||
}).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0]));
|
||||
|
||||
await transactionalEntityManager.insert(UserKeypair, {
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: keyPair.privateKey,
|
||||
userId: account.id,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(UserProfile, {
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: false,
|
||||
password: hash,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(UsedUsername, {
|
||||
createdAt: new Date(),
|
||||
username: username.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
175
packages/backend/src/core/CustomEmojiService.ts
Normal file
175
packages/backend/src/core/CustomEmojiService.ts
Normal file
|
@ -0,0 +1,175 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { EmojisRepository } from '@/models/index.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import { ReactionService } from './ReactionService.js';
|
||||
|
||||
/**
|
||||
* 添付用絵文字情報
|
||||
*/
|
||||
type PopulatedEmoji = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
private cache: Cache<Emoji | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private utilityService: UtilityService,
|
||||
private reactionService: ReactionService,
|
||||
) {
|
||||
this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
}
|
||||
|
||||
public async add(data: {
|
||||
driveFile: DriveFile;
|
||||
name: string;
|
||||
category: string | null;
|
||||
aliases: string[];
|
||||
host: string | null;
|
||||
}): Promise<Emoji> {
|
||||
const emoji = await this.emojisRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
updatedAt: new Date(),
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
host: data.host,
|
||||
aliases: data.aliases,
|
||||
originalUrl: data.driveFile.url,
|
||||
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
|
||||
type: data.driveFile.webpublicType ?? data.driveFile.type,
|
||||
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
return emoji;
|
||||
}
|
||||
|
||||
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
||||
// クエリに使うホスト
|
||||
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
||||
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
||||
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
||||
: (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
|
||||
|
||||
host = this.utilityService.toPunyNullable(host);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
private parseEmojiStr(emojiName: string, noteUserHost: string | null) {
|
||||
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
|
||||
if (!match) return { name: null, host: null };
|
||||
|
||||
const name = match[1];
|
||||
|
||||
// ホスト正規化
|
||||
const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost));
|
||||
|
||||
return { name, host };
|
||||
}
|
||||
|
||||
/**
|
||||
* 添付用絵文字情報を解決する
|
||||
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
|
||||
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
|
||||
* @returns 絵文字情報, nullは未マッチを意味する
|
||||
*/
|
||||
public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
|
||||
const { name, host } = this.parseEmojiStr(emojiName, noteUserHost);
|
||||
if (name == null) return null;
|
||||
|
||||
const queryOrNull = async () => (await this.emojisRepository.findOneBy({
|
||||
name,
|
||||
host: host ?? IsNull(),
|
||||
})) ?? null;
|
||||
|
||||
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
|
||||
|
||||
if (emoji == null) return null;
|
||||
|
||||
const isLocal = emoji.host == null;
|
||||
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため
|
||||
const url = isLocal ? emojiUrl : `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`;
|
||||
|
||||
return {
|
||||
name: emojiName,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される)
|
||||
*/
|
||||
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
|
||||
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost)));
|
||||
return emojis.filter((x): x is PopulatedEmoji => x != null);
|
||||
}
|
||||
|
||||
public aggregateNoteEmojis(notes: Note[]) {
|
||||
let emojis: { name: string | null; host: string | null; }[] = [];
|
||||
for (const note of notes) {
|
||||
emojis = emojis.concat(note.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.userHost)));
|
||||
if (note.renote) {
|
||||
emojis = emojis.concat(note.renote.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
|
||||
if (note.renote.user) {
|
||||
emojis = emojis.concat(note.renote.user.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
|
||||
}
|
||||
}
|
||||
const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
|
||||
emojis = emojis.concat(customReactions);
|
||||
if (note.user) {
|
||||
emojis = emojis.concat(note.user.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.userHost)));
|
||||
}
|
||||
}
|
||||
return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
|
||||
*/
|
||||
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
||||
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const emojisQuery: any[] = [];
|
||||
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
||||
for (const host of hosts) {
|
||||
emojisQuery.push({
|
||||
name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)),
|
||||
host: host ?? IsNull(),
|
||||
});
|
||||
}
|
||||
const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({
|
||||
where: emojisQuery,
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
}) : [];
|
||||
for (const emoji of _emojis) {
|
||||
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||
}
|
||||
}
|
||||
}
|
38
packages/backend/src/core/DeleteAccountService.ts
Normal file
38
packages/backend/src/core/DeleteAccountService.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userSuspendService: UserSuspendService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async deleteAccount(user: {
|
||||
id: string;
|
||||
host: string | null;
|
||||
}): Promise<void> {
|
||||
// 物理削除する前にDelete activityを送信する
|
||||
await this.userSuspendService.doPostSuspend(user).catch(e => {});
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
|
||||
await this.usersRepository.update(user.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
// Terminate streaming
|
||||
this.globalEventServie.publishUserEvent(user.id, 'terminate', {});
|
||||
}
|
||||
}
|
125
packages/backend/src/core/DownloadService.ts
Normal file
125
packages/backend/src/core/DownloadService.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as stream from 'node:stream';
|
||||
import * as util from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
import PrivateIp from 'private-ip';
|
||||
import got, * as Got from 'got';
|
||||
import chalk from 'chalk';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
@Injectable()
|
||||
export class DownloadService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('download');
|
||||
}
|
||||
|
||||
public async downloadUrl(url: string, path: string): Promise<void> {
|
||||
this.logger.info(`Downloading ${chalk.cyan(url)} ...`);
|
||||
|
||||
const timeout = 30 * 1000;
|
||||
const operationTimeout = 60 * 1000;
|
||||
const maxSize = this.config.maxFileSize ?? 262144000;
|
||||
|
||||
const req = got.stream(url, {
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
timeout: {
|
||||
lookup: timeout,
|
||||
connect: timeout,
|
||||
secureConnect: timeout,
|
||||
socket: timeout, // read timeout
|
||||
response: timeout,
|
||||
send: timeout,
|
||||
request: operationTimeout, // whole operation timeout
|
||||
},
|
||||
agent: {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
},
|
||||
http2: false, // default
|
||||
retry: {
|
||||
limit: 0,
|
||||
},
|
||||
}).on('response', (res: Got.Response) => {
|
||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
||||
if (this.isPrivateIp(res.ip)) {
|
||||
this.logger.warn(`Blocked address: ${res.ip}`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = res.headers['content-length'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
if (size > maxSize) {
|
||||
this.logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||
if (progress.transferred > maxSize) {
|
||||
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(req, fs.createWriteStream(path));
|
||||
} catch (e) {
|
||||
if (e instanceof Got.HTTPError) {
|
||||
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
|
||||
}
|
||||
|
||||
public async downloadTextFile(url: string): Promise<string> {
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
this.logger.info(`text file: Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
// write content at URL to temp file
|
||||
await this.downloadUrl(url, path);
|
||||
|
||||
const text = await util.promisify(fs.readFile)(path, 'utf8');
|
||||
|
||||
return text;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = new IPCIDR(net);
|
||||
if (cidr.contains(ip)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return PrivateIp(ip);
|
||||
}
|
||||
}
|
740
packages/backend/src/core/DriveService.ts
Normal file
740
packages/backend/src/core/DriveService.ts
Normal file
|
@ -0,0 +1,740 @@
|
|||
import * as fs from 'node:fs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import sharp from 'sharp';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
import type { IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { DriveFolder } from '@/models/entities/DriveFolder.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import DriveChart from '@/core/chart/charts/drive.js';
|
||||
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { S3Service } from '@/core/S3Service.js';
|
||||
import { InternalStorageService } from '@/core/InternalStorageService.js';
|
||||
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { FileInfoService } from './FileInfoService.js';
|
||||
import type S3 from 'aws-sdk/clients/s3.js';
|
||||
|
||||
type AddFileArgs = {
|
||||
/** User who wish to add file */
|
||||
user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null;
|
||||
/** File path */
|
||||
path: string;
|
||||
/** Name */
|
||||
name?: string | null;
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
/** Folder ID */
|
||||
folderId?: any;
|
||||
/** If set to true, forcibly upload the file even if there is a file with the same hash. */
|
||||
force?: boolean;
|
||||
/** Do not save file to local */
|
||||
isLink?: boolean;
|
||||
/** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */
|
||||
url?: string | null;
|
||||
/** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */
|
||||
uri?: string | null;
|
||||
/** Mark file as sensitive */
|
||||
sensitive?: boolean | null;
|
||||
|
||||
requestIp?: string | null;
|
||||
requestHeaders?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
type UploadFromUrlArgs = {
|
||||
url: string;
|
||||
user: { id: User['id']; host: User['host'] } | null;
|
||||
folderId?: DriveFolder['id'] | null;
|
||||
uri?: string | null;
|
||||
sensitive?: boolean;
|
||||
force?: boolean;
|
||||
isLink?: boolean;
|
||||
comment?: string | null;
|
||||
requestIp?: string | null;
|
||||
requestHeaders?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DriveService {
|
||||
private registerLogger: Logger;
|
||||
private downloaderLogger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.driveFoldersRepository)
|
||||
private driveFoldersRepository: DriveFoldersRepository,
|
||||
|
||||
private fileInfoService: FileInfoService,
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private downloadService: DownloadService,
|
||||
private internalStorageService: InternalStorageService,
|
||||
private s3Service: S3Service,
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
private videoProcessingService: VideoProcessingService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private driveChart: DriveChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {
|
||||
const logger = new Logger('drive', 'blue');
|
||||
this.registerLogger = logger.createSubLogger('register', 'yellow');
|
||||
this.downloaderLogger = logger.createSubLogger('downloader');
|
||||
}
|
||||
|
||||
/***
|
||||
* Save file
|
||||
* @param path Path for original
|
||||
* @param name Name for original
|
||||
* @param type Content-Type for original
|
||||
* @param hash Hash for original
|
||||
* @param size Size for original
|
||||
*/
|
||||
private async save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<DriveFile> {
|
||||
// thunbnail, webpublic を必要なら生成
|
||||
const alts = await this.generateAlts(path, type, !file.uri);
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.useObjectStorage) {
|
||||
//#region ObjectStorage params
|
||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||
|
||||
if (ext === '') {
|
||||
if (type === 'image/jpeg') ext = '.jpg';
|
||||
if (type === 'image/png') ext = '.png';
|
||||
if (type === 'image/webp') ext = '.webp';
|
||||
if (type === 'image/apng') ext = '.apng';
|
||||
if (type === 'image/vnd.mozilla.apng') ext = '.apng';
|
||||
}
|
||||
|
||||
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
|
||||
// 許可されているファイル形式でしか拡張子をつけない
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) {
|
||||
ext = '';
|
||||
}
|
||||
|
||||
const baseUrl = meta.objectStorageBaseUrl
|
||||
?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
|
||||
|
||||
// for original
|
||||
const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`;
|
||||
const url = `${ baseUrl }/${ key }`;
|
||||
|
||||
// for alts
|
||||
let webpublicKey: string | null = null;
|
||||
let webpublicUrl: string | null = null;
|
||||
let thumbnailKey: string | null = null;
|
||||
let thumbnailUrl: string | null = null;
|
||||
//#endregion
|
||||
|
||||
//#region Uploads
|
||||
this.registerLogger.info(`uploading original: ${key}`);
|
||||
const uploads = [
|
||||
this.upload(key, fs.createReadStream(path), type, name),
|
||||
];
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`;
|
||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
||||
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
|
||||
}
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`;
|
||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
||||
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
|
||||
}
|
||||
|
||||
await Promise.all(uploads);
|
||||
//#endregion
|
||||
|
||||
file.url = url;
|
||||
file.thumbnailUrl = thumbnailUrl;
|
||||
file.webpublicUrl = webpublicUrl;
|
||||
file.accessKey = key;
|
||||
file.thumbnailAccessKey = thumbnailKey;
|
||||
file.webpublicAccessKey = webpublicKey;
|
||||
file.webpublicType = alts.webpublic?.type ?? null;
|
||||
file.name = name;
|
||||
file.type = type;
|
||||
file.md5 = hash;
|
||||
file.size = size;
|
||||
file.storedInternal = false;
|
||||
|
||||
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
} else { // use internal storage
|
||||
const accessKey = uuid();
|
||||
const thumbnailAccessKey = 'thumbnail-' + uuid();
|
||||
const webpublicAccessKey = 'webpublic-' + uuid();
|
||||
|
||||
const url = this.internalStorageService.saveFromPath(accessKey, path);
|
||||
|
||||
let thumbnailUrl: string | null = null;
|
||||
let webpublicUrl: string | null = null;
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data);
|
||||
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||
}
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data);
|
||||
this.registerLogger.info(`web stored: ${webpublicAccessKey}`);
|
||||
}
|
||||
|
||||
file.storedInternal = true;
|
||||
file.url = url;
|
||||
file.thumbnailUrl = thumbnailUrl;
|
||||
file.webpublicUrl = webpublicUrl;
|
||||
file.accessKey = accessKey;
|
||||
file.thumbnailAccessKey = thumbnailAccessKey;
|
||||
file.webpublicAccessKey = webpublicAccessKey;
|
||||
file.webpublicType = alts.webpublic?.type ?? null;
|
||||
file.name = name;
|
||||
file.type = type;
|
||||
file.md5 = hash;
|
||||
file.size = size;
|
||||
|
||||
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate webpublic, thumbnail, etc
|
||||
* @param path Path for original
|
||||
* @param type Content-Type for original
|
||||
* @param generateWeb Generate webpublic or not
|
||||
*/
|
||||
public async generateAlts(path: string, type: string, generateWeb: boolean) {
|
||||
if (type.startsWith('video/')) {
|
||||
try {
|
||||
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail,
|
||||
};
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) {
|
||||
this.registerLogger.debug('web image and thumbnail not created (not an required file)');
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
let img: sharp.Sharp | null = null;
|
||||
let satisfyWebpublic: boolean;
|
||||
|
||||
try {
|
||||
img = sharp(path);
|
||||
const metadata = await img.metadata();
|
||||
const isAnimated = metadata.pages && metadata.pages > 1;
|
||||
|
||||
// skip animated
|
||||
if (isAnimated) {
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
satisfyWebpublic = !!(
|
||||
type !== 'image/svg+xml' && type !== 'image/webp' &&
|
||||
!(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) &&
|
||||
metadata.width && metadata.width <= 2048 &&
|
||||
metadata.height && metadata.height <= 2048
|
||||
);
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`sharp failed: ${err}`);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
// #region webpublic
|
||||
let webpublic: IImage | null = null;
|
||||
|
||||
if (generateWeb && !satisfyWebpublic) {
|
||||
this.registerLogger.info('creating web image');
|
||||
|
||||
try {
|
||||
if (['image/jpeg', 'image/webp'].includes(type)) {
|
||||
webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048);
|
||||
} else if (['image/png'].includes(type)) {
|
||||
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
|
||||
} else if (['image/svg+xml'].includes(type)) {
|
||||
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
|
||||
} else {
|
||||
this.registerLogger.debug('web image not created (not an required image)');
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.warn('web image not created (an error occured)', err as Error);
|
||||
}
|
||||
} else {
|
||||
if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)');
|
||||
else this.registerLogger.info('web image not created (from remote)');
|
||||
}
|
||||
// #endregion webpublic
|
||||
|
||||
// #region thumbnail
|
||||
let thumbnail: IImage | null = null;
|
||||
|
||||
try {
|
||||
if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) {
|
||||
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280);
|
||||
} else {
|
||||
this.registerLogger.debug('thumbnail not created (not an required file)');
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.warn('thumbnail not created (an error occured)', err as Error);
|
||||
}
|
||||
// #endregion thumbnail
|
||||
|
||||
return {
|
||||
webpublic,
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload to ObjectStorage
|
||||
*/
|
||||
private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
|
||||
if (type === 'image/apng') type = 'image/png';
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const params = {
|
||||
Bucket: meta.objectStorageBucket,
|
||||
Key: key,
|
||||
Body: stream,
|
||||
ContentType: type,
|
||||
CacheControl: 'max-age=31536000, immutable',
|
||||
} as S3.PutObjectRequest;
|
||||
|
||||
if (filename) params.ContentDisposition = contentDisposition('inline', filename);
|
||||
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
|
||||
const s3 = this.s3Service.getS3(meta);
|
||||
|
||||
const upload = s3.upload(params, {
|
||||
partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const result = await upload.promise();
|
||||
if (result) this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
}
|
||||
|
||||
private async deleteOldFile(user: IRemoteUser) {
|
||||
const q = this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId', { userId: user.id })
|
||||
.andWhere('file.isLink = FALSE');
|
||||
|
||||
if (user.avatarId) {
|
||||
q.andWhere('file.id != :avatarId', { avatarId: user.avatarId });
|
||||
}
|
||||
|
||||
if (user.bannerId) {
|
||||
q.andWhere('file.id != :bannerId', { bannerId: user.bannerId });
|
||||
}
|
||||
|
||||
q.orderBy('file.id', 'ASC');
|
||||
|
||||
const oldFile = await q.getOne();
|
||||
|
||||
if (oldFile) {
|
||||
this.deleteFile(oldFile, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file to drive
|
||||
*
|
||||
*/
|
||||
public async addFile({
|
||||
user,
|
||||
path,
|
||||
name = null,
|
||||
comment = null,
|
||||
folderId = null,
|
||||
force = false,
|
||||
isLink = false,
|
||||
url = null,
|
||||
uri = null,
|
||||
sensitive = null,
|
||||
requestIp = null,
|
||||
requestHeaders = null,
|
||||
}: AddFileArgs): Promise<DriveFile> {
|
||||
let skipNsfwCheck = false;
|
||||
const instance = await this.metaService.fetch();
|
||||
if (user == null) skipNsfwCheck = true;
|
||||
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
|
||||
if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
|
||||
if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
|
||||
|
||||
const info = await this.fileInfoService.getFileInfo(path, {
|
||||
skipSensitiveDetection: skipNsfwCheck,
|
||||
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
|
||||
instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||
0.5,
|
||||
sensitiveThresholdForPorn: 0.75,
|
||||
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
|
||||
});
|
||||
this.registerLogger.info(`${JSON.stringify(info)}`);
|
||||
|
||||
// 現状 false positive が多すぎて実用に耐えない
|
||||
//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
|
||||
// throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
|
||||
//}
|
||||
|
||||
// detect name
|
||||
const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
|
||||
|
||||
if (user && !force) {
|
||||
// Check if there is a file with the same hash
|
||||
const much = await this.driveFilesRepository.findOneBy({
|
||||
md5: info.md5,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (much) {
|
||||
this.registerLogger.info(`file with same hash is found: ${much.id}`);
|
||||
return much;
|
||||
}
|
||||
}
|
||||
|
||||
//#region Check drive usage
|
||||
if (user && !isLink) {
|
||||
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
|
||||
const u = await this.usersRepository.findOneBy({ id: user.id });
|
||||
|
||||
const instance = await this.metaService.fetch();
|
||||
let driveCapacity = 1024 * 1024 * (this.userEntityService.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
|
||||
|
||||
if (this.userEntityService.isLocalUser(user) && u?.driveCapacityOverrideMb != null) {
|
||||
driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb;
|
||||
this.registerLogger.debug('drive capacity override applied');
|
||||
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
|
||||
}
|
||||
|
||||
this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
|
||||
|
||||
// If usage limit exceeded
|
||||
if (usage + info.size > driveCapacity) {
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
|
||||
} else {
|
||||
// (アバターまたはバナーを含まず)最も古いファイルを削除する
|
||||
this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as IRemoteUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const fetchFolder = async () => {
|
||||
if (!folderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const driveFolder = await this.driveFoldersRepository.findOneBy({
|
||||
id: folderId,
|
||||
userId: user ? user.id : IsNull(),
|
||||
});
|
||||
|
||||
if (driveFolder == null) throw new Error('folder-not-found');
|
||||
|
||||
return driveFolder;
|
||||
};
|
||||
|
||||
const properties: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
} = {};
|
||||
|
||||
if (info.width) {
|
||||
properties['width'] = info.width;
|
||||
properties['height'] = info.height;
|
||||
}
|
||||
if (info.orientation != null) {
|
||||
properties['orientation'] = info.orientation;
|
||||
}
|
||||
|
||||
const profile = user ? await this.userProfilesRepository.findOneBy({ userId: user.id }) : null;
|
||||
|
||||
const folder = await fetchFolder();
|
||||
|
||||
let file = new DriveFile();
|
||||
file.id = this.idService.genId();
|
||||
file.createdAt = new Date();
|
||||
file.userId = user ? user.id : null;
|
||||
file.userHost = user ? user.host : null;
|
||||
file.folderId = folder !== null ? folder.id : null;
|
||||
file.comment = comment;
|
||||
file.properties = properties;
|
||||
file.blurhash = info.blurhash ?? null;
|
||||
file.isLink = isLink;
|
||||
file.requestIp = requestIp;
|
||||
file.requestHeaders = requestHeaders;
|
||||
file.maybeSensitive = info.sensitive;
|
||||
file.maybePorn = info.porn;
|
||||
file.isSensitive = user
|
||||
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
|
||||
(sensitive !== null && sensitive !== undefined)
|
||||
? sensitive
|
||||
: false
|
||||
: false;
|
||||
|
||||
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
|
||||
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
|
||||
|
||||
if (url !== null) {
|
||||
file.src = url;
|
||||
|
||||
if (isLink) {
|
||||
file.url = url;
|
||||
// ローカルプロキシ用
|
||||
file.accessKey = uuid();
|
||||
file.thumbnailAccessKey = 'thumbnail-' + uuid();
|
||||
file.webpublicAccessKey = 'webpublic-' + uuid();
|
||||
}
|
||||
}
|
||||
|
||||
if (uri !== null) {
|
||||
file.uri = uri;
|
||||
}
|
||||
|
||||
if (isLink) {
|
||||
try {
|
||||
file.size = 0;
|
||||
file.md5 = info.md5;
|
||||
file.name = detectedName;
|
||||
file.type = info.type.mime;
|
||||
file.storedInternal = false;
|
||||
|
||||
file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
} catch (err) {
|
||||
// duplicate key error (when already registered)
|
||||
if (isDuplicateKeyValueError(err)) {
|
||||
this.registerLogger.info(`already registered ${file.uri}`);
|
||||
|
||||
file = await this.driveFilesRepository.findOneBy({
|
||||
uri: file.uri!,
|
||||
userId: user ? user.id : IsNull(),
|
||||
}) as DriveFile;
|
||||
} else {
|
||||
this.registerLogger.error(err as Error);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
file = await (this.save(file, path, detectedName, info.type.mime, info.md5, info.size));
|
||||
}
|
||||
|
||||
this.registerLogger.succ(`drive file has been created ${file.id}`);
|
||||
|
||||
if (user) {
|
||||
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
|
||||
// Publish driveFileCreated event
|
||||
this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile);
|
||||
this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile);
|
||||
});
|
||||
}
|
||||
|
||||
// 統計を更新
|
||||
this.driveChart.update(file, true);
|
||||
this.perUserDriveChart.update(file, true);
|
||||
if (file.userHost !== null) {
|
||||
this.instanceChart.updateDrive(file, true);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public async deleteFile(file: DriveFile, isExpired = false) {
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!);
|
||||
}
|
||||
}
|
||||
|
||||
this.deletePostProcess(file, isExpired);
|
||||
}
|
||||
|
||||
public async deleteFileSync(file: DriveFile, isExpired = false) {
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
const promises = [];
|
||||
|
||||
promises.push(this.deleteObjectStorageFile(file.accessKey!));
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
promises.push(this.deleteObjectStorageFile(file.thumbnailAccessKey!));
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
this.deletePostProcess(file, isExpired);
|
||||
}
|
||||
|
||||
private async deletePostProcess(file: DriveFile, isExpired = false) {
|
||||
// リモートファイル期限切れ削除後は直リンクにする
|
||||
if (isExpired && file.userHost !== null && file.uri != null) {
|
||||
this.driveFilesRepository.update(file.id, {
|
||||
isLink: true,
|
||||
url: file.uri,
|
||||
thumbnailUrl: null,
|
||||
webpublicUrl: null,
|
||||
storedInternal: false,
|
||||
// ローカルプロキシ用
|
||||
accessKey: uuid(),
|
||||
thumbnailAccessKey: 'thumbnail-' + uuid(),
|
||||
webpublicAccessKey: 'webpublic-' + uuid(),
|
||||
});
|
||||
} else {
|
||||
this.driveFilesRepository.delete(file.id);
|
||||
}
|
||||
|
||||
// 統計を更新
|
||||
this.driveChart.update(file, false);
|
||||
this.perUserDriveChart.update(file, false);
|
||||
if (file.userHost !== null) {
|
||||
this.instanceChart.updateDrive(file, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteObjectStorageFile(key: string) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const s3 = this.s3Service.getS3(meta);
|
||||
|
||||
await s3.deleteObject({
|
||||
Bucket: meta.objectStorageBucket!,
|
||||
Key: key,
|
||||
}).promise();
|
||||
}
|
||||
|
||||
public async uploadFromUrl({
|
||||
url,
|
||||
user,
|
||||
folderId = null,
|
||||
uri = null,
|
||||
sensitive = false,
|
||||
force = false,
|
||||
isLink = false,
|
||||
comment = null,
|
||||
requestIp = null,
|
||||
requestHeaders = null,
|
||||
}: UploadFromUrlArgs): Promise<DriveFile> {
|
||||
let name = new URL(url).pathname.split('/').pop() ?? null;
|
||||
if (name == null || !this.driveFileEntityService.validateFileName(name)) {
|
||||
name = null;
|
||||
}
|
||||
|
||||
// If the comment is same as the name, skip comment
|
||||
// (image.name is passed in when receiving attachment)
|
||||
if (comment !== null && name === comment) {
|
||||
comment = null;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
try {
|
||||
// write content at URL to temp file
|
||||
await this.downloadService.downloadUrl(url, path);
|
||||
|
||||
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
|
||||
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
|
||||
return driveFile!;
|
||||
} catch (err) {
|
||||
this.downloaderLogger.error(`Failed to create drive file: ${err}`, {
|
||||
url: url,
|
||||
e: err,
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
177
packages/backend/src/core/EmailService.ts
Normal file
177
packages/backend/src/core/EmailService.ts
Normal file
|
@ -0,0 +1,177 @@
|
|||
import * as nodemailer from 'nodemailer';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { validate as validateEmail } from 'deep-email-validator';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { UserProfilesRepository } from '@/models/index.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('email');
|
||||
}
|
||||
|
||||
public async sendEmail(to: string, subject: string, html: string, text: string) {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
|
||||
const emailSettingUrl = `${this.config.url}/settings/email`;
|
||||
|
||||
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: meta.smtpHost,
|
||||
port: meta.smtpPort,
|
||||
secure: meta.smtpSecure,
|
||||
ignoreTLS: !enableAuth,
|
||||
proxy: this.config.proxySmtp,
|
||||
auth: enableAuth ? {
|
||||
user: meta.smtpUser,
|
||||
pass: meta.smtpPass,
|
||||
} : undefined,
|
||||
} as any);
|
||||
|
||||
try {
|
||||
// TODO: htmlサニタイズ
|
||||
const info = await transporter.sendMail({
|
||||
from: meta.email!,
|
||||
to: to,
|
||||
subject: subject,
|
||||
text: text,
|
||||
html: `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${ subject }</title>
|
||||
<style>
|
||||
html {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #86b300;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
color: #555;
|
||||
}
|
||||
main > header {
|
||||
padding: 32px;
|
||||
background: #86b300;
|
||||
}
|
||||
main > header > img {
|
||||
max-width: 128px;
|
||||
max-height: 28px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
main > article {
|
||||
padding: 32px;
|
||||
}
|
||||
main > article > h1 {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
main > footer {
|
||||
padding: 32px;
|
||||
border-top: solid 1px #eee;
|
||||
}
|
||||
|
||||
nav {
|
||||
box-sizing: border-box;
|
||||
max-width: 500px;
|
||||
margin: 16px auto 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
nav > a {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<img src="${ meta.logoImageUrl ?? meta.iconUrl ?? iconUrl }"/>
|
||||
</header>
|
||||
<article>
|
||||
<h1>${ subject }</h1>
|
||||
<div>${ html }</div>
|
||||
</article>
|
||||
<footer>
|
||||
<a href="${ emailSettingUrl }">${ 'Email setting' }</a>
|
||||
</footer>
|
||||
</main>
|
||||
<nav>
|
||||
<a href="${ this.config.url }">${ this.config.host }</a>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
this.logger.info(`Message sent: ${info.messageId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(err as Error);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async validateEmailForAccount(emailAddress: string): Promise<{
|
||||
available: boolean;
|
||||
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp';
|
||||
}> {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const exist = await this.userProfilesRepository.countBy({
|
||||
emailVerified: true,
|
||||
email: emailAddress,
|
||||
});
|
||||
|
||||
const validated = meta.enableActiveEmailValidation ? await validateEmail({
|
||||
email: emailAddress,
|
||||
validateRegex: true,
|
||||
validateMx: true,
|
||||
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
|
||||
validateDisposable: true, // 捨てアドかどうかチェック
|
||||
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
|
||||
}) : { valid: true };
|
||||
|
||||
const available = exist === 0 && validated.valid;
|
||||
|
||||
return {
|
||||
available,
|
||||
reason: available ? null :
|
||||
exist !== 0 ? 'used' :
|
||||
validated.reason === 'regex' ? 'format' :
|
||||
validated.reason === 'disposable' ? 'disposable' :
|
||||
validated.reason === 'mx' ? 'mx' :
|
||||
validated.reason === 'smtp' ? 'smtp' :
|
||||
null,
|
||||
};
|
||||
}
|
||||
}
|
46
packages/backend/src/core/FederatedInstanceService.ts
Normal file
46
packages/backend/src/core/FederatedInstanceService.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InstancesRepository } from '@/models/index.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService {
|
||||
private cache: Cache<Instance>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.cache = new Cache<Instance>(1000 * 60 * 60);
|
||||
}
|
||||
|
||||
public async registerOrFetchInstanceDoc(host: string): Promise<Instance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = this.cache.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
const i = await this.instancesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
host,
|
||||
caughtAt: new Date(),
|
||||
lastCommunicatedAt: new Date(),
|
||||
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
this.cache.set(host, i);
|
||||
return i;
|
||||
} else {
|
||||
this.cache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
286
packages/backend/src/core/FetchInstanceMetadataService.ts
Normal file
286
packages/backend/src/core/FetchInstanceMetadataService.ts
Normal file
|
@ -0,0 +1,286 @@
|
|||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import fetch from 'node-fetch';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import { InstancesRepository } from '@/models/index.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
import type { DOMWindow } from 'jsdom';
|
||||
|
||||
type NodeInfo = {
|
||||
openRegistrations?: any;
|
||||
software?: {
|
||||
name?: any;
|
||||
version?: any;
|
||||
};
|
||||
metadata?: {
|
||||
name?: any;
|
||||
nodeName?: any;
|
||||
nodeDescription?: any;
|
||||
description?: any;
|
||||
maintainer?: {
|
||||
name?: any;
|
||||
email?: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FetchInstanceMetadataService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('metadata', 'cyan');
|
||||
}
|
||||
|
||||
public async fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
|
||||
const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host);
|
||||
|
||||
if (!force) {
|
||||
const _instance = await this.instancesRepository.findOneBy({ host: instance.host });
|
||||
const now = Date.now();
|
||||
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
|
||||
unlock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Fetching metadata of ${instance.host} ...`);
|
||||
|
||||
try {
|
||||
const [info, dom, manifest] = await Promise.all([
|
||||
this.fetchNodeinfo(instance).catch(() => null),
|
||||
this.fetchDom(instance).catch(() => null),
|
||||
this.fetchManifest(instance).catch(() => null),
|
||||
]);
|
||||
|
||||
const [favicon, icon, themeColor, name, description] = await Promise.all([
|
||||
this.fetchFaviconUrl(instance, dom).catch(() => null),
|
||||
this.fetchIconUrl(instance, dom, manifest).catch(() => null),
|
||||
this.getThemeColor(info, dom, manifest).catch(() => null),
|
||||
this.getSiteName(info, dom, manifest).catch(() => null),
|
||||
this.getDescription(info, dom, manifest).catch(() => null),
|
||||
]);
|
||||
|
||||
this.logger.succ(`Successfuly fetched metadata of ${instance.host}`);
|
||||
|
||||
const updates = {
|
||||
infoUpdatedAt: new Date(),
|
||||
} as Record<string, any>;
|
||||
|
||||
if (info) {
|
||||
updates.softwareName = info.software?.name.toLowerCase();
|
||||
updates.softwareVersion = info.software?.version;
|
||||
updates.openRegistrations = info.openRegistrations;
|
||||
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
|
||||
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
|
||||
}
|
||||
|
||||
if (name) updates.name = name;
|
||||
if (description) updates.description = description;
|
||||
if (icon || favicon) updates.iconUrl = icon ?? favicon;
|
||||
if (favicon) updates.faviconUrl = favicon;
|
||||
if (themeColor) updates.themeColor = themeColor;
|
||||
|
||||
await this.instancesRepository.update(instance.id, updates);
|
||||
|
||||
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
|
||||
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
|
||||
|
||||
try {
|
||||
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
|
||||
.catch(err => {
|
||||
if (err.statusCode === 404) {
|
||||
throw 'No nodeinfo provided';
|
||||
} else {
|
||||
throw err.statusCode ?? err.message;
|
||||
}
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
|
||||
throw 'No wellknown links';
|
||||
}
|
||||
|
||||
const links = wellknown.links as any[];
|
||||
|
||||
const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
|
||||
const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
|
||||
const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1');
|
||||
const link = lnik2_1 ?? lnik2_0 ?? lnik1_0;
|
||||
|
||||
if (link == null) {
|
||||
throw 'No nodeinfo link provided';
|
||||
}
|
||||
|
||||
const info = await this.httpRequestService.getJson(link.href)
|
||||
.catch(err => {
|
||||
throw err.statusCode ?? err.message;
|
||||
});
|
||||
|
||||
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
|
||||
|
||||
return info as NodeInfo;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDom(instance: Instance): Promise<DOMWindow['document']> {
|
||||
this.logger.info(`Fetching HTML of ${instance.host} ...`);
|
||||
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
const html = await this.httpRequestService.getHtml(url);
|
||||
|
||||
const { window } = new JSDOM(html);
|
||||
const doc = window.document;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async fetchManifest(instance: Instance): Promise<Record<string, unknown> | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
const manifestUrl = url + '/manifest.json';
|
||||
|
||||
const manifest = await this.httpRequestService.getJson(manifestUrl) as Record<string, unknown>;
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private async fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
if (doc) {
|
||||
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
|
||||
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
|
||||
|
||||
if (href) {
|
||||
return (new URL(href, url)).href;
|
||||
}
|
||||
}
|
||||
|
||||
const faviconUrl = url + '/favicon.ico';
|
||||
|
||||
const favicon = await fetch(faviconUrl, {
|
||||
// TODO
|
||||
//timeout: 10000,
|
||||
agent: url => this.httpRequestService.getAgentByUrl(url),
|
||||
});
|
||||
|
||||
if (favicon.ok) {
|
||||
return faviconUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
|
||||
const url = 'https://' + instance.host;
|
||||
return (new URL(manifest.icons[0].src, url)).href;
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
|
||||
const links = Array.from(doc.getElementsByTagName('link')).reverse();
|
||||
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
|
||||
const href =
|
||||
[
|
||||
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
|
||||
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
|
||||
links.find(link => link.relList.contains('icon'))?.href,
|
||||
]
|
||||
.find(href => href);
|
||||
|
||||
if (href) {
|
||||
return (new URL(href, url)).href;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
|
||||
|
||||
if (themeColor) {
|
||||
const color = new tinycolor(themeColor);
|
||||
if (color.isValid()) return color.toHexString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (info.metadata.nodeName || info.metadata.name) {
|
||||
return info.metadata.nodeName ?? info.metadata.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
|
||||
|
||||
if (og) {
|
||||
return og;
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
return manifest.name ?? manifest.short_name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (info.metadata.nodeDescription || info.metadata.description) {
|
||||
return info.metadata.nodeDescription ?? info.metadata.description;
|
||||
}
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
|
||||
if (meta) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
|
||||
if (og) {
|
||||
return og;
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
return manifest.name ?? manifest.short_name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
382
packages/backend/src/core/FileInfoService.ts
Normal file
382
packages/backend/src/core/FileInfoService.ts
Normal file
|
@ -0,0 +1,382 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import * as stream from 'node:stream';
|
||||
import * as util from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { FSWatcher } from 'chokidar';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import isSvg from 'is-svg';
|
||||
import probeImageSize from 'probe-image-size';
|
||||
import { type predictionType } from 'nsfwjs';
|
||||
import sharp from 'sharp';
|
||||
import { encode } from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
md5: string;
|
||||
type: {
|
||||
mime: string;
|
||||
ext: string | null;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
blurhash?: string;
|
||||
sensitive: boolean;
|
||||
porn: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const TYPE_OCTET_STREAM = {
|
||||
mime: 'application/octet-stream',
|
||||
ext: null,
|
||||
};
|
||||
|
||||
const TYPE_SVG = {
|
||||
mime: 'image/svg+xml',
|
||||
ext: 'svg',
|
||||
};
|
||||
@Injectable()
|
||||
export class FileInfoService {
|
||||
constructor(
|
||||
private aiService: AiService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file information
|
||||
*/
|
||||
public async getFileInfo(path: string, opts: {
|
||||
skipSensitiveDetection: boolean;
|
||||
sensitiveThreshold?: number;
|
||||
sensitiveThresholdForPorn?: number;
|
||||
enableSensitiveMediaDetectionForVideos?: boolean;
|
||||
}): Promise<FileInfo> {
|
||||
const warnings = [] as string[];
|
||||
|
||||
const size = await this.getFileSize(path);
|
||||
const md5 = await this.calcHash(path);
|
||||
|
||||
let type = await this.detectType(path);
|
||||
|
||||
// image dimensions
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let orientation: number | undefined;
|
||||
|
||||
if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) {
|
||||
const imageSize = await this.detectImageSize(path).catch(e => {
|
||||
warnings.push(`detectImageSize failed: ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// うまく判定できない画像は octet-stream にする
|
||||
if (!imageSize) {
|
||||
warnings.push('cannot detect image dimensions');
|
||||
type = TYPE_OCTET_STREAM;
|
||||
} else if (imageSize.wUnits === 'px') {
|
||||
width = imageSize.width;
|
||||
height = imageSize.height;
|
||||
orientation = imageSize.orientation;
|
||||
|
||||
// 制限を超えている画像は octet-stream にする
|
||||
if (imageSize.width > 16383 || imageSize.height > 16383) {
|
||||
warnings.push('image dimensions exceeds limits');
|
||||
type = TYPE_OCTET_STREAM;
|
||||
}
|
||||
} else {
|
||||
warnings.push(`unsupported unit type: ${imageSize.wUnits}`);
|
||||
}
|
||||
}
|
||||
|
||||
let blurhash: string | undefined;
|
||||
|
||||
if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) {
|
||||
blurhash = await this.getBlurhash(path).catch(e => {
|
||||
warnings.push(`getBlurhash failed: ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
if (!opts.skipSensitiveDetection) {
|
||||
await this.detectSensitivity(
|
||||
path,
|
||||
type.mime,
|
||||
opts.sensitiveThreshold ?? 0.5,
|
||||
opts.sensitiveThresholdForPorn ?? 0.75,
|
||||
opts.enableSensitiveMediaDetectionForVideos ?? false,
|
||||
).then(value => {
|
||||
[sensitive, porn] = value;
|
||||
}, error => {
|
||||
warnings.push(`detectSensitivity failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
size,
|
||||
md5,
|
||||
type,
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
blurhash,
|
||||
sensitive,
|
||||
porn,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
|
||||
if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
|
||||
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
|
||||
|
||||
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true;
|
||||
|
||||
return [sensitive, porn];
|
||||
}
|
||||
|
||||
if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) {
|
||||
const result = await this.aiService.detectSensitive(source);
|
||||
if (result) {
|
||||
[sensitive, porn] = judgePrediction(result);
|
||||
}
|
||||
} else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
|
||||
const [outDir, disposeOutDir] = await createTempDir();
|
||||
try {
|
||||
const command = FFmpeg()
|
||||
.input(source)
|
||||
.inputOptions([
|
||||
'-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
|
||||
'-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
|
||||
])
|
||||
.noAudio()
|
||||
.videoFilters([
|
||||
{
|
||||
filter: 'select', // フレームのフィルタリング
|
||||
options: {
|
||||
e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい)
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: 'blackframe', // 暗いフレームの検出
|
||||
options: {
|
||||
amount: '0', // 暗さに関わらず全てのフレームで測定値を取る
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: 'metadata',
|
||||
options: {
|
||||
mode: 'select', // フレーム選択モード
|
||||
key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する)
|
||||
value: '50',
|
||||
function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので)
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: 'scale',
|
||||
options: {
|
||||
w: 299,
|
||||
h: 299,
|
||||
},
|
||||
},
|
||||
])
|
||||
.format('image2')
|
||||
.output(join(outDir, '%d.png'))
|
||||
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
|
||||
const results: ReturnType<typeof judgePrediction>[] = [];
|
||||
let frameIndex = 0;
|
||||
let targetIndex = 0;
|
||||
let nextIndex = 1;
|
||||
for await (const path of this.asyncIterateFrames(outDir, command)) {
|
||||
try {
|
||||
const index = frameIndex++;
|
||||
if (index !== targetIndex) {
|
||||
continue;
|
||||
}
|
||||
targetIndex = nextIndex;
|
||||
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
|
||||
const result = await this.aiService.detectSensitive(path);
|
||||
if (result) {
|
||||
results.push(judgePrediction(result));
|
||||
}
|
||||
} finally {
|
||||
fs.promises.unlink(path);
|
||||
}
|
||||
}
|
||||
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
|
||||
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
|
||||
} finally {
|
||||
disposeOutDir();
|
||||
}
|
||||
}
|
||||
|
||||
return [sensitive, porn];
|
||||
}
|
||||
|
||||
private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
|
||||
const watcher = new FSWatcher({
|
||||
cwd,
|
||||
disableGlobbing: true,
|
||||
});
|
||||
let finished = false;
|
||||
command.once('end', () => {
|
||||
finished = true;
|
||||
watcher.close();
|
||||
});
|
||||
command.run();
|
||||
for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
const current = `${i}.png`;
|
||||
const next = `${i + 1}.png`;
|
||||
const framePath = join(cwd, current);
|
||||
if (await this.exists(join(cwd, next))) {
|
||||
yield framePath;
|
||||
} else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
watcher.add(next);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
watcher.on('add', function onAdd(path) {
|
||||
if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている
|
||||
watcher.unwatch(current);
|
||||
watcher.off('add', onAdd);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている
|
||||
command.once('error', reject);
|
||||
});
|
||||
yield framePath;
|
||||
} else if (await this.exists(framePath)) {
|
||||
yield framePath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private exists(path: string): Promise<boolean> {
|
||||
return fs.promises.access(path).then(() => true, () => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME Type and extension
|
||||
*/
|
||||
public async detectType(path: string): Promise<{
|
||||
mime: string;
|
||||
ext: string | null;
|
||||
}> {
|
||||
// Check 0 byte
|
||||
const fileSize = await this.getFileSize(path);
|
||||
if (fileSize === 0) {
|
||||
return TYPE_OCTET_STREAM;
|
||||
}
|
||||
|
||||
const type = await fileTypeFromFile(path);
|
||||
|
||||
if (type) {
|
||||
// XMLはSVGかもしれない
|
||||
if (type.mime === 'application/xml' && await this.checkSvg(path)) {
|
||||
return TYPE_SVG;
|
||||
}
|
||||
|
||||
return {
|
||||
mime: type.mime,
|
||||
ext: type.ext,
|
||||
};
|
||||
}
|
||||
|
||||
// 種類が不明でもSVGかもしれない
|
||||
if (await this.checkSvg(path)) {
|
||||
return TYPE_SVG;
|
||||
}
|
||||
|
||||
// それでも種類が不明なら application/octet-stream にする
|
||||
return TYPE_OCTET_STREAM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the file is SVG or not
|
||||
*/
|
||||
public async checkSvg(path: string) {
|
||||
try {
|
||||
const size = await this.getFileSize(path);
|
||||
if (size > 1 * 1024 * 1024) return false;
|
||||
return isSvg(fs.readFileSync(path));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size
|
||||
*/
|
||||
public async getFileSize(path: string): Promise<number> {
|
||||
const getStat = util.promisify(fs.stat);
|
||||
return (await getStat(path)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate MD5 hash
|
||||
*/
|
||||
private async calcHash(path: string): Promise<string> {
|
||||
const hash = crypto.createHash('md5').setEncoding('hex');
|
||||
await pipeline(fs.createReadStream(path), hash);
|
||||
return hash.read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect dimensions of image
|
||||
*/
|
||||
private async detectImageSize(path: string): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
wUnits: string;
|
||||
hUnits: string;
|
||||
orientation?: number;
|
||||
}> {
|
||||
const readable = fs.createReadStream(path);
|
||||
const imageSize = await probeImageSize(readable);
|
||||
readable.destroy();
|
||||
return imageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average color of image
|
||||
*/
|
||||
private getBlurhash(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sharp(path)
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.resize(64, 64, { fit: 'inside' })
|
||||
.toBuffer((err, buffer, { width, height }) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let hash;
|
||||
|
||||
try {
|
||||
hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7);
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
|
||||
resolve(hash);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
109
packages/backend/src/core/GlobalEventService.ts
Normal file
109
packages/backend/src/core/GlobalEventService.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { UserList } from '@/models/entities/UserList.js';
|
||||
import type { UserGroup } from '@/models/entities/UserGroup.js';
|
||||
import type { Antenna } from '@/models/entities/Antenna.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import type {
|
||||
StreamChannels,
|
||||
AdminStreamTypes,
|
||||
AntennaStreamTypes,
|
||||
BroadcastTypes,
|
||||
ChannelStreamTypes,
|
||||
DriveStreamTypes,
|
||||
GroupMessagingStreamTypes,
|
||||
InternalStreamTypes,
|
||||
MainStreamTypes,
|
||||
MessagingIndexStreamTypes,
|
||||
MessagingStreamTypes,
|
||||
NoteStreamTypes,
|
||||
UserListStreamTypes,
|
||||
UserStreamTypes,
|
||||
} from '@/server/api/stream/types.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalEventService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
) {
|
||||
}
|
||||
|
||||
private publish(channel: StreamChannels, type: string | null, value?: any): void {
|
||||
const message = type == null ? value : value == null ?
|
||||
{ type: type, body: null } :
|
||||
{ type: type, body: value };
|
||||
|
||||
this.redisClient.publish(this.config.host, JSON.stringify({
|
||||
channel: channel,
|
||||
message: message,
|
||||
}));
|
||||
}
|
||||
|
||||
public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void {
|
||||
this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishUserEvent<K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void {
|
||||
this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void {
|
||||
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishMainStream<K extends keyof MainStreamTypes>(userId: User['id'], type: K, value?: MainStreamTypes[K]): void {
|
||||
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishDriveStream<K extends keyof DriveStreamTypes>(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void {
|
||||
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void {
|
||||
this.publish(`noteStream:${noteId}`, type, {
|
||||
id: noteId,
|
||||
body: value,
|
||||
});
|
||||
}
|
||||
|
||||
public publishChannelStream<K extends keyof ChannelStreamTypes>(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void {
|
||||
this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishUserListStream<K extends keyof UserListStreamTypes>(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void {
|
||||
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void {
|
||||
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishMessagingStream<K extends keyof MessagingStreamTypes>(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void {
|
||||
this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishGroupMessagingStream<K extends keyof GroupMessagingStreamTypes>(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void {
|
||||
this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishMessagingIndexStream<K extends keyof MessagingIndexStreamTypes>(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void {
|
||||
this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishNotesStream(note: Packed<'Note'>): void {
|
||||
this.publish('notesStream', null, note);
|
||||
}
|
||||
|
||||
public publishAdminStream<K extends keyof AdminStreamTypes>(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void {
|
||||
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
147
packages/backend/src/core/HashtagService.ts
Normal file
147
packages/backend/src/core/HashtagService.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { Hashtag } from '@/models/entities/Hashtag.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import { HashtagsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.hashtagsRepository)
|
||||
private hashtagsRepository: HashtagsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private hashtagChart: HashtagChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) {
|
||||
for (const tag of tags) {
|
||||
await this.updateHashtag(user, tag);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateUsertags(user: User, tags: string[]) {
|
||||
for (const tag of tags) {
|
||||
await this.updateHashtag(user, tag, true, true);
|
||||
}
|
||||
|
||||
for (const tag of (user.tags ?? []).filter(x => !tags.includes(x))) {
|
||||
await this.updateHashtag(user, tag, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) {
|
||||
tag = normalizeForSearch(tag);
|
||||
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (index == null && !inc) return;
|
||||
|
||||
if (index != null) {
|
||||
const q = this.hashtagsRepository.createQueryBuilder('tag').update()
|
||||
.where('name = :name', { name: tag });
|
||||
|
||||
const set = {} as any;
|
||||
|
||||
if (isUserAttached) {
|
||||
if (inc) {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.attachedUserIds.some(id => id === user.id)) {
|
||||
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
|
||||
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
|
||||
}
|
||||
// 自分が(ローカル内で)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
|
||||
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
|
||||
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
|
||||
}
|
||||
// 自分が(リモートで)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
|
||||
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
|
||||
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
|
||||
}
|
||||
} else {
|
||||
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
|
||||
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
|
||||
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
|
||||
} else {
|
||||
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
|
||||
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.mentionedUserIds.some(id => id === user.id)) {
|
||||
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
|
||||
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
|
||||
}
|
||||
// 自分が(ローカル内で)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
|
||||
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
|
||||
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
|
||||
}
|
||||
// 自分が(リモートで)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
|
||||
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
|
||||
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(set).length > 0) {
|
||||
q.set(set);
|
||||
q.execute();
|
||||
}
|
||||
} else {
|
||||
if (isUserAttached) {
|
||||
this.hashtagsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
name: tag,
|
||||
mentionedUserIds: [],
|
||||
mentionedUsersCount: 0,
|
||||
mentionedLocalUserIds: [],
|
||||
mentionedLocalUsersCount: 0,
|
||||
mentionedRemoteUserIds: [],
|
||||
mentionedRemoteUsersCount: 0,
|
||||
attachedUserIds: [user.id],
|
||||
attachedUsersCount: 1,
|
||||
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
} as Hashtag);
|
||||
} else {
|
||||
this.hashtagsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
name: tag,
|
||||
mentionedUserIds: [user.id],
|
||||
mentionedUsersCount: 1,
|
||||
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
attachedUserIds: [],
|
||||
attachedUsersCount: 0,
|
||||
attachedLocalUserIds: [],
|
||||
attachedLocalUsersCount: 0,
|
||||
attachedRemoteUserIds: [],
|
||||
attachedRemoteUsersCount: 0,
|
||||
} as Hashtag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUserAttached) {
|
||||
this.hashtagChart.update(tag, user);
|
||||
}
|
||||
}
|
||||
}
|
154
packages/backend/src/core/HttpRequestService.ts
Normal file
154
packages/backend/src/core/HttpRequestService.ts
Normal file
|
@ -0,0 +1,154 @@
|
|||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
|
||||
@Injectable()
|
||||
export class HttpRequestService {
|
||||
/**
|
||||
* Get http non-proxy agent
|
||||
*/
|
||||
private http: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent
|
||||
*/
|
||||
private https: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http proxy or non-proxy agent
|
||||
*/
|
||||
public httpAgent: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https proxy or non-proxy agent
|
||||
*/
|
||||
public httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
errorTtl: 30, // 30secs
|
||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.http = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup,
|
||||
} as http.AgentOptions);
|
||||
|
||||
this.https = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup,
|
||||
} as https.AgentOptions);
|
||||
|
||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||
|
||||
this.httpAgent = config.proxy
|
||||
? new HttpProxyAgent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
maxSockets,
|
||||
maxFreeSockets: 256,
|
||||
scheduling: 'lifo',
|
||||
proxy: config.proxy,
|
||||
})
|
||||
: this.http;
|
||||
|
||||
this.httpsAgent = config.proxy
|
||||
? new HttpsProxyAgent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
maxSockets,
|
||||
maxFreeSockets: 256,
|
||||
scheduling: 'lifo',
|
||||
proxy: config.proxy,
|
||||
})
|
||||
: this.https;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
*/
|
||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
||||
if (bypassProxy || (this.config.proxyBypassHosts || []).includes(url.hostname)) {
|
||||
return url.protocol === 'http:' ? this.http : this.https;
|
||||
} else {
|
||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
public async getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record<string, string>): Promise<unknown> {
|
||||
const res = await this.getResponse({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
'User-Agent': this.config.userAgent,
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout,
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
public async getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record<string, string>): Promise<string> {
|
||||
const res = await this.getResponse({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
'User-Agent': this.config.userAgent,
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout,
|
||||
});
|
||||
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
public async getResponse(args: {
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string,
|
||||
headers: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
}): Promise<Response> {
|
||||
const timeout = args.timeout ?? 10 * 1000;
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeout * 6);
|
||||
|
||||
const res = await fetch(args.url, {
|
||||
method: args.method,
|
||||
headers: args.headers,
|
||||
body: args.body,
|
||||
timeout,
|
||||
size: args.size ?? 10 * 1024 * 1024,
|
||||
agent: (url) => this.getAgentByUrl(url),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
33
packages/backend/src/core/IdService.ts
Normal file
33
packages/backend/src/core/IdService.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ulid } from 'ulid';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { genAid } from '@/misc/id/aid.js';
|
||||
import { genMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId } from '@/misc/id/object-id.js';
|
||||
|
||||
@Injectable()
|
||||
export class IdService {
|
||||
private metohd: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
this.metohd = config.id.toLowerCase();
|
||||
}
|
||||
|
||||
public genId(date?: Date): string {
|
||||
if (!date || (date > new Date())) date = new Date();
|
||||
|
||||
switch (this.metohd) {
|
||||
case 'aid': return genAid(date);
|
||||
case 'meid': return genMeid(date);
|
||||
case 'meidg': return genMeidg(date);
|
||||
case 'ulid': return ulid(date.getTime());
|
||||
case 'objectid': return genObjectId(date);
|
||||
default: throw new Error('unrecognized id generation method');
|
||||
}
|
||||
}
|
||||
}
|
99
packages/backend/src/core/ImageProcessingService.ts
Normal file
99
packages/backend/src/core/ImageProcessingService.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
export type IImage = {
|
||||
data: Buffer;
|
||||
ext: string | null;
|
||||
type: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ImageProcessingService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JPEG
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
public async convertToJpeg(path: string, width: number, height: number): Promise<IImage> {
|
||||
return this.convertSharpToJpeg(await sharp(path), width, height);
|
||||
}
|
||||
|
||||
public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.jpeg({
|
||||
quality: 85,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'jpg',
|
||||
type: 'image/jpeg',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to WebP
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
public async convertToWebp(path: string, width: number, height: number, quality = 85): Promise<IImage> {
|
||||
return this.convertSharpToWebp(await sharp(path), width, height, quality);
|
||||
}
|
||||
|
||||
public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality = 85): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.webp({
|
||||
quality,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'webp',
|
||||
type: 'image/webp',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to PNG
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
public async convertToPng(path: string, width: number, height: number): Promise<IImage> {
|
||||
return this.convertSharpToPng(await sharp(path), width, height);
|
||||
}
|
||||
|
||||
public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'png',
|
||||
type: 'image/png',
|
||||
};
|
||||
}
|
||||
}
|
42
packages/backend/src/core/InstanceActorService.ts
Normal file
42
packages/backend/src/core/InstanceActorService.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { ILocalUser } from '@/models/entities/User.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CreateSystemUserService } from './CreateSystemUserService.js';
|
||||
|
||||
const ACTOR_USERNAME = 'instance.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class InstanceActorService {
|
||||
private cache: Cache<ILocalUser>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
) {
|
||||
this.cache = new Cache<ILocalUser>(Infinity);
|
||||
}
|
||||
|
||||
public async getInstanceActor(): Promise<ILocalUser> {
|
||||
const cached = this.cache.get(null);
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
username: ACTOR_USERNAME,
|
||||
}) as ILocalUser | undefined;
|
||||
|
||||
if (user) {
|
||||
this.cache.set(null, user);
|
||||
return user;
|
||||
} else {
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as ILocalUser;
|
||||
this.cache.set(null, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
}
|
45
packages/backend/src/core/InternalStorageService.ts
Normal file
45
packages/backend/src/core/InternalStorageService.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as Path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const path = Path.resolve(_dirname, '../../../../files');
|
||||
|
||||
@Injectable()
|
||||
export class InternalStorageService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public resolvePath(key: string) {
|
||||
return Path.resolve(path, key);
|
||||
}
|
||||
|
||||
public read(key: string) {
|
||||
return fs.createReadStream(this.resolvePath(key));
|
||||
}
|
||||
|
||||
public saveFromPath(key: string, srcPath: string) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.copyFileSync(srcPath, this.resolvePath(key));
|
||||
return `${this.config.url}/files/${key}`;
|
||||
}
|
||||
|
||||
public saveFromBuffer(key: string, data: Buffer) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.writeFileSync(this.resolvePath(key), data);
|
||||
return `${this.config.url}/files/${key}`;
|
||||
}
|
||||
|
||||
public del(key: string) {
|
||||
fs.unlink(this.resolvePath(key), () => {});
|
||||
}
|
||||
}
|
33
packages/backend/src/core/LoggerService.ts
Normal file
33
packages/backend/src/core/LoggerService.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as SyslogPro from 'syslog-pro';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class LoggerService {
|
||||
private syslogClient;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
if (this.config.syslog) {
|
||||
this.syslogClient = new SyslogPro.RFC5424({
|
||||
applacationName: 'Misskey',
|
||||
timestamp: true,
|
||||
encludeStructuredData: true,
|
||||
color: true,
|
||||
extendedColor: true,
|
||||
server: {
|
||||
target: config.syslog.host,
|
||||
port: config.syslog.port,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public getLogger(domain: string, color?: string | undefined, store?: boolean) {
|
||||
return new Logger(domain, color, store, this.syslogClient);
|
||||
}
|
||||
}
|
300
packages/backend/src/core/MessagingService.ts
Normal file
300
packages/backend/src/core/MessagingService.ts
Normal file
|
@ -0,0 +1,300 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { MessagingMessage } from '@/models/entities/MessagingMessage.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { User, CacheableUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import type { UserGroup } from '@/models/entities/UserGroup.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { MessagingMessagesRepository, MutingsRepository, UserGroupJoiningsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdService } from './IdService.js';
|
||||
import { GlobalEventService } from './GlobalEventService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
export class MessagingService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.messagingMessagesRepository)
|
||||
private messagingMessagesRepository: MessagingMessagesRepository,
|
||||
|
||||
@Inject(DI.userGroupJoiningsRepository)
|
||||
private userGroupJoiningsRepository: UserGroupJoiningsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private messagingMessageEntityService: MessagingMessageEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private queueService: QueueService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) {
|
||||
const message = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
fileId: file ? file.id : null,
|
||||
recipientId: recipientUser ? recipientUser.id : null,
|
||||
groupId: recipientGroup ? recipientGroup.id : null,
|
||||
text: text ? text.trim() : null,
|
||||
userId: user.id,
|
||||
isRead: false,
|
||||
reads: [] as any[],
|
||||
uri,
|
||||
} as MessagingMessage;
|
||||
|
||||
await this.messagingMessagesRepository.insert(message);
|
||||
|
||||
const messageObj = await this.messagingMessageEntityService.pack(message);
|
||||
|
||||
if (recipientUser) {
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 自分のストリーム
|
||||
this.globalEventService.publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj);
|
||||
this.globalEventService.publishMessagingIndexStream(message.userId, 'message', messageObj);
|
||||
this.globalEventService.publishMainStream(message.userId, 'messagingMessage', messageObj);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(recipientUser)) {
|
||||
// 相手のストリーム
|
||||
this.globalEventService.publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj);
|
||||
this.globalEventService.publishMessagingIndexStream(recipientUser.id, 'message', messageObj);
|
||||
this.globalEventService.publishMainStream(recipientUser.id, 'messagingMessage', messageObj);
|
||||
}
|
||||
} else if (recipientGroup) {
|
||||
// グループのストリーム
|
||||
this.globalEventService.publishGroupMessagingStream(recipientGroup.id, 'message', messageObj);
|
||||
|
||||
// メンバーのストリーム
|
||||
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id });
|
||||
for (const joining of joinings) {
|
||||
this.globalEventService.publishMessagingIndexStream(joining.userId, 'message', messageObj);
|
||||
this.globalEventService.publishMainStream(joining.userId, 'messagingMessage', messageObj);
|
||||
}
|
||||
}
|
||||
|
||||
// 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const freshMessage = await this.messagingMessagesRepository.findOneBy({ id: message.id });
|
||||
if (freshMessage == null) return; // メッセージが削除されている場合もある
|
||||
|
||||
if (recipientUser && this.userEntityService.isLocalUser(recipientUser)) {
|
||||
if (freshMessage.isRead) return; // 既読
|
||||
|
||||
//#region ただしミュートされているなら発行しない
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: recipientUser.id,
|
||||
});
|
||||
if (mute.map(m => m.muteeId).includes(user.id)) return;
|
||||
//#endregion
|
||||
|
||||
this.globalEventService.publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj);
|
||||
this.pushNotificationService.pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj);
|
||||
} else if (recipientGroup) {
|
||||
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) });
|
||||
for (const joining of joinings) {
|
||||
if (freshMessage.reads.includes(joining.userId)) return; // 既読
|
||||
this.globalEventService.publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj);
|
||||
this.pushNotificationService.pushNotification(joining.userId, 'unreadMessagingMessage', messageObj);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
if (recipientUser && this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipientUser)) {
|
||||
const note = {
|
||||
id: message.id,
|
||||
createdAt: message.createdAt,
|
||||
fileIds: message.fileId ? [message.fileId] : [],
|
||||
text: message.text,
|
||||
userId: message.userId,
|
||||
visibility: 'specified',
|
||||
mentions: [recipientUser].map(u => u.id),
|
||||
mentionedRemoteUsers: JSON.stringify([recipientUser].map(u => ({
|
||||
uri: u.uri,
|
||||
username: u.username,
|
||||
host: u.host,
|
||||
}))),
|
||||
} as Note;
|
||||
|
||||
const activity = this.apRendererService.renderActivity(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note));
|
||||
|
||||
this.queueService.deliver(user, activity, recipientUser.inbox);
|
||||
}
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
public async deleteMessage(message: MessagingMessage) {
|
||||
await this.messagingMessagesRepository.delete(message.id);
|
||||
this.postDeleteMessage(message);
|
||||
}
|
||||
|
||||
private async postDeleteMessage(message: MessagingMessage) {
|
||||
if (message.recipientId) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: message.userId });
|
||||
const recipient = await this.usersRepository.findOneByOrFail({ id: message.recipientId });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) this.globalEventService.publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id);
|
||||
if (this.userEntityService.isLocalUser(recipient)) this.globalEventService.publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id);
|
||||
|
||||
if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) {
|
||||
const activity = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user));
|
||||
this.queueService.deliver(user, activity, recipient.inbox);
|
||||
}
|
||||
} else if (message.groupId) {
|
||||
this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read
|
||||
*/
|
||||
public async readUserMessagingMessage(
|
||||
userId: User['id'],
|
||||
otherpartyId: User['id'],
|
||||
messageIds: MessagingMessage['id'][],
|
||||
) {
|
||||
if (messageIds.length === 0) return;
|
||||
|
||||
const messages = await this.messagingMessagesRepository.findBy({
|
||||
id: In(messageIds),
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.recipientId !== userId) {
|
||||
throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).');
|
||||
}
|
||||
}
|
||||
|
||||
// Update documents
|
||||
await this.messagingMessagesRepository.update({
|
||||
id: In(messageIds),
|
||||
userId: otherpartyId,
|
||||
recipientId: userId,
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
// Publish event
|
||||
this.globalEventService.publishMessagingStream(otherpartyId, userId, 'read', messageIds);
|
||||
this.globalEventService.publishMessagingIndexStream(userId, 'read', messageIds);
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) {
|
||||
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages');
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined);
|
||||
} else {
|
||||
// そのユーザーとのメッセージで未読がなければイベント発行
|
||||
const count = await this.messagingMessagesRepository.count({
|
||||
where: {
|
||||
userId: otherpartyId,
|
||||
recipientId: userId,
|
||||
isRead: false,
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (!count) {
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read
|
||||
*/
|
||||
public async readGroupMessagingMessage(
|
||||
userId: User['id'],
|
||||
groupId: UserGroup['id'],
|
||||
messageIds: MessagingMessage['id'][],
|
||||
) {
|
||||
if (messageIds.length === 0) return;
|
||||
|
||||
// check joined
|
||||
const joining = await this.userGroupJoiningsRepository.findOneBy({
|
||||
userId: userId,
|
||||
userGroupId: groupId,
|
||||
});
|
||||
|
||||
if (joining == null) {
|
||||
throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).');
|
||||
}
|
||||
|
||||
const messages = await this.messagingMessagesRepository.findBy({
|
||||
id: In(messageIds),
|
||||
});
|
||||
|
||||
const reads: MessagingMessage['id'][] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.userId === userId) continue;
|
||||
if (message.reads.includes(userId)) continue;
|
||||
|
||||
// Update document
|
||||
await this.messagingMessagesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reads: (() => `array_append("reads", '${joining.userId}')`) as any,
|
||||
})
|
||||
.where('id = :id', { id: message.id })
|
||||
.execute();
|
||||
|
||||
reads.push(message.id);
|
||||
}
|
||||
|
||||
// Publish event
|
||||
this.globalEventService.publishGroupMessagingStream(groupId, 'read', {
|
||||
ids: reads,
|
||||
userId: userId,
|
||||
});
|
||||
this.globalEventService.publishMessagingIndexStream(userId, 'read', reads);
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) {
|
||||
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages');
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined);
|
||||
} else {
|
||||
// そのグループにおいて未読がなければイベント発行
|
||||
const unreadExist = await this.messagingMessagesRepository.createQueryBuilder('message')
|
||||
.where('message.groupId = :groupId', { groupId: groupId })
|
||||
.andWhere('message.userId != :userId', { userId: userId })
|
||||
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
|
||||
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
|
||||
.getOne().then(x => x != null);
|
||||
|
||||
if (!unreadExist) {
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) {
|
||||
messages = toArray(messages).filter(x => x.uri);
|
||||
const contents = messages.map(x => this.apRendererService.renderRead(user, x));
|
||||
|
||||
if (contents.length > 1) {
|
||||
const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents);
|
||||
this.queueService.deliver(user, this.apRendererService.renderActivity(collection), recipient.inbox);
|
||||
} else {
|
||||
for (const content of contents) {
|
||||
this.queueService.deliver(user, this.apRendererService.renderActivity(content), recipient.inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
63
packages/backend/src/core/MetaService.ts
Normal file
63
packages/backend/src/core/MetaService.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Meta } from '@/models/entities/Meta.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class MetaService implements OnApplicationShutdown {
|
||||
private cache: Meta | undefined;
|
||||
private intervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.fetch(true).then(meta => {
|
||||
this.cache = meta;
|
||||
});
|
||||
}, 1000 * 10);
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(noCache = false): Promise<Meta> {
|
||||
if (!noCache && this.cache) return this.cache;
|
||||
|
||||
return await this.db.transaction(async transactionalEntityManager => {
|
||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||
const metas = await transactionalEntityManager.find(Meta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
const meta = metas[0];
|
||||
|
||||
if (meta) {
|
||||
this.cache = meta;
|
||||
return meta;
|
||||
} else {
|
||||
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
||||
const saved = await transactionalEntityManager
|
||||
.upsert(
|
||||
Meta,
|
||||
{
|
||||
id: 'x',
|
||||
},
|
||||
['id'],
|
||||
)
|
||||
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
|
||||
|
||||
this.cache = saved;
|
||||
return saved;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
384
packages/backend/src/core/MfmService.ts
Normal file
384
packages/backend/src/core/MfmService.ts
Normal file
|
@ -0,0 +1,384 @@
|
|||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as parse5 from 'parse5';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { intersperse } from '@/misc/prelude/array.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||
import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js';
|
||||
import type * as mfm from 'mfm-js';
|
||||
|
||||
const treeAdapter = TreeAdapter.defaultTreeAdapter;
|
||||
|
||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||
|
||||
@Injectable()
|
||||
export class MfmService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public fromHtml(html: string, hashtagNames?: string[]): string {
|
||||
// some AP servers like Pixelfed use br tags as well as newlines
|
||||
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
|
||||
|
||||
const dom = parse5.parseFragment(html);
|
||||
|
||||
let text = '';
|
||||
|
||||
for (const n of dom.childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
|
||||
function getText(node: TreeAdapter.Node): string {
|
||||
if (treeAdapter.isTextNode(node)) return node.value;
|
||||
if (!treeAdapter.isElementNode(node)) return '';
|
||||
if (node.nodeName === 'br') return '\n';
|
||||
|
||||
if (node.childNodes) {
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function appendChildren(childNodes: TreeAdapter.ChildNode[]): void {
|
||||
if (childNodes) {
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function analyze(node: TreeAdapter.Node) {
|
||||
if (treeAdapter.isTextNode(node)) {
|
||||
text += node.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comment or document type node
|
||||
if (!treeAdapter.isElementNode(node)) return;
|
||||
|
||||
switch (node.nodeName) {
|
||||
case 'br': {
|
||||
text += '\n';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'a':
|
||||
{
|
||||
const txt = getText(node);
|
||||
const rel = node.attrs.find(x => x.name === 'rel');
|
||||
const href = node.attrs.find(x => x.name === 'href');
|
||||
|
||||
// ハッシュタグ
|
||||
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
|
||||
text += txt;
|
||||
// メンション
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
|
||||
const part = txt.split('@');
|
||||
|
||||
if (part.length === 2 && href) {
|
||||
//#region ホスト名部分が省略されているので復元する
|
||||
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
||||
text += acct;
|
||||
//#endregion
|
||||
} else if (part.length === 3) {
|
||||
text += txt;
|
||||
}
|
||||
// その他
|
||||
} else {
|
||||
const generateLink = () => {
|
||||
if (!href && !txt) {
|
||||
return '';
|
||||
}
|
||||
if (!href) {
|
||||
return txt;
|
||||
}
|
||||
if (!txt || txt === href.value) { // #6383: Missing text node
|
||||
if (href.value.match(urlRegexFull)) {
|
||||
return href.value;
|
||||
} else {
|
||||
return `<${href.value}>`;
|
||||
}
|
||||
}
|
||||
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
|
||||
return `[${txt}](<${href.value}>)`; // #6846
|
||||
} else {
|
||||
return `[${txt}](${href.value})`;
|
||||
}
|
||||
};
|
||||
|
||||
text += generateLink();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'h1':
|
||||
{
|
||||
text += '【';
|
||||
appendChildren(node.childNodes);
|
||||
text += '】\n';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'b':
|
||||
case 'strong':
|
||||
{
|
||||
text += '**';
|
||||
appendChildren(node.childNodes);
|
||||
text += '**';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'small':
|
||||
{
|
||||
text += '<small>';
|
||||
appendChildren(node.childNodes);
|
||||
text += '</small>';
|
||||
break;
|
||||
}
|
||||
|
||||
case 's':
|
||||
case 'del':
|
||||
{
|
||||
text += '~~';
|
||||
appendChildren(node.childNodes);
|
||||
text += '~~';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'i':
|
||||
case 'em':
|
||||
{
|
||||
text += '<i>';
|
||||
appendChildren(node.childNodes);
|
||||
text += '</i>';
|
||||
break;
|
||||
}
|
||||
|
||||
// block code (<pre><code>)
|
||||
case 'pre': {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
||||
text += '\n```\n';
|
||||
text += getText(node.childNodes[0]);
|
||||
text += '\n```\n';
|
||||
} else {
|
||||
appendChildren(node.childNodes);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// inline code (<code>)
|
||||
case 'code': {
|
||||
text += '`';
|
||||
appendChildren(node.childNodes);
|
||||
text += '`';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blockquote': {
|
||||
const t = getText(node);
|
||||
if (t) {
|
||||
text += '\n> ';
|
||||
text += t.split('\n').join('\n> ');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'p':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
{
|
||||
text += '\n\n';
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
// other block elements
|
||||
case 'div':
|
||||
case 'header':
|
||||
case 'footer':
|
||||
case 'article':
|
||||
case 'li':
|
||||
case 'dt':
|
||||
case 'dd':
|
||||
{
|
||||
text += '\n';
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
default: // includes inline elements
|
||||
{
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { window } = new JSDOM('');
|
||||
|
||||
const doc = window.document;
|
||||
|
||||
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
|
||||
if (children) {
|
||||
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
||||
bold: (node) => {
|
||||
const el = doc.createElement('b');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
small: (node) => {
|
||||
const el = doc.createElement('small');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
strike: (node) => {
|
||||
const el = doc.createElement('del');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
italic: (node) => {
|
||||
const el = doc.createElement('i');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
fn: (node) => {
|
||||
const el = doc.createElement('i');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
blockCode: (node) => {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
inner.textContent = node.props.code;
|
||||
pre.appendChild(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
center: (node) => {
|
||||
const el = doc.createElement('div');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode: (node) => {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji: (node) => {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
},
|
||||
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `${this.config.url}/tags/${node.props.hashtag}`;
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
link: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
||||
mention: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
const { username, host, acct } = node.props;
|
||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
|
||||
a.className = 'u-url mention';
|
||||
a.textContent = acct;
|
||||
return a;
|
||||
},
|
||||
|
||||
quote: (node) => {
|
||||
const el = doc.createElement('blockquote');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text: (node) => {
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.textContent = node.props.url;
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `https://www.google.com/search?q=${node.props.query}`;
|
||||
a.textContent = node.props.content;
|
||||
return a;
|
||||
},
|
||||
|
||||
plain: (node) => {
|
||||
const el = doc.createElement('span');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
appendChildren(nodes, doc.body);
|
||||
|
||||
return `<p>${doc.body.innerHTML}</p>`;
|
||||
}
|
||||
}
|
26
packages/backend/src/core/ModerationLogService.ts
Normal file
26
packages/backend/src/core/ModerationLogService.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ModerationLogsRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ModerationLogService {
|
||||
constructor(
|
||||
@Inject(DI.moderationLogsRepository)
|
||||
private moderationLogsRepository: ModerationLogsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record<string, any>) {
|
||||
await this.moderationLogsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: moderator.id,
|
||||
type: type,
|
||||
info: info ?? {},
|
||||
});
|
||||
}
|
||||
}
|
742
packages/backend/src/core/NoteCreateService.ts
Normal file
742
packages/backend/src/core/NoteCreateService.ts
Normal file
|
@ -0,0 +1,742 @@
|
|||
import * as mfm from 'mfm-js';
|
||||
import { Not, In, DataSource } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||
import { Note } from '@/models/entities/Note.js';
|
||||
import { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { App } from '@/models/entities/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import type { IPoll } from '@/models/entities/Poll.js';
|
||||
import { Poll } from '@/models/entities/Poll.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import NotesChart from '@/core/chart/charts/notes.js';
|
||||
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import { WebhookService } from '@/core/WebhookService.js';
|
||||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { AntennaService } from '@/core/AntennaService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { ResolveUserService } from './remote/ResolveUserService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
|
||||
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
class NotificationManager {
|
||||
private notifier: { id: User['id']; };
|
||||
private note: Note;
|
||||
private queue: {
|
||||
target: ILocalUser['id'];
|
||||
reason: NotificationType;
|
||||
}[];
|
||||
|
||||
constructor(
|
||||
private mutingsRepository: MutingsRepository,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
notifier: { id: User['id']; },
|
||||
note: Note,
|
||||
) {
|
||||
this.notifier = notifier;
|
||||
this.note = note;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
public push(notifiee: ILocalUser['id'], reason: NotificationType) {
|
||||
// 自分自身へは通知しない
|
||||
if (this.notifier.id === notifiee) return;
|
||||
|
||||
const exist = this.queue.find(x => x.target === notifiee);
|
||||
|
||||
if (exist) {
|
||||
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
|
||||
if (reason !== 'mention') {
|
||||
exist.reason = reason;
|
||||
}
|
||||
} else {
|
||||
this.queue.push({
|
||||
reason: reason,
|
||||
target: notifiee,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async deliver() {
|
||||
for (const x of this.queue) {
|
||||
// ミュート情報を取得
|
||||
const mentioneeMutes = await this.mutingsRepository.findBy({
|
||||
muterId: x.target,
|
||||
});
|
||||
|
||||
const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId);
|
||||
|
||||
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
|
||||
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
|
||||
this.createNotificationService.createNotification(x.target, x.reason, {
|
||||
notifierId: this.notifier.id,
|
||||
noteId: this.note.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MinimumUser = {
|
||||
id: User['id'];
|
||||
host: User['host'];
|
||||
username: User['username'];
|
||||
uri: User['uri'];
|
||||
};
|
||||
|
||||
type Option = {
|
||||
createdAt?: Date | null;
|
||||
name?: string | null;
|
||||
text?: string | null;
|
||||
reply?: Note | null;
|
||||
renote?: Note | null;
|
||||
files?: DriveFile[] | null;
|
||||
poll?: IPoll | null;
|
||||
localOnly?: boolean | null;
|
||||
cw?: string | null;
|
||||
visibility?: string;
|
||||
visibleUsers?: MinimumUser[] | null;
|
||||
channel?: Channel | null;
|
||||
apMentions?: MinimumUser[] | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
uri?: string | null;
|
||||
url?: string | null;
|
||||
app?: App | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NoteCreateService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private noteReadService: NoteReadService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private hashtagService: HashtagService,
|
||||
private antennaService: AntennaService,
|
||||
private webhookService: WebhookService,
|
||||
private resolveUserService: ResolveUserService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {}
|
||||
|
||||
public async create(user: {
|
||||
id: User['id'];
|
||||
username: User['username'];
|
||||
host: User['host'];
|
||||
isSilenced: User['isSilenced'];
|
||||
createdAt: User['createdAt'];
|
||||
}, data: Option, silent = false): Promise<Note> {
|
||||
// チャンネル外にリプライしたら対象のスコープに合わせる
|
||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
|
||||
if (data.reply.channelId) {
|
||||
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
|
||||
} else {
|
||||
data.channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
// チャンネル内にリプライしたら対象のスコープに合わせる
|
||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||
if (data.reply && (data.channel == null) && data.reply.channelId) {
|
||||
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
|
||||
}
|
||||
|
||||
if (data.createdAt == null) data.createdAt = new Date();
|
||||
if (data.visibility == null) data.visibility = 'public';
|
||||
if (data.localOnly == null) data.localOnly = false;
|
||||
if (data.channel != null) data.visibility = 'public';
|
||||
if (data.channel != null) data.visibleUsers = [];
|
||||
if (data.channel != null) data.localOnly = true;
|
||||
|
||||
// サイレンス
|
||||
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がpublicではないならhomeにする
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
if (data.renote && data.renote.visibility === 'followers') {
|
||||
data.visibility = 'followers';
|
||||
}
|
||||
|
||||
// 返信対象がpublicではないならhomeにする
|
||||
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// ローカルのみをRenoteしたらローカルのみにする
|
||||
if (data.renote && data.renote.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
|
||||
// ローカルのみにリプライしたらローカルのみにする
|
||||
if (data.reply && data.reply.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
|
||||
if (data.text) {
|
||||
data.text = data.text.trim();
|
||||
} else {
|
||||
data.text = null;
|
||||
}
|
||||
|
||||
let tags = data.apHashtags;
|
||||
let emojis = data.apEmojis;
|
||||
let mentionedUsers = data.apMentions;
|
||||
|
||||
// Parse MFM if needed
|
||||
if (!tags || !emojis || !mentionedUsers) {
|
||||
const tokens = data.text ? mfm.parse(data.text)! : [];
|
||||
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
|
||||
const choiceTokens = data.poll && data.poll.choices
|
||||
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
|
||||
: [];
|
||||
|
||||
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
||||
|
||||
tags = data.apHashtags ?? extractHashtags(combinedTokens);
|
||||
|
||||
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
|
||||
|
||||
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
|
||||
}
|
||||
|
||||
tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32);
|
||||
|
||||
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
|
||||
mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
|
||||
}
|
||||
|
||||
if (data.visibility === 'specified') {
|
||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
if (!mentionedUsers.some(x => x.id === u.id)) {
|
||||
mentionedUsers.push(u);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
|
||||
data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
|
||||
}
|
||||
}
|
||||
|
||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!));
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
private async insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
|
||||
const insert = new Note({
|
||||
id: this.idService.genId(data.createdAt!),
|
||||
createdAt: data.createdAt!,
|
||||
fileIds: data.files ? data.files.map(file => file.id) : [],
|
||||
replyId: data.reply ? data.reply.id : null,
|
||||
renoteId: data.renote ? data.renote.id : null,
|
||||
channelId: data.channel ? data.channel.id : null,
|
||||
threadId: data.reply
|
||||
? data.reply.threadId
|
||||
? data.reply.threadId
|
||||
: data.reply.id
|
||||
: null,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
hasPoll: data.poll != null,
|
||||
cw: data.cw == null ? null : data.cw,
|
||||
tags: tags.map(tag => normalizeForSearch(tag)),
|
||||
emojis,
|
||||
userId: user.id,
|
||||
localOnly: data.localOnly!,
|
||||
visibility: data.visibility as any,
|
||||
visibleUserIds: data.visibility === 'specified'
|
||||
? data.visibleUsers
|
||||
? data.visibleUsers.map(u => u.id)
|
||||
: []
|
||||
: [],
|
||||
|
||||
attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
|
||||
|
||||
// 以下非正規化データ
|
||||
replyUserId: data.reply ? data.reply.userId : null,
|
||||
replyUserHost: data.reply ? data.reply.userHost : null,
|
||||
renoteUserId: data.renote ? data.renote.userId : null,
|
||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
if (data.uri != null) insert.uri = data.uri;
|
||||
if (data.url != null) insert.url = data.url;
|
||||
|
||||
// Append mentions data
|
||||
if (mentionedUsers.length > 0) {
|
||||
insert.mentions = mentionedUsers.map(u => u.id);
|
||||
const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) });
|
||||
insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => {
|
||||
const profile = profiles.find(p => p.userId === u.id);
|
||||
const url = profile != null ? profile.url : null;
|
||||
return {
|
||||
uri: u.uri,
|
||||
url: url == null ? undefined : url,
|
||||
username: u.username,
|
||||
host: u.host,
|
||||
} as IMentionedRemoteUsers[0];
|
||||
}));
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
try {
|
||||
if (insert.hasPoll) {
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.insert(Note, insert);
|
||||
|
||||
const poll = new Poll({
|
||||
noteId: insert.id,
|
||||
choices: data.poll!.choices,
|
||||
expiresAt: data.poll!.expiresAt,
|
||||
multiple: data.poll!.multiple,
|
||||
votes: new Array(data.poll!.choices.length).fill(0),
|
||||
noteVisibility: insert.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(Poll, poll);
|
||||
});
|
||||
} else {
|
||||
await this.notesRepository.insert(insert);
|
||||
}
|
||||
|
||||
return insert;
|
||||
} catch (e) {
|
||||
// duplicate key error
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const err = new Error('Duplicated note');
|
||||
err.name = 'duplicated';
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async postNoteCreated(note: Note, user: {
|
||||
id: User['id'];
|
||||
username: User['username'];
|
||||
host: User['host'];
|
||||
isSilenced: User['isSilenced'];
|
||||
createdAt: User['createdAt'];
|
||||
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
|
||||
// 統計を更新
|
||||
this.notesChart.update(note, true);
|
||||
this.perUserNotesChart.update(user, note, true);
|
||||
|
||||
// Register host
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
this.hashtagService.updateHashtags(user, tags);
|
||||
}
|
||||
|
||||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
|
||||
// Word mute
|
||||
mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({
|
||||
where: {
|
||||
enableWordMute: true,
|
||||
},
|
||||
select: ['userId', 'mutedWords'],
|
||||
})).then(us => {
|
||||
for (const u of us) {
|
||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||
if (shouldMute) {
|
||||
this.mutedNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
userId: u.userId,
|
||||
noteId: note.id,
|
||||
reason: 'word',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Antenna
|
||||
for (const antenna of (await this.antennaService.getAntennas())) {
|
||||
this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
|
||||
if (hit) {
|
||||
this.antennaService.addNoteToAntenna(antenna, note, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Channel
|
||||
if (note.channelId) {
|
||||
this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => {
|
||||
for (const following of followings) {
|
||||
this.noteReadService.insertNoteUnread(following.followerId, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.reply) {
|
||||
this.saveReply(data.reply, note);
|
||||
}
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
|
||||
this.incRenoteCount(data.renote);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
this.queueService.endedPollNotificationQueue.add({
|
||||
noteId: note.id,
|
||||
}, {
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
|
||||
|
||||
// 未読通知を作成
|
||||
if (data.visibility === 'specified') {
|
||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: true,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const u of mentionedUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pack the note
|
||||
const noteObj = await this.noteEntityService.pack(note);
|
||||
|
||||
this.globalEventServie.publishNotesStream(noteObj);
|
||||
|
||||
this.webhookService.getActiveWebhooks().then(webhooks => {
|
||||
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'note', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note);
|
||||
const nmRelatedPromises = [];
|
||||
|
||||
await this.createMentionedEvents(mentionedUsers, note, nm);
|
||||
|
||||
// If has in reply to note
|
||||
if (data.reply) {
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
});
|
||||
|
||||
if (!threadMuted) {
|
||||
nm.push(data.reply.userId, 'reply');
|
||||
this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'reply', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it is renote
|
||||
if (data.renote) {
|
||||
const type = data.text ? 'quote' : 'renote';
|
||||
|
||||
// Notify
|
||||
if (data.renote.userHost === null) {
|
||||
nm.push(data.renote.userId, type);
|
||||
}
|
||||
|
||||
// Publish event
|
||||
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
|
||||
this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'renote', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(nmRelatedPromises).then(() => {
|
||||
nm.deliver();
|
||||
});
|
||||
|
||||
//#region AP deliver
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
(async () => {
|
||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
|
||||
// メンションされたリモートユーザーに配送
|
||||
for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) {
|
||||
dm.addDirectRecipe(u as IRemoteUser);
|
||||
}
|
||||
|
||||
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
|
||||
if (data.reply && data.reply.userHost !== null) {
|
||||
const u = await this.usersRepository.findOneBy({ id: data.reply.userId });
|
||||
if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
|
||||
// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
|
||||
if (data.renote && data.renote.userHost !== null) {
|
||||
const u = await this.usersRepository.findOneBy({ id: data.renote.userId });
|
||||
if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
|
||||
// フォロワーに配送
|
||||
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
}
|
||||
|
||||
if (['public'].includes(note.visibility)) {
|
||||
this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
|
||||
dm.execute();
|
||||
})();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
if (data.channel) {
|
||||
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
|
||||
this.channelsRepository.update(data.channel.id, {
|
||||
lastNotedAt: new Date(),
|
||||
});
|
||||
|
||||
this.notesRepository.countBy({
|
||||
userId: user.id,
|
||||
channelId: data.channel.id,
|
||||
}).then(count => {
|
||||
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
|
||||
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
|
||||
if (count === 1) {
|
||||
this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register to search database
|
||||
this.index(note);
|
||||
}
|
||||
|
||||
private incRenoteCount(renote: Note) {
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
score: () => '"score" + 1',
|
||||
})
|
||||
.where('id = :id', { id: renote.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) {
|
||||
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
|
||||
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
|
||||
userId: u.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
});
|
||||
|
||||
if (threadMuted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const detailPackedNote = await this.noteEntityService.pack(note, u, {
|
||||
detail: true,
|
||||
});
|
||||
|
||||
this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'mention', {
|
||||
note: detailPackedNote,
|
||||
});
|
||||
}
|
||||
|
||||
// Create notification
|
||||
nm.push(u.id, 'mention');
|
||||
}
|
||||
}
|
||||
|
||||
private saveReply(reply: Note, note: Note) {
|
||||
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
||||
}
|
||||
|
||||
private async renderNoteOrRenoteActivity(data: Option, note: Note) {
|
||||
if (data.localOnly) return null;
|
||||
|
||||
const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)
|
||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
||||
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
|
||||
|
||||
return this.apRendererService.renderActivity(content);
|
||||
}
|
||||
|
||||
private index(note: Note) {
|
||||
if (note.text == null || this.config.elasticsearch == null) return;
|
||||
/*
|
||||
es!.index({
|
||||
index: this.config.elasticsearch.index ?? 'misskey_note',
|
||||
id: note.id.toString(),
|
||||
body: {
|
||||
text: normalizeForSearch(note.text),
|
||||
userId: note.userId,
|
||||
userHost: note.userHost,
|
||||
},
|
||||
});*/
|
||||
}
|
||||
|
||||
private incNotesCountOfUser(user: { id: User['id']; }) {
|
||||
this.usersRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
notesCount: () => '"notesCount" + 1',
|
||||
})
|
||||
.where('id = :id', { id: user.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
|
||||
if (tokens == null) return [];
|
||||
|
||||
const mentions = extractMentions(tokens);
|
||||
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
||||
this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
|
||||
))).filter(x => x != null) as User[];
|
||||
|
||||
// Drop duplicate users
|
||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
||||
i === self.findIndex(u2 => u.id === u2.id),
|
||||
);
|
||||
|
||||
return mentionedUsers;
|
||||
}
|
||||
}
|
168
packages/backend/src/core/NoteDeleteService.ts
Normal file
168
packages/backend/src/core/NoteDeleteService.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import { Brackets, In } from 'typeorm';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import type { Note, IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||
import { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import NotesChart from '@/core/chart/charts/notes.js';
|
||||
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteDeleteService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 投稿を削除します。
|
||||
* @param user 投稿者
|
||||
* @param note 投稿
|
||||
*/
|
||||
async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) {
|
||||
const deletedAt = new Date();
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
|
||||
this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1);
|
||||
this.notesRepository.decrement({ id: note.renoteId }, 'score', 1);
|
||||
}
|
||||
|
||||
if (note.replyId) {
|
||||
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
this.globalEventServie.publishNoteStream(note.id, 'deleted', {
|
||||
deletedAt: deletedAt,
|
||||
});
|
||||
|
||||
//#region ローカルの投稿なら削除アクティビティを配送
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
|
||||
let renote: Note | null = null;
|
||||
|
||||
// if deletd note is renote
|
||||
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
|
||||
renote = await this.notesRepository.findOneBy({
|
||||
id: note.renoteId,
|
||||
});
|
||||
}
|
||||
|
||||
const content = this.apRendererService.renderActivity(renote
|
||||
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
|
||||
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
|
||||
|
||||
this.deliverToConcerned(user, note, content);
|
||||
}
|
||||
|
||||
// also deliever delete activity to cascaded notes
|
||||
const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
|
||||
for (const cascadingNote of cascadingNotes) {
|
||||
if (!cascadingNote.user) continue;
|
||||
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
||||
this.deliverToConcerned(cascadingNote.user, cascadingNote, content);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// 統計を更新
|
||||
this.notesChart.update(note, false);
|
||||
this.perUserNotesChart.update(user, note, false);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.notesRepository.delete({
|
||||
id: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
private async findCascadingNotes(note: Note) {
|
||||
const cascadingNotes: Note[] = [];
|
||||
|
||||
const recursive = async (noteId: string) => {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.replyId = :noteId', { noteId })
|
||||
.orWhere(new Brackets(q => {
|
||||
q.where('note.renoteId = :noteId', { noteId })
|
||||
.andWhere('note.text IS NOT NULL');
|
||||
}))
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
const replies = await query.getMany();
|
||||
for (const reply of replies) {
|
||||
cascadingNotes.push(reply);
|
||||
await recursive(reply.id);
|
||||
}
|
||||
};
|
||||
await recursive(note.id);
|
||||
|
||||
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
|
||||
}
|
||||
|
||||
private async getMentionedRemoteUsers(note: Note) {
|
||||
const where = [] as any[];
|
||||
|
||||
// mention / reply / dm
|
||||
const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
|
||||
if (uris.length > 0) {
|
||||
where.push(
|
||||
{ uri: In(uris) },
|
||||
);
|
||||
}
|
||||
|
||||
// renote / quote
|
||||
if (note.renoteUserId) {
|
||||
where.push({
|
||||
id: note.renoteUserId,
|
||||
});
|
||||
}
|
||||
|
||||
if (where.length === 0) return [];
|
||||
|
||||
return await this.usersRepository.find({
|
||||
where,
|
||||
}) as IRemoteUser[];
|
||||
}
|
||||
|
||||
private async deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) {
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
const remoteUsers = await this.getMentionedRemoteUsers(note);
|
||||
for (const remoteUser of remoteUsers) {
|
||||
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
}
|
||||
}
|
117
packages/backend/src/core/NotePiningService.ts
Normal file
117
packages/backend/src/core/NotePiningService.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { UserNotePining } from '@/models/entities/UserNotePining.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotePiningService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private relayService: RelayService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した投稿をピン留めします
|
||||
* @param user
|
||||
* @param noteId
|
||||
*/
|
||||
public async addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
|
||||
// Fetch pinee
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: noteId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
||||
}
|
||||
|
||||
const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
|
||||
|
||||
if (pinings.length >= 5) {
|
||||
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
||||
}
|
||||
|
||||
if (pinings.some(pining => pining.noteId === note.id)) {
|
||||
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
||||
}
|
||||
|
||||
await this.userNotePiningsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
} as UserNotePining);
|
||||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.deliverPinnedChange(user.id, note.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した投稿のピン留めを解除します
|
||||
* @param user
|
||||
* @param noteId
|
||||
*/
|
||||
public async removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
|
||||
// Fetch unpinee
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: noteId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
|
||||
}
|
||||
|
||||
this.userNotePiningsRepository.delete({
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.deliverPinnedChange(user.id, noteId, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
if (!this.userEntityService.isLocalUser(user)) return;
|
||||
|
||||
const target = `${this.config.url}/users/${user.id}/collections/featured`;
|
||||
const item = `${this.config.url}/notes/${noteId}`;
|
||||
const content = this.apRendererService.renderActivity(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item));
|
||||
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
214
packages/backend/src/core/NoteReadService.ts
Normal file
214
packages/backend/src/core/NoteReadService.ts
Normal file
|
@ -0,0 +1,214 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, IsNull, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteReadService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.noteUnreadsRepository)
|
||||
private noteUnreadsRepository: NoteUnreadsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private notificationService: NotificationService,
|
||||
private antennaService: AntennaService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async insertNoteUnread(userId: User['id'], note: Note, params: {
|
||||
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
|
||||
isSpecified: boolean;
|
||||
isMentioned: boolean;
|
||||
}): Promise<void> {
|
||||
//#region ミュートしているなら無視
|
||||
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: userId,
|
||||
});
|
||||
if (mute.map(m => m.muteeId).includes(note.userId)) return;
|
||||
//#endregion
|
||||
|
||||
// スレッドミュート
|
||||
const threadMute = await this.noteThreadMutingsRepository.findOneBy({
|
||||
userId: userId,
|
||||
threadId: note.threadId ?? note.id,
|
||||
});
|
||||
if (threadMute) return;
|
||||
|
||||
const unread = {
|
||||
id: this.idService.genId(),
|
||||
noteId: note.id,
|
||||
userId: userId,
|
||||
isSpecified: params.isSpecified,
|
||||
isMentioned: params.isMentioned,
|
||||
noteChannelId: note.channelId,
|
||||
noteUserId: note.userId,
|
||||
};
|
||||
|
||||
await this.noteUnreadsRepository.insert(unread);
|
||||
|
||||
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
|
||||
|
||||
if (exist == null) return;
|
||||
|
||||
if (params.isMentioned) {
|
||||
this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id);
|
||||
}
|
||||
if (params.isSpecified) {
|
||||
this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
|
||||
}
|
||||
if (note.channelId) {
|
||||
this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
public async read(
|
||||
userId: User['id'],
|
||||
notes: (Note | Packed<'Note'>)[],
|
||||
info?: {
|
||||
following: Set<User['id']>;
|
||||
followingChannels: Set<Channel['id']>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const following = info?.following ? info.following : new Set<string>((await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: userId,
|
||||
},
|
||||
select: ['followeeId'],
|
||||
})).map(x => x.followeeId));
|
||||
const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: userId,
|
||||
},
|
||||
select: ['followeeId'],
|
||||
})).map(x => x.followeeId));
|
||||
|
||||
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
|
||||
const readMentions: (Note | Packed<'Note'>)[] = [];
|
||||
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
|
||||
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
|
||||
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.mentions && note.mentions.includes(userId)) {
|
||||
readMentions.push(note);
|
||||
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
|
||||
readSpecifiedNotes.push(note);
|
||||
}
|
||||
|
||||
if (note.channelId && followingChannels.has(note.channelId)) {
|
||||
readChannelNotes.push(note);
|
||||
}
|
||||
|
||||
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
|
||||
for (const antenna of myAntennas) {
|
||||
if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) {
|
||||
readAntennaNotes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
|
||||
});
|
||||
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
});
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
});
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
noteChannelId: Not(IsNull()),
|
||||
}).then(channelNoteCount => {
|
||||
if (channelNoteCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllChannels');
|
||||
}
|
||||
});
|
||||
|
||||
this.notificationService.readNotificationByQuery(userId, {
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
||||
});
|
||||
}
|
||||
|
||||
if (readAntennaNotes.length > 0) {
|
||||
await this.antennaNotesRepository.update({
|
||||
antennaId: In(myAntennas.map(a => a.id)),
|
||||
noteId: In(readAntennaNotes.map(n => n.id)),
|
||||
}, {
|
||||
read: true,
|
||||
});
|
||||
|
||||
// TODO: まとめてクエリしたい
|
||||
for (const antenna of myAntennas) {
|
||||
const count = await this.antennaNotesRepository.countBy({
|
||||
antennaId: antenna.id,
|
||||
read: false,
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna);
|
||||
}
|
||||
}
|
||||
|
||||
this.userEntityService.getHasUnreadAntenna(userId).then(unread => {
|
||||
if (!unread) {
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllAntennas');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
67
packages/backend/src/core/NotificationService.ts
Normal file
67
packages/backend/src/core/NotificationService.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotificationsRepository } from '@/models/index.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Notification } from '@/models/entities/Notification.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { GlobalEventService } from './GlobalEventService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async readNotification(
|
||||
userId: User['id'],
|
||||
notificationIds: Notification['id'][],
|
||||
) {
|
||||
if (notificationIds.length === 0) return;
|
||||
|
||||
// Update documents
|
||||
const result = await this.notificationsRepository.update({
|
||||
notifieeId: userId,
|
||||
id: In(notificationIds),
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
if (result.affected === 0) return;
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId);
|
||||
else return this.postReadNotifications(userId, notificationIds);
|
||||
}
|
||||
|
||||
public async readNotificationByQuery(
|
||||
userId: User['id'],
|
||||
query: Record<string, any>,
|
||||
) {
|
||||
const notificationIds = await this.notificationsRepository.findBy({
|
||||
...query,
|
||||
notifieeId: userId,
|
||||
isRead: false,
|
||||
}).then(notifications => notifications.map(notification => notification.id));
|
||||
|
||||
return this.readNotification(userId, notificationIds);
|
||||
}
|
||||
|
||||
private postReadAllNotifications(userId: User['id']) {
|
||||
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
||||
return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
||||
}
|
||||
|
||||
private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
|
||||
this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds);
|
||||
return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds });
|
||||
}
|
||||
}
|
115
packages/backend/src/core/PollService.ts
Normal file
115
packages/backend/src/core/PollService.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotesRepository, UsersRepository, BlockingsRepository } from '@/models/index.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import type { CacheableUser } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class PollService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.pollVotesRepository)
|
||||
private pollVotesRepository: PollVotesRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private relayService: RelayService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async vote(user: CacheableUser, note: Note, choice: number) {
|
||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
|
||||
if (poll == null) throw new Error('poll not found');
|
||||
|
||||
// Check whether is valid choice
|
||||
if (poll.choices[choice] == null) throw new Error('invalid choice param');
|
||||
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const block = await this.blockingsRepository.findOneBy({
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new Error('blocked');
|
||||
}
|
||||
}
|
||||
|
||||
// if already voted
|
||||
const exist = await this.pollVotesRepository.findBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (poll.multiple) {
|
||||
if (exist.some(x => x.choice === choice)) {
|
||||
throw new Error('already voted');
|
||||
}
|
||||
} else if (exist.length !== 0) {
|
||||
throw new Error('already voted');
|
||||
}
|
||||
|
||||
// Create vote
|
||||
await this.pollVotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
choice: choice,
|
||||
});
|
||||
|
||||
// Increment votes count
|
||||
const index = choice + 1; // In SQL, array index is 1 based
|
||||
await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
|
||||
|
||||
this.globalEventServie.publishNoteStream(note.id, 'pollVoted', {
|
||||
choice: choice,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Notify
|
||||
this.createNotificationService.createNotification(note.userId, 'pollVote', {
|
||||
notifierId: user.id,
|
||||
noteId: note.id,
|
||||
choice: choice,
|
||||
});
|
||||
}
|
||||
|
||||
public async deliverQuestionUpdate(noteId: Note['id']) {
|
||||
const note = await this.notesRepository.findOneBy({ id: noteId });
|
||||
if (note == null) throw new Error('note not found');
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
if (user == null) throw new Error('note not found');
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
}
|
22
packages/backend/src/core/ProxyAccountService.ts
Normal file
22
packages/backend/src/core/ProxyAccountService.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import type { ILocalUser, User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyAccountService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async fetch(): Promise<ILocalUser | null> {
|
||||
const meta = await this.metaService.fetch();
|
||||
if (meta.proxyAccountId == null) return null;
|
||||
return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser;
|
||||
}
|
||||
}
|
101
packages/backend/src/core/PushNotificationService.ts
Normal file
101
packages/backend/src/core/PushNotificationService.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import push from 'web-push';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { Packed } from '@/misc/schema';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import { SwSubscriptionsRepository } from '@/models/index.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
|
||||
// Defined also packages/sw/types.ts#L14-L21
|
||||
type pushNotificationsTypes = {
|
||||
'notification': Packed<'Notification'>;
|
||||
'unreadMessagingMessage': Packed<'MessagingMessage'>;
|
||||
'readNotifications': { notificationIds: string[] };
|
||||
'readAllNotifications': undefined;
|
||||
'readAllMessagingMessages': undefined;
|
||||
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
|
||||
};
|
||||
|
||||
// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
|
||||
function truncateNotification(notification: Packed<'Notification'>): any {
|
||||
if (notification.note) {
|
||||
return {
|
||||
...notification,
|
||||
note: {
|
||||
...notification.note,
|
||||
// textをgetNoteSummaryしたものに置き換える
|
||||
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
|
||||
|
||||
cw: undefined,
|
||||
reply: undefined,
|
||||
renote: undefined,
|
||||
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushNotificationService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
||||
|
||||
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
|
||||
push.setVapidDetails(this.config.url,
|
||||
meta.swPublicKey,
|
||||
meta.swPrivateKey);
|
||||
|
||||
// Fetch
|
||||
const subscriptions = await this.swSubscriptionsRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
auth: subscription.auth,
|
||||
p256dh: subscription.publickey,
|
||||
},
|
||||
};
|
||||
|
||||
push.sendNotification(pushSubscription, JSON.stringify({
|
||||
type,
|
||||
body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body,
|
||||
userId,
|
||||
dateTime: (new Date()).getTime(),
|
||||
}), {
|
||||
proxy: this.config.proxy,
|
||||
}).catch((err: any) => {
|
||||
//swLogger.info(err.statusCode);
|
||||
//swLogger.info(err.headers);
|
||||
//swLogger.info(err.body);
|
||||
|
||||
if (err.statusCode === 410) {
|
||||
this.swSubscriptionsRepository.delete({
|
||||
userId: userId,
|
||||
endpoint: subscription.endpoint,
|
||||
auth: subscription.auth,
|
||||
publickey: subscription.publickey,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
262
packages/backend/src/core/QueryService.ts
Normal file
262
packages/backend/src/core/QueryService.ts
Normal file
|
@ -0,0 +1,262 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class QueryService {
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder<T> {
|
||||
if (sinceId && untilId) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceId) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
} else if (untilId) {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceDate && untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
} else if (sinceDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'ASC');
|
||||
} else if (untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
} else {
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
// 投稿の作者にブロックされていない かつ
|
||||
// 投稿の返信先の作者にブロックされていない かつ
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockeeId')
|
||||
.where('blocking.blockerId = :blockerId', { blockerId: me.id });
|
||||
|
||||
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
|
||||
q.setParameters(blockedQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere('note.channelId IS NULL');
|
||||
} else {
|
||||
q.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
|
||||
.select('channelFollowing.followeeId')
|
||||
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
// チャンネルのノートではない
|
||||
.where('note.channelId IS NULL')
|
||||
// または自分がフォローしているチャンネルのノート
|
||||
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(channelFollowingQuery.getParameters());
|
||||
}
|
||||
}
|
||||
|
||||
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
|
||||
.select('muted.noteId')
|
||||
.where('muted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
.select('threadMuted.threadId')
|
||||
.where('threadMuted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
|
||||
}
|
||||
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
.select('user_profile.mutedInstances')
|
||||
.where('user_profile.userId = :muterId', { muterId: me.id });
|
||||
|
||||
// 投稿の作者をミュートしていない かつ
|
||||
// 投稿の返信先の作者をミュートしていない かつ
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
q.setParameters(mutingInstanceQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
} else if (!me.showTimelineReplies) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
}))
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(new Brackets(qb => { qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
|
||||
q.setParameters({ meId: me.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
242
packages/backend/src/core/QueueService.ts
Normal file
242
packages/backend/src/core/QueueService.ts
Normal file
|
@ -0,0 +1,242 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { IActivity } from '@/core/remote/activitypub/type.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js';
|
||||
import type { ThinUser } from '../queue/types.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
||||
) {}
|
||||
|
||||
public deliver(user: ThinUser, content: IActivity, to: string | null) {
|
||||
if (content == null) return null;
|
||||
if (to == null) return null;
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
content,
|
||||
to,
|
||||
};
|
||||
|
||||
return this.deliverQueue.add(data, {
|
||||
attempts: this.config.deliverJobMaxAttempts ?? 12,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
|
||||
const data = {
|
||||
activity: activity,
|
||||
signature,
|
||||
};
|
||||
|
||||
return this.inboxQueue.add(data, {
|
||||
attempts: this.config.inboxJobMaxAttempts ?? 8,
|
||||
timeout: 5 * 60 * 1000, // 5min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createDeleteDriveFilesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('deleteDriveFiles', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportCustomEmojisJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportCustomEmojis', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportNotesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportNotes', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
|
||||
return this.dbQueue.add('exportFollowing', {
|
||||
user: user,
|
||||
excludeMuting,
|
||||
excludeInactive,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportMuteJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportMuting', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportBlockingJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportBlocking', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportUserListsJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportUserLists', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importFollowing', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importMuting', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importBlocking', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importUserLists', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importCustomEmojis', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
|
||||
return this.dbQueue.add('deleteAccount', {
|
||||
user: user,
|
||||
soft: opts.soft,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createDeleteObjectStorageFileJob(key: string) {
|
||||
return this.objectStorageQueue.add('deleteFile', {
|
||||
key: key,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createCleanRemoteFilesJob() {
|
||||
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) {
|
||||
const data = {
|
||||
type,
|
||||
content,
|
||||
webhookId: webhook.id,
|
||||
userId: webhook.userId,
|
||||
to: webhook.url,
|
||||
secret: webhook.secret,
|
||||
createdAt: Date.now(),
|
||||
eventId: uuid(),
|
||||
};
|
||||
|
||||
return this.webhookDeliverQueue.add(data, {
|
||||
attempts: 4,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.deliverQueue.once('cleaned', (jobs, status) => {
|
||||
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.deliverQueue.clean(0, 'delayed');
|
||||
|
||||
this.inboxQueue.once('cleaned', (jobs, status) => {
|
||||
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.inboxQueue.clean(0, 'delayed');
|
||||
}
|
||||
}
|
340
packages/backend/src/core/ReactionService.ts
Normal file
340
packages/backend/src/core/ReactionService.ts
Normal file
|
@ -0,0 +1,340 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
|
||||
import { emojiRegex } from '@/misc/emoji-regex.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
|
||||
const legacies: Record<string, string> = {
|
||||
'like': '👍',
|
||||
'love': '❤', // ここに記述する場合は異体字セレクタを入れない
|
||||
'laugh': '😆',
|
||||
'hmm': '🤔',
|
||||
'surprise': '😮',
|
||||
'congrats': '🎉',
|
||||
'angry': '💢',
|
||||
'confused': '😥',
|
||||
'rip': '😇',
|
||||
'pudding': '🍮',
|
||||
'star': '⭐',
|
||||
};
|
||||
|
||||
type DecodedReaction = {
|
||||
/**
|
||||
* リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.')
|
||||
*/
|
||||
reaction: string;
|
||||
|
||||
/**
|
||||
* name (カスタム絵文字の場合name, Emojiクエリに使う)
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* host (カスタム絵文字の場合host, Emojiクエリに使う)
|
||||
*/
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReactionService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private metaService: MetaService,
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async create(user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) {
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const block = await this.blockingsRepository.findOneBy({
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
|
||||
}
|
||||
}
|
||||
|
||||
// check visibility
|
||||
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
|
||||
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
|
||||
}
|
||||
|
||||
// TODO: cache
|
||||
reaction = await this.toDbReaction(reaction, user.host);
|
||||
|
||||
const record: NoteReaction = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
reaction,
|
||||
};
|
||||
|
||||
// Create reaction
|
||||
try {
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} catch (e) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const exists = await this.noteReactionsRepository.findOneByOrFail({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (exists.reaction !== reaction) {
|
||||
// 別のリアクションがすでにされていたら置き換える
|
||||
await this.delete(user, note);
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} else {
|
||||
// 同じリアクションがすでにされていたらエラー
|
||||
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Increment reactions count
|
||||
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
score: () => '"score" + 1',
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
this.perUserReactionsChart.update(user, note);
|
||||
|
||||
// カスタム絵文字リアクションだったら絵文字情報も送る
|
||||
const decodedReaction = this.decodeReaction(reaction);
|
||||
|
||||
const emoji = await this.emojisRepository.findOne({
|
||||
where: {
|
||||
name: decodedReaction.name,
|
||||
host: decodedReaction.host ?? IsNull(),
|
||||
},
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
});
|
||||
|
||||
this.globalEventServie.publishNoteStream(note.id, 'reacted', {
|
||||
reaction: decodedReaction.reaction,
|
||||
emoji: emoji != null ? {
|
||||
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
|
||||
url: emoji.publicUrl ?? emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため
|
||||
} : null,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
||||
if (note.userHost === null) {
|
||||
this.createNotificationService.createNotification(note.userId, 'reaction', {
|
||||
notifierId: user.id,
|
||||
noteId: note.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
}
|
||||
|
||||
//#region 配信
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
|
||||
const content = this.apRendererService.renderActivity(await this.apRendererService.renderLike(record, note));
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
if (note.userHost !== null) {
|
||||
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
dm.addDirectRecipe(reactee as IRemoteUser);
|
||||
}
|
||||
|
||||
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
} else if (note.visibility === 'specified') {
|
||||
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
|
||||
for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) {
|
||||
dm.addDirectRecipe(u as IRemoteUser);
|
||||
}
|
||||
}
|
||||
|
||||
dm.execute();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
public async delete(user: { id: User['id']; host: User['host']; }, note: Note) {
|
||||
// if already unreacted
|
||||
const exist = await this.noteReactionsRepository.findOneBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
||||
}
|
||||
|
||||
// Delete reaction
|
||||
const result = await this.noteReactionsRepository.delete(exist.id);
|
||||
|
||||
if (result.affected !== 1) {
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
||||
}
|
||||
|
||||
// Decrement reactions count
|
||||
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
this.notesRepository.decrement({ id: note.id }, 'score', 1);
|
||||
|
||||
this.globalEventServie.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
//#region 配信
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
if (note.userHost !== null) {
|
||||
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
dm.addDirectRecipe(reactee as IRemoteUser);
|
||||
}
|
||||
dm.addFollowersRecipe();
|
||||
dm.execute();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
public async getFallbackReaction(): Promise<string> {
|
||||
const meta = await this.metaService.fetch();
|
||||
return meta.useStarForReactionFallback ? '⭐' : '👍';
|
||||
}
|
||||
|
||||
public convertLegacyReactions(reactions: Record<string, number>) {
|
||||
const _reactions = {} as Record<string, number>;
|
||||
|
||||
for (const reaction of Object.keys(reactions)) {
|
||||
if (reactions[reaction] <= 0) continue;
|
||||
|
||||
if (Object.keys(legacies).includes(reaction)) {
|
||||
if (_reactions[legacies[reaction]]) {
|
||||
_reactions[legacies[reaction]] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[legacies[reaction]] = reactions[reaction];
|
||||
}
|
||||
} else {
|
||||
if (_reactions[reaction]) {
|
||||
_reactions[reaction] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[reaction] = reactions[reaction];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _reactions2 = {} as Record<string, number>;
|
||||
|
||||
for (const reaction of Object.keys(_reactions)) {
|
||||
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
|
||||
}
|
||||
|
||||
return _reactions2;
|
||||
}
|
||||
|
||||
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
|
||||
if (reaction == null) return await this.getFallbackReaction();
|
||||
|
||||
reacterHost = this.utilityService.toPunyNullable(reacterHost);
|
||||
|
||||
// 文字列タイプのリアクションを絵文字に変換
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
|
||||
// Unicode絵文字
|
||||
const match = emojiRegex.exec(reaction);
|
||||
if (match) {
|
||||
// 合字を含む1つの絵文字
|
||||
const unicode = match[0];
|
||||
|
||||
// 異体字セレクタ除去
|
||||
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
|
||||
}
|
||||
|
||||
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
|
||||
if (custom) {
|
||||
const name = custom[1];
|
||||
const emoji = await this.emojisRepository.findOneBy({
|
||||
host: reacterHost ?? IsNull(),
|
||||
name,
|
||||
});
|
||||
|
||||
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
|
||||
}
|
||||
|
||||
return await this.getFallbackReaction();
|
||||
}
|
||||
|
||||
public decodeReaction(str: string): DecodedReaction {
|
||||
const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
|
||||
|
||||
if (custom) {
|
||||
const name = custom[1];
|
||||
const host = custom[2] ?? null;
|
||||
|
||||
return {
|
||||
reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする
|
||||
name,
|
||||
host,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reaction: str,
|
||||
name: undefined,
|
||||
host: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public convertLegacyReaction(reaction: string): string {
|
||||
reaction = this.decodeReaction(reaction).reaction;
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
return reaction;
|
||||
}
|
||||
}
|
119
packages/backend/src/core/RelayService.ts
Normal file
119
packages/backend/src/core/RelayService.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { ILocalUser, User } from '@/models/entities/User.js';
|
||||
import { RelaysRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { Relay } from '@/models/entities/Relay.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
const ACTOR_USERNAME = 'relay.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class RelayService {
|
||||
private relaysCache: Cache<Relay[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.relaysRepository)
|
||||
private relaysRepository: RelaysRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
this.relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
|
||||
}
|
||||
|
||||
private async getRelayActor(): Promise<ILocalUser> {
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
username: ACTOR_USERNAME,
|
||||
});
|
||||
|
||||
if (user) return user as ILocalUser;
|
||||
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
|
||||
return created as ILocalUser;
|
||||
}
|
||||
|
||||
public async addRelay(inbox: string): Promise<Relay> {
|
||||
const relay = await this.relaysRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
inbox,
|
||||
status: 'requesting',
|
||||
}).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const relayActor = await this.getRelayActor();
|
||||
const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const activity = this.apRendererService.renderActivity(follow);
|
||||
this.queueService.deliver(relayActor, activity, relay.inbox);
|
||||
|
||||
return relay;
|
||||
}
|
||||
|
||||
public async removeRelay(inbox: string): Promise<void> {
|
||||
const relay = await this.relaysRepository.findOneBy({
|
||||
inbox,
|
||||
});
|
||||
|
||||
if (relay == null) {
|
||||
throw new Error('relay not found');
|
||||
}
|
||||
|
||||
const relayActor = await this.getRelayActor();
|
||||
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const undo = this.apRendererService.renderUndo(follow, relayActor);
|
||||
const activity = this.apRendererService.renderActivity(undo);
|
||||
this.queueService.deliver(relayActor, activity, relay.inbox);
|
||||
|
||||
await this.relaysRepository.delete(relay.id);
|
||||
}
|
||||
|
||||
public async listRelay(): Promise<Relay[]> {
|
||||
const relays = await this.relaysRepository.find();
|
||||
return relays;
|
||||
}
|
||||
|
||||
public async relayAccepted(id: string): Promise<string> {
|
||||
const result = await this.relaysRepository.update(id, {
|
||||
status: 'accepted',
|
||||
});
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
public async relayRejected(id: string): Promise<string> {
|
||||
const result = await this.relaysRepository.update(id, {
|
||||
status: 'rejected',
|
||||
});
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
|
||||
if (activity == null) return;
|
||||
|
||||
const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({
|
||||
status: 'accepted',
|
||||
}));
|
||||
if (relays.length === 0) return;
|
||||
|
||||
// TODO
|
||||
//const copy = structuredClone(activity);
|
||||
const copy = JSON.parse(JSON.stringify(activity));
|
||||
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||
|
||||
const signed = await this.apRendererService.attachLdSignature(copy, user);
|
||||
|
||||
for (const relay of relays) {
|
||||
this.queueService.deliver(user, signed, relay.inbox);
|
||||
}
|
||||
}
|
||||
}
|
38
packages/backend/src/core/S3Service.ts
Normal file
38
packages/backend/src/core/S3Service.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import S3 from 'aws-sdk/clients/s3.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { Meta } from '@/models/entities/Meta.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
|
||||
@Injectable()
|
||||
export class S3Service {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public getS3(meta: Meta) {
|
||||
const u = meta.objectStorageEndpoint != null
|
||||
? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}`
|
||||
: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`;
|
||||
|
||||
return new S3({
|
||||
endpoint: meta.objectStorageEndpoint ?? undefined,
|
||||
accessKeyId: meta.objectStorageAccessKey!,
|
||||
secretAccessKey: meta.objectStorageSecretKey!,
|
||||
region: meta.objectStorageRegion ?? undefined,
|
||||
sslEnabled: meta.objectStorageUseSSL,
|
||||
s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted
|
||||
? false
|
||||
: meta.objectStorageS3ForcePathStyle,
|
||||
httpOptions: {
|
||||
agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
141
packages/backend/src/core/SignupService.ts
Normal file
141
packages/backend/src/core/SignupService.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import { generateKeyPair } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsedUsernamesRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { UsedUsername } from '@/models/entities/UsedUsername.js';
|
||||
import generateUserToken from '@/misc/generate-native-user-token.js';
|
||||
import UsersChart from './chart/charts/users.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.usedUsernamesRepository)
|
||||
private usedUsernamesRepository: UsedUsernamesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private usersChart: UsersChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async signup(opts: {
|
||||
username: User['username'];
|
||||
password?: string | null;
|
||||
passwordHash?: UserProfile['password'] | null;
|
||||
host?: string | null;
|
||||
}) {
|
||||
const { username, password, passwordHash, host } = opts;
|
||||
let hash = passwordHash;
|
||||
|
||||
// Validate username
|
||||
if (!this.userEntityService.validateLocalUsername(username)) {
|
||||
throw new Error('INVALID_USERNAME');
|
||||
}
|
||||
|
||||
if (password != null && passwordHash == null) {
|
||||
// Validate password
|
||||
if (!this.userEntityService.validatePassword(password)) {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
}
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
hash = await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = generateUserToken();
|
||||
|
||||
// Check username duplication
|
||||
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
|
||||
throw new Error('DUPLICATED_USERNAME');
|
||||
}
|
||||
|
||||
// Check deleted username duplication
|
||||
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
|
||||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
|
||||
const keyPair = await new Promise<string[]>((res, rej) =>
|
||||
generateKeyPair('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
cipher: undefined,
|
||||
passphrase: undefined,
|
||||
},
|
||||
} as any, (err, publicKey, privateKey) =>
|
||||
err ? rej(err) : res([publicKey, privateKey]),
|
||||
));
|
||||
|
||||
let account!: User;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const exist = await transactionalEntityManager.findOneBy(User, {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (exist) throw new Error(' the username is already used');
|
||||
|
||||
account = await transactionalEntityManager.save(new User({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
username: username,
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: this.utilityService.toPunyNullable(host),
|
||||
token: secret,
|
||||
isAdmin: (await this.usersRepository.countBy({
|
||||
host: IsNull(),
|
||||
})) === 0,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new UserKeypair({
|
||||
publicKey: keyPair[0],
|
||||
privateKey: keyPair[1],
|
||||
userId: account.id,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new UserProfile({
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: true,
|
||||
password: hash,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new UsedUsername({
|
||||
createdAt: new Date(),
|
||||
username: username.toLowerCase(),
|
||||
}));
|
||||
});
|
||||
|
||||
this.usersChart.update(account, true);
|
||||
|
||||
return { account, secret };
|
||||
}
|
||||
}
|
||||
|
439
packages/backend/src/core/TwoFactorAuthenticationService.ts
Normal file
439
packages/backend/src/core/TwoFactorAuthenticationService.ts
Normal file
|
@ -0,0 +1,439 @@
|
|||
import * as crypto from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as jsrsasign from 'jsrsasign';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
const ECC_PRELUDE = Buffer.from([0x04]);
|
||||
const NULL_BYTE = Buffer.from([0]);
|
||||
const PEM_PRELUDE = Buffer.from(
|
||||
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
|
||||
'hex',
|
||||
);
|
||||
|
||||
// Android Safetynet attestations are signed with this cert:
|
||||
const GSR2 = `-----BEGIN CERTIFICATE-----
|
||||
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
|
||||
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
|
||||
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
|
||||
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
|
||||
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
|
||||
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
|
||||
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
|
||||
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
|
||||
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
|
||||
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
|
||||
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
|
||||
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
|
||||
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
|
||||
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
|
||||
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
|
||||
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
|
||||
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
|
||||
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
|
||||
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
|
||||
-----END CERTIFICATE-----\n`;
|
||||
|
||||
function base64URLDecode(source: string) {
|
||||
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function getCertSubject(certificate: string) {
|
||||
const subjectCert = new jsrsasign.X509();
|
||||
subjectCert.readCertPEM(certificate);
|
||||
|
||||
const subjectString = subjectCert.getSubjectString();
|
||||
const subjectFields = subjectString.slice(1).split('/');
|
||||
|
||||
const fields = {} as Record<string, string>;
|
||||
for (const field of subjectFields) {
|
||||
const eqIndex = field.indexOf('=');
|
||||
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function verifyCertificateChain(certificates: string[]) {
|
||||
let valid = true;
|
||||
|
||||
for (let i = 0; i < certificates.length; i++) {
|
||||
const Cert = certificates[i];
|
||||
const certificate = new jsrsasign.X509();
|
||||
certificate.readCertPEM(Cert);
|
||||
|
||||
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
|
||||
|
||||
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
|
||||
const algorithm = certificate.getSignatureAlgorithmField();
|
||||
const signatureHex = certificate.getSignatureValueHex();
|
||||
|
||||
// Verify against CA
|
||||
const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm });
|
||||
Signature.init(CACert);
|
||||
Signature.updateHex(certStruct);
|
||||
valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
|
||||
if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
|
||||
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
|
||||
type = 'PUBLIC KEY';
|
||||
}
|
||||
const cert = pemBuffer.toString('base64');
|
||||
|
||||
const keyParts = [];
|
||||
const max = Math.ceil(cert.length / 64);
|
||||
let start = 0;
|
||||
for (let i = 0; i < max; i++) {
|
||||
keyParts.push(cert.substring(start, start + 64));
|
||||
start += 64;
|
||||
}
|
||||
|
||||
return (
|
||||
`-----BEGIN ${type}-----\n` +
|
||||
keyParts.join('\n') +
|
||||
`\n-----END ${type}-----\n`
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorAuthenticationService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public hash(data: Buffer) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(data)
|
||||
.digest();
|
||||
}
|
||||
|
||||
public verifySignin({
|
||||
publicKey,
|
||||
authenticatorData,
|
||||
clientDataJSON,
|
||||
clientData,
|
||||
signature,
|
||||
challenge,
|
||||
}: {
|
||||
publicKey: Buffer,
|
||||
authenticatorData: Buffer,
|
||||
clientDataJSON: Buffer,
|
||||
clientData: any,
|
||||
signature: Buffer,
|
||||
challenge: string
|
||||
}) {
|
||||
if (clientData.type !== 'webauthn.get') {
|
||||
throw new Error('type is not webauthn.get');
|
||||
}
|
||||
|
||||
if (this.hash(clientData.challenge).toString('hex') !== challenge) {
|
||||
throw new Error('challenge mismatch');
|
||||
}
|
||||
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat(
|
||||
[authenticatorData, this.hash(clientDataJSON)],
|
||||
32 + authenticatorData.length,
|
||||
);
|
||||
|
||||
return crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(publicKey), signature);
|
||||
}
|
||||
|
||||
public getProcedures() {
|
||||
return {
|
||||
none: {
|
||||
verify({ publicKey }: { publicKey: Map<number, Buffer> }) {
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyU2F,
|
||||
valid: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
'android-key': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
if (attStmt.alg !== -7) {
|
||||
throw new Error('alg mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
]);
|
||||
|
||||
const attCert: Buffer = attStmt.x5c[0];
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
if (!attCert.equals(publicKeyData)) {
|
||||
throw new Error('public key mismatch');
|
||||
}
|
||||
|
||||
const isValid = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
},
|
||||
},
|
||||
// what a stupid attestation
|
||||
'android-safetynet': {
|
||||
verify: ({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) => {
|
||||
const verificationData = this.hash(
|
||||
Buffer.concat([authenticatorData, clientDataHash]),
|
||||
);
|
||||
|
||||
const jwsParts = attStmt.response.toString('utf-8').split('.');
|
||||
|
||||
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
|
||||
const response = JSON.parse(
|
||||
base64URLDecode(jwsParts[1]).toString('utf-8'),
|
||||
);
|
||||
const signature = jwsParts[2];
|
||||
|
||||
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
|
||||
throw new Error('invalid nonce');
|
||||
}
|
||||
|
||||
const certificateChain = header.x5c
|
||||
.map((key: any) => PEMString(key))
|
||||
.concat([GSR2]);
|
||||
|
||||
if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
|
||||
throw new Error('invalid common name');
|
||||
}
|
||||
|
||||
if (!verifyCertificateChain(certificateChain)) {
|
||||
throw new Error('Invalid certificate chain!');
|
||||
}
|
||||
|
||||
const signatureBase = Buffer.from(
|
||||
jwsParts[0] + '.' + jwsParts[1],
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const valid = crypto
|
||||
.createVerify('sha256')
|
||||
.update(signatureBase)
|
||||
.verify(certificateChain[0], base64URLDecode(signature));
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
return {
|
||||
valid,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
},
|
||||
},
|
||||
packed: {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
]);
|
||||
|
||||
if (attStmt.x5c) {
|
||||
const attCert = attStmt.x5c[0];
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
} else if (attStmt.ecdaaKeyId) {
|
||||
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
|
||||
throw new Error('ECDAA-Verify is not supported');
|
||||
} else {
|
||||
if (attStmt.alg !== -7) throw new Error('alg mismatch');
|
||||
|
||||
throw new Error('self attestation is not supported');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'fido-u2f': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>,
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer
|
||||
}) {
|
||||
const x5c: Buffer[] = attStmt.x5c;
|
||||
if (x5c.length !== 1) {
|
||||
throw new Error('x5c length does not match expectation');
|
||||
}
|
||||
|
||||
const attCert = x5c[0];
|
||||
|
||||
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
|
||||
|
||||
const negTwo: Buffer = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree: Buffer = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
NULL_BYTE,
|
||||
rpIdHash,
|
||||
clientDataHash,
|
||||
credentialId,
|
||||
publicKeyU2F,
|
||||
]);
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyU2F,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
199
packages/backend/src/core/UserBlockingService.ts
Normal file
199
packages/backend/src/core/UserBlockingService.ts
Normal file
|
@ -0,0 +1,199 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { CacheableUser, User } from '@/models/entities/User.js';
|
||||
import type { Blocking } from '@/models/entities/Blocking.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { WebhookService } from './WebhookService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserBlockingService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private webhookService: WebhookService,
|
||||
private apRendererService: ApRendererService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async block(blocker: User, blockee: User) {
|
||||
await Promise.all([
|
||||
this.cancelRequest(blocker, blockee),
|
||||
this.cancelRequest(blockee, blocker),
|
||||
this.unFollow(blocker, blockee),
|
||||
this.unFollow(blockee, blocker),
|
||||
this.removeFromList(blockee, blocker),
|
||||
]);
|
||||
|
||||
const blocking = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
blocker,
|
||||
blockerId: blocker.id,
|
||||
blockee,
|
||||
blockeeId: blockee.id,
|
||||
} as Blocking;
|
||||
|
||||
await this.blockingsRepository.insert(blocking);
|
||||
|
||||
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking));
|
||||
this.queueService.deliver(blocker, content, blockee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelRequest(follower: User, followee: User) {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(followee, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// リモートにフォローリクエストをしていたらUndoFollow送信
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
|
||||
// リモートからフォローリクエストを受けていたらReject送信
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
private async unFollow(follower: User, followee: User) {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
|
||||
if (following == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.followingsRepository.delete(following.id),
|
||||
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
|
||||
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
|
||||
this.perUserFollowingChart.update(follower, followee, false),
|
||||
]);
|
||||
|
||||
// Publish unfollow event
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// リモートにフォローをしていたらUndoFollow送信
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeFromList(listOwner: User, user: User) {
|
||||
const userLists = await this.userListsRepository.findBy({
|
||||
userId: listOwner.id,
|
||||
});
|
||||
|
||||
for (const userList of userLists) {
|
||||
await this.userListJoiningsRepository.delete({
|
||||
userListId: userList.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async unblock(blocker: CacheableUser, blockee: CacheableUser) {
|
||||
const blocking = await this.blockingsRepository.findOneBy({
|
||||
blockerId: blocker.id,
|
||||
blockeeId: blockee.id,
|
||||
});
|
||||
|
||||
if (blocking == null) {
|
||||
logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
// Since we already have the blocker and blockee, we do not need to fetch
|
||||
// them in the query above and can just manually insert them here.
|
||||
blocking.blocker = blocker;
|
||||
blocking.blockee = blockee;
|
||||
|
||||
await this.blockingsRepository.delete(blocking.id);
|
||||
|
||||
// deliver if remote bloking
|
||||
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker));
|
||||
this.queueService.deliver(blocker, content, blockee.inbox);
|
||||
}
|
||||
}
|
||||
}
|
74
packages/backend/src/core/UserCacheService.ts
Normal file
74
packages/backend/src/core/UserCacheService.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class UserCacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: Cache<CacheableUser>;
|
||||
public localUserByNativeTokenCache: Cache<CacheableLocalUser | null>;
|
||||
public localUserByIdCache: Cache<CacheableLocalUser>;
|
||||
public uriPersonCache: Cache<CacheableUser | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.userByIdCache = new Cache<CacheableUser>(Infinity);
|
||||
this.localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
|
||||
this.uriPersonCache = new Cache<CacheableUser | null>(Infinity);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
private async onMessage(_, data) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message;
|
||||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'userChangeSilencedState':
|
||||
case 'userChangeModeratorState':
|
||||
case 'remoteUserUpdated': {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: body.id });
|
||||
this.userByIdCache.set(user.id, user);
|
||||
for (const [k, v] of this.uriPersonCache.cache.entries()) {
|
||||
if (v.value?.id === user.id) {
|
||||
this.uriPersonCache.set(k, user);
|
||||
}
|
||||
}
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.localUserByNativeTokenCache.set(user.token, user);
|
||||
this.localUserByIdCache.set(user.id, user);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'userTokenRegenerated': {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as ILocalUser;
|
||||
this.localUserByNativeTokenCache.delete(body.oldToken);
|
||||
this.localUserByNativeTokenCache.set(body.newToken, user);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
}
|
||||
}
|
574
packages/backend/src/core/UserFollowingService.ts
Normal file
574
packages/backend/src/core/UserFollowingService.ts
Normal file
|
@ -0,0 +1,574 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { WebhookService } from '@/core/WebhookService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Logger from '../logger.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
|
||||
type Local = ILocalUser | {
|
||||
id: ILocalUser['id'];
|
||||
host: ILocalUser['host'];
|
||||
uri: ILocalUser['uri']
|
||||
};
|
||||
type Remote = IRemoteUser | {
|
||||
id: IRemoteUser['id'];
|
||||
host: IRemoteUser['host'];
|
||||
uri: IRemoteUser['uri'];
|
||||
inbox: IRemoteUser['inbox'];
|
||||
};
|
||||
type Both = Local | Remote;
|
||||
|
||||
@Injectable()
|
||||
export class UserFollowingService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private webhookService: WebhookService,
|
||||
private apRendererService: ApRendererService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
]);
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: follower.id,
|
||||
blockeeId: followee.id,
|
||||
}),
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: followee.id,
|
||||
blockeeId: follower.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) {
|
||||
// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
return;
|
||||
} else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) {
|
||||
// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
|
||||
await this.blockingsRepository.delete(blocking.id);
|
||||
} else {
|
||||
// それ以外は単純に例外
|
||||
if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
|
||||
if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||
}
|
||||
|
||||
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
|
||||
|
||||
// フォロー対象が鍵アカウントである or
|
||||
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
|
||||
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
|
||||
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
|
||||
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
|
||||
let autoAccept = false;
|
||||
|
||||
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
if (following) {
|
||||
autoAccept = true;
|
||||
}
|
||||
|
||||
// フォローしているユーザーは自動承認オプション
|
||||
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
|
||||
const followed = await this.followingsRepository.findOneBy({
|
||||
followerId: followee.id,
|
||||
followeeId: follower.id,
|
||||
});
|
||||
|
||||
if (followed) autoAccept = true;
|
||||
}
|
||||
|
||||
if (!autoAccept) {
|
||||
await this.createFollowRequest(follower, followee, requestId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.insertFollowingDoc(followee, follower);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
private async insertFollowingDoc(
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
|
||||
},
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
|
||||
},
|
||||
): Promise<void> {
|
||||
if (follower.id === followee.id) return;
|
||||
|
||||
let alreadyFollowed = false as boolean;
|
||||
|
||||
await this.followingsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
|
||||
// 非正規化
|
||||
followerHost: follower.host,
|
||||
followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null,
|
||||
followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null,
|
||||
followeeHost: followee.host,
|
||||
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null,
|
||||
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}`);
|
||||
alreadyFollowed = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const req = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (req) {
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
// 通知を作成
|
||||
this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', {
|
||||
notifierId: followee.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (alreadyFollowed) return;
|
||||
|
||||
//#region Increment counts
|
||||
await Promise.all([
|
||||
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
|
||||
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
|
||||
]);
|
||||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, true);
|
||||
|
||||
// Publish follow event
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'follow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Publish followed event
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(follower.id, followee).then(async packed => {
|
||||
this.globalEventServie.publishMainStream(followee.id, 'followed', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'followed', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 通知を作成
|
||||
this.createNotificationService.createNotification(followee.id, 'follow', {
|
||||
notifierId: follower.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async unfollow(
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
silent = false,
|
||||
): Promise<void> {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
|
||||
if (following == null) {
|
||||
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
|
||||
this.decrementFollowing(follower, followee);
|
||||
|
||||
// Publish unfollow event
|
||||
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
|
||||
// local user has null host
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
private async decrementFollowing(
|
||||
follower: {id: User['id']; host: User['host']; },
|
||||
followee: { id: User['id']; host: User['host']; },
|
||||
): Promise<void> {
|
||||
//#region Decrement following / followers counts
|
||||
await Promise.all([
|
||||
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
|
||||
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
|
||||
]);
|
||||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, false);
|
||||
}
|
||||
|
||||
public async createFollowRequest(
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
if (follower.id === followee.id) return;
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: follower.id,
|
||||
blockeeId: followee.id,
|
||||
}),
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: followee.id,
|
||||
blockeeId: follower.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (blocking != null) throw new Error('blocking');
|
||||
if (blocked != null) throw new Error('blocked');
|
||||
|
||||
const followRequest = await this.followRequestsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
requestId,
|
||||
|
||||
// 非正規化
|
||||
followerHost: follower.host,
|
||||
followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined,
|
||||
followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined,
|
||||
followeeHost: followee.host,
|
||||
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined,
|
||||
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined,
|
||||
}).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
// Publish receiveRequest event
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed));
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
|
||||
// 通知を作成
|
||||
this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', {
|
||||
notifierId: follower.id,
|
||||
followRequestId: followRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
public async cancelFollowRequest(
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']
|
||||
},
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']
|
||||
},
|
||||
): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (request == null) {
|
||||
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
|
||||
}
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
public async acceptFollowRequest(
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
follower: CacheableUser,
|
||||
): Promise<void> {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (request == null) {
|
||||
throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.');
|
||||
}
|
||||
|
||||
await this.insertFollowingDoc(followee, follower);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
public async acceptAllFollowRequests(
|
||||
user: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
): Promise<void> {
|
||||
const requests = await this.followRequestsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
for (const request of requests) {
|
||||
const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
|
||||
this.acceptFollowRequest(user, follower);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API following/request/reject
|
||||
*/
|
||||
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(follower)) {
|
||||
this.deliverReject(user, follower);
|
||||
}
|
||||
|
||||
await this.removeFollowRequest(user, follower);
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.publishUnfollow(user, follower);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API following/reject
|
||||
*/
|
||||
public async rejectFollow(user: Local, follower: Both): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(follower)) {
|
||||
this.deliverReject(user, follower);
|
||||
}
|
||||
|
||||
await this.removeFollow(user, follower);
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.publishUnfollow(user, follower);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AP Reject/Follow
|
||||
*/
|
||||
public async remoteReject(actor: Remote, follower: Local): Promise<void> {
|
||||
await this.removeFollowRequest(actor, follower);
|
||||
await this.removeFollow(actor, follower);
|
||||
this.publishUnfollow(actor, follower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove follow request record
|
||||
*/
|
||||
private async removeFollowRequest(followee: Both, follower: Both): Promise<void> {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (!request) return;
|
||||
|
||||
await this.followRequestsRepository.delete(request.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove follow record
|
||||
*/
|
||||
private async removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (!following) return;
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
this.decrementFollowing(follower, followee);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Reject to remote
|
||||
*/
|
||||
private async deliverReject(followee: Local, follower: Remote): Promise<void> {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish unfollow to local
|
||||
*/
|
||||
private async publishUnfollow(followee: Both, follower: Local): Promise<void> {
|
||||
const packedFollowee = await this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
});
|
||||
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packedFollowee,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
22
packages/backend/src/core/UserKeypairStoreService.ts
Normal file
22
packages/backend/src/core/UserKeypairStoreService.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { UserKeypairsRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserKeypairStoreService {
|
||||
private cache: Cache<UserKeypair>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userKeypairsRepository)
|
||||
private userKeypairsRepository: UserKeypairsRepository,
|
||||
) {
|
||||
this.cache = new Cache<UserKeypair>(Infinity);
|
||||
}
|
||||
|
||||
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
||||
return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId }));
|
||||
}
|
||||
}
|
48
packages/backend/src/core/UserListService.ts
Normal file
48
packages/backend/src/core/UserListService.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { UserList } from '@/models/entities/UserList.js';
|
||||
import type { UserListJoining } from '@/models/entities/UserListJoining.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async push(target: User, list: UserList) {
|
||||
await this.userListJoiningsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
} as UserListJoining);
|
||||
|
||||
this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
|
||||
|
||||
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
|
||||
if (this.userEntityService.isRemoteUser(target)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.userFollowingService.follow(proxy, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
packages/backend/src/core/UserMutingService.ts
Normal file
32
packages/backend/src/core/UserMutingService.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository, MutingsRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserMutingService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async mute(user: User, target: User): Promise<void> {
|
||||
await this.mutingsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
muterId: user.id,
|
||||
muteeId: target.id,
|
||||
});
|
||||
}
|
||||
}
|
88
packages/backend/src/core/UserSuspendService.ts
Normal file
88
packages/backend/src/core/UserSuspendService.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { FollowingsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async doPostUnsuspend(user: User): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにUndo Delete配信
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user as any, content, inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
packages/backend/src/core/UtilityService.ts
Normal file
37
packages/backend/src/core/UtilityService.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { URL } from 'node:url';
|
||||
import { toASCII } from 'punycode';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public getFullApAccount(username: string, host: string | null): string {
|
||||
return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`;
|
||||
}
|
||||
|
||||
public isSelfHost(host: string | null): boolean {
|
||||
if (host == null) return true;
|
||||
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||
}
|
||||
|
||||
public extractDbHost(uri: string): string {
|
||||
const url = new URL(uri);
|
||||
return this.toPuny(url.hostname);
|
||||
}
|
||||
|
||||
public toPuny(host: string): string {
|
||||
return toASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return toASCII(host.toLowerCase());
|
||||
}
|
||||
}
|
44
packages/backend/src/core/VideoProcessingService.ts
Normal file
44
packages/backend/src/core/VideoProcessingService.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessingService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async generateVideoThumbnail(source: string): Promise<IImage> {
|
||||
const [dir, cleanup] = await createTempDir();
|
||||
|
||||
try {
|
||||
await new Promise((res, rej) => {
|
||||
FFmpeg({
|
||||
source,
|
||||
})
|
||||
.on('end', res)
|
||||
.on('error', rej)
|
||||
.screenshot({
|
||||
folder: dir,
|
||||
filename: 'out.png', // must have .png extension
|
||||
count: 1,
|
||||
timestamps: ['5%'],
|
||||
});
|
||||
});
|
||||
|
||||
// JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
|
||||
return await this.imageProcessingService.convertToJpeg(`${dir}/out.png`, 498, 280);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
70
packages/backend/src/core/WebhookService.ts
Normal file
70
packages/backend/src/core/WebhookService.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { WebhooksRepository } from '@/models/index.js';
|
||||
import type { Webhook } from '@/models/entities/Webhook.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookService implements OnApplicationShutdown {
|
||||
private webhooksFetched = false;
|
||||
private webhooks: Webhook[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
@Inject(DI.webhooksRepository)
|
||||
private webhooksRepository: WebhooksRepository,
|
||||
) {
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
public async getActiveWebhooks() {
|
||||
if (!this.webhooksFetched) {
|
||||
this.webhooks = await this.webhooksRepository.findBy({
|
||||
active: true,
|
||||
});
|
||||
this.webhooksFetched = true;
|
||||
}
|
||||
|
||||
return this.webhooks;
|
||||
}
|
||||
|
||||
private async onMessage(_, data) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message;
|
||||
switch (type) {
|
||||
case 'webhookCreated':
|
||||
if (body.active) {
|
||||
this.webhooks.push(body);
|
||||
}
|
||||
break;
|
||||
case 'webhookUpdated':
|
||||
if (body.active) {
|
||||
const i = this.webhooks.findIndex(a => a.id === body.id);
|
||||
if (i > -1) {
|
||||
this.webhooks[i] = body;
|
||||
} else {
|
||||
this.webhooks.push(body);
|
||||
}
|
||||
} else {
|
||||
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
|
||||
}
|
||||
break;
|
||||
case 'webhookDeleted':
|
||||
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
}
|
||||
}
|
14
packages/backend/src/core/chart/ChartLoggerService.ts
Normal file
14
packages/backend/src/core/chart/ChartLoggerService.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChartLoggerService {
|
||||
public logger: Logger;
|
||||
|
||||
constructor(
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test');
|
||||
}
|
||||
}
|
67
packages/backend/src/core/chart/ChartManagementService.ts
Normal file
67
packages/backend/src/core/chart/ChartManagementService.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
|
||||
import FederationChart from './charts/federation.js';
|
||||
import NotesChart from './charts/notes.js';
|
||||
import UsersChart from './charts/users.js';
|
||||
import ActiveUsersChart from './charts/active-users.js';
|
||||
import InstanceChart from './charts/instance.js';
|
||||
import PerUserNotesChart from './charts/per-user-notes.js';
|
||||
import DriveChart from './charts/drive.js';
|
||||
import PerUserReactionsChart from './charts/per-user-reactions.js';
|
||||
import HashtagChart from './charts/hashtag.js';
|
||||
import PerUserFollowingChart from './charts/per-user-following.js';
|
||||
import PerUserDriveChart from './charts/per-user-drive.js';
|
||||
import ApRequestChart from './charts/ap-request.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ChartManagementService implements OnApplicationShutdown {
|
||||
private charts;
|
||||
private saveIntervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
private federationChart: FederationChart,
|
||||
private notesChart: NotesChart,
|
||||
private usersChart: UsersChart,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private instanceChart: InstanceChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private driveChart: DriveChart,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private hashtagChart: HashtagChart,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
) {
|
||||
this.charts = [
|
||||
this.federationChart,
|
||||
this.notesChart,
|
||||
this.usersChart,
|
||||
this.activeUsersChart,
|
||||
this.instanceChart,
|
||||
this.perUserNotesChart,
|
||||
this.driveChart,
|
||||
this.perUserReactionsChart,
|
||||
this.hashtagChart,
|
||||
this.perUserFollowingChart,
|
||||
this.perUserDriveChart,
|
||||
this.apRequestChart,
|
||||
];
|
||||
}
|
||||
|
||||
public async run() {
|
||||
// 20分おきにメモリ情報をDBに書き込み
|
||||
this.saveIntervalId = setInterval(() => {
|
||||
for (const chart of this.charts) {
|
||||
chart.save();
|
||||
}
|
||||
}, 1000 * 60 * 20);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
clearInterval(this.saveIntervalId);
|
||||
await Promise.all(
|
||||
this.charts.map(chart => chart.save()),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
import Chart, { KVs } from '../core.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Users } from '@/models/index.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||
import { name, schema } from './entities/active-users.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
const month = 1000 * 60 * 60 * 24 * 30;
|
||||
|
@ -11,9 +16,16 @@ const year = 1000 * 60 * 60 * 24 * 365;
|
|||
* アクティブユーザーに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class ActiveUsersChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
|
@ -1,13 +1,26 @@
|
|||
import Chart, { KVs } from '../core.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||
import { name, schema } from './entities/ap-request.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* Chart about ActivityPub requests
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class ApRequestChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
|
@ -1,16 +1,27 @@
|
|||
import Chart, { KVs } from '../core.js';
|
||||
import { DriveFiles } from '@/models/index.js';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Not, IsNull, DataSource } from 'typeorm';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||
import { name, schema } from './entities/drive.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ドライブに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class DriveChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue