diff --git a/.gitignore b/.gitignore index 42b1bde94..2ae0f98c5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /node_modules /built /uploads +/data npm-debug.log *.pem run.bat diff --git a/.travis.yml b/.travis.yml index 91e124443..ed53af9e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,14 @@ # travis file # https://docs.travis-ci.com/user/customizing-the-build +branches: + except: + - release + language: node_js node_js: - - 7.10.0 + - 8.4.0 env: - CXX=g++-4.8 NODE_ENV=production diff --git a/.travis/.gitignore-release b/.travis/.gitignore-release index ad1d3724f..ae1157b33 100644 --- a/.travis/.gitignore-release +++ b/.travis/.gitignore-release @@ -6,3 +6,5 @@ !/tools !/elasticsearch !/package.json +!/.travis.yml +!/appveyor.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a584ddb..f8018e4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,172 @@ -ChangeLog -========= +ChangeLog (Release Notes) +========================= 主に notable な changes を書いていきます +2807 (2017/11/02) +----------------- +* いい感じに + +2805 (2017/11/02) +----------------- +* いい感じに + +2801 (2017/11/01) +----------------- +* チャンネルのWatch実装 + +2799 (2017/11/01) +----------------- +* いい感じに + +2795 (2017/11/01) +----------------- +* いい感じに + +2793 (2017/11/01) +----------------- +* なんか + +2783 (2017/11/01) +----------------- +* なんか + +2777 (2017/11/01) +----------------- +* 細かいブラッシュアップ + +2775 (2017/11/01) +----------------- +* Fix: バグ修正 + +2769 (2017/11/01) +----------------- +* New: チャンネルシステム + +2752 (2017/10/30) +----------------- +* New: 未読の通知がある場合アイコンを表示するように + +2747 (2017/10/25) +----------------- +* Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89) + +2742 (2017/10/25) +----------------- +* New: トラブルシューティングを実装するなど + +2735 (2017/10/22) +----------------- +* New: モバイル版からでもクライアントバージョンを確認できるように + +2732 (2017/10/22) +----------------- +* 依存関係の更新など + +2584 (2017/09/08) +----------------- +* New: ユーザーページによく使うドメインを表示 (#771) +* New: よくリプライするユーザーをユーザーページに表示 (#770) + +2566 (2017/09/07) +----------------- +* New: 投稿することの多いキーワードをユーザーページに表示する (#768) +* l10n +* デザインの修正 + +2544 (2017/09/06) +----------------- +* 投稿のカテゴリに関する実験的な実装 +* l10n +* ユーザビリティの向上 + +2520 (2017/08/30) +----------------- +* デザインの調整 + +2518 (2017/08/30) +----------------- +* Fix: モバイル版のタイムラインからリアクションやメニューを開けない +* デザインの調整 + +2515 (2017/08/30) +----------------- +* New: 投稿のピン留め (#746) +* New: モバイル版のユーザーページに知り合いのフォロワーを表示するように +* New: ホームストリームにメッセージを流すことでlast_used_atを更新できるようにする (#745) +* その他細かな修正 + +2508 (2017/08/30) +----------------- +* New: モバイル版のユーザーページのアクティビティチャートを変更 +* New: モバイル版のユーザーページに最終ログイン日時を表示するように +* デザインの調整 + +2503 (2017/08/30) +----------------- +* デザインの調整 + +2502 (2017/08/30) +----------------- +* デザインの修正・調整 + +2501 (2017/08/30) +----------------- +* New: モバイルのユーザーページを刷新 + +2498 (2017/08/29) +----------------- +* Fix: repostのborder-radiusが効いていない (#743) +* テーマカラーを赤に戻してみた +* ユーザビリティの向上 +* デザインの調整 + +2493-2 (2017/08/29) +------------------- +* デザインの修正 + +2493 (2017/08/29) +----------------- +* デザインの変更など + +2491 (2017/08/29) +----------------- +* デザインの修正と調整 + +2489 (2017/08/29) +----------------- +* ユーザビリティの向上 +* デザインの調整 + +2487 (2017/08/29) +----------------- +* New: パスワードを変更する際に新しいパスワードを二度入力させる (#739) +* New: ドナーを表示する (#738) +* Fix: 投稿のリンクが機能していない問題を修正 +* Fix: アカウント作成フォームのユーザーページURLプレビューが正しく機能していなかった問題を修正 +* l10n +* デザインの調整 + +2470 (2017/08/29) +----------------- +* New: トークンを再生成できるように (#497) +* New: パスワードを変更する機能 (#364) + +2461 (2017/08/28) +----------------- +* Fix: モバイル版からアバターとバナーの設定を行えなかった問題を修正 +* デザインの修正 + +2458 (2017/08/28) +----------------- +* New: モバイル版からプロフィールを設定できるように +* New: モバイル版からサインアウトを行えるように +* New: 投稿ページに次の投稿/前の投稿リンクを作成 (#734) +* New: タイムラインの投稿をダブルクリックすることで詳細な情報が見れるように +* Fix: モバイル版でおすすめユーザーをフォローしてもタイムラインが更新されない (#736) +* Fix: モバイル版で設定にアクセスできない +* デザインの調整 +* 依存関係の更新 + 2380 ---- アプリケーションが作れない問題を修正 diff --git a/DONORS.md b/DONORS.md new file mode 100644 index 000000000..da71c043a --- /dev/null +++ b/DONORS.md @@ -0,0 +1,19 @@ +DONORS +====== + +(no particular order) + +* らふぁ +* 俺様 +* なぎうり +* スルメ https://surume.tk/ + +:heart: Thanks for donating, guys! + +--- + +Although you donated, you are not listed here? please contact to us! + +If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link]. + +[syuilo-link]: https://syuilo.com diff --git a/README.md b/README.md index 9d2d38149..b777618f4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Key features * Automatically updated timeline * Private messages * Free 1GB storage for each all users -* Mobile device support (smartphone, tablet, etc) +* Machine learning * Web API for third-party applications * No ads @@ -25,24 +25,23 @@ and more! You can touch with your own eyes at https://misskey.xyz/. Setup and Installation ---------------------------------------------------------------- -Please see [Setup and installation guide](./docs/setup.en.md). +If you want to run your own instance of Misskey, +please see [Setup and installation guide](./docs/setup.en.md). Contribution ---------------------------------------------------------------- Please see [Contribution guide](./CONTRIBUTING.md). +Release Notes +---------------------------------------------------------------- +Please see [ChangeLog](./CHANGELOG.md). + Sponsors & Backers ---------------------------------------------------------------- Misskey have no 100+ GitHub stars currently. However, donation are always welcome! If you want to donate to Misskey, please get in touch with [@syuilo][syuilo-link]. -Collaborators ----------------------------------------------------------------- -| ![syuilo][syuilo-icon] | ![Morisawa Aya][ayamorisawa-icon] | ![otofune][otofune-icon] | -|------------------------|-----------------------------------|---------------------------------| -| [syuilo][syuilo-link] | [Aya Morisawa][ayamorisawa-link] | [otofune][otofune-link] | - -[List of all contributors](https://github.com/syuilo/misskey/graphs/contributors) +**Note:** When you donate to Misskey, your name will be displayed in [donors](./DONORS.md). Copyright ---------------------------------------------------------------- @@ -61,7 +60,3 @@ Misskey is an open-source software licensed under [The MIT License](LICENSE). [syuilo-link]: https://syuilo.com [syuilo-icon]: https://avatars2.githubusercontent.com/u/4439005?v=3&s=70 -[ayamorisawa-link]: https://github.com/ayamorisawa -[ayamorisawa-icon]: https://avatars0.githubusercontent.com/u/10798641?v=3&s=70 -[otofune-link]: https://github.com/otofune -[otofune-icon]: https://avatars0.githubusercontent.com/u/15062473?v=3&s=70 diff --git a/appveyor.yml b/appveyor.yml index d26cbc27e..03a42b9b4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,9 +1,13 @@ # appveyor file # http://www.appveyor.com/docs/appveyor-yml +branches: + except: + - release + environment: matrix: - - nodejs_version: 7.10.0 + - nodejs_version: 8.4.0 build: off diff --git a/docs/backup.md b/docs/backup.md new file mode 100644 index 000000000..484564b31 --- /dev/null +++ b/docs/backup.md @@ -0,0 +1,22 @@ +How to backup your Misskey +========================== + +Make sure **mongodb-tools** installed. + +--- + +In your shell: +``` shell +$ mongodump --archive=db-backup +``` + +For details, plese see [mongodump docs](https://docs.mongodb.com/manual/reference/program/mongodump/). + +Restore +------- + +``` shell +$ mongorestore --archive=db-backup +``` + +For details, please see [mongorestore docs](https://docs.mongodb.com/manual/reference/program/mongorestore/). diff --git a/docs/setup.en.md b/docs/setup.en.md index 3e4893534..dbc0599b5 100644 --- a/docs/setup.en.md +++ b/docs/setup.en.md @@ -25,6 +25,7 @@ Note that Misskey uses following subdomains: * **api**.*{primary domain}* * **auth**.*{primary domain}* * **about**.*{primary domain}* +* **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* * **dev**.*{primary domain}* diff --git a/docs/setup.ja.md b/docs/setup.ja.md index 4f48a0808..602fd9b6a 100644 --- a/docs/setup.ja.md +++ b/docs/setup.ja.md @@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います: * **api**.*{primary domain}* * **auth**.*{primary domain}* * **about**.*{primary domain}* +* **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* * **dev**.*{primary domain}* diff --git a/locales/en.yml b/locales/en.yml index 55a588f99..52e8dfdb4 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,4 +1,6 @@ common: + misskey: "Note everything and share it others using Misskey." + time: unknown: "unknown" future: "future" @@ -22,12 +24,21 @@ common: confused: "Confused" pudding: "Pudding" + post_categories: + music: "Music" + game: "Video Game" + anime: "Anime" + it: "IT" + gadgets: "Gadgets" + photography: "Photography" + input-message-here: "Enter message here" send: "Send" delete: "Delete" loading: "Loading" ok: "OK" update-available: "New version of Misskey is now available({newer}, current is {current}). Reload page to apply update." + my-token-regenerated: "Your token is just regenerated, so you will signout." tags: mk-messaging-form: @@ -55,8 +66,27 @@ common: mk-error: title: "Unable to connect to the server" - description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" + description: "There is a problem with Internet connection, or the server may be down or maintaining. Please {try again} later." thanks: "Thank you for using Misskey." + troubleshoot: "Troubleshoot" + + troubleshooter: + title: "TroubleShooting" + network: "Network connection" + checking-network: "Checking network connection" + internet: "Internet connection" + checking-internet: "Checking internet connection" + server: "Server connection" + checking-server: "Checking server connection" + finding: "Finding a problem" + no-network: "There is no Network connection" + no-network-desc: "Please make sure you are connected to the Network." + no-internet: "There is no Internet connection" + no-internet-desc: "Please make sure you are connected to the Internet." + no-server: "Unable to connect to the server" + no-server-desc: "The network connection of your PC is normal, but you could not connect to Misskey's server. There is a possibility that the server is down or maintaining, please try to access it again after a while." + success: "Successfully connect to the Misskey's server" + success-desc: "It seems to be able to connect normally. Please reload the page." mk-forkit: open-github-link: "View source on Github" @@ -76,6 +106,13 @@ common: show-result: "Show result" voted: "Voted" + mk-post-menu: + pin: "Pin" + pinned: "Pinned" + select: "Select category" + categorize: "Accept" + categorized: "Category reported. Thank you!" + mk-reaction-picker: choose-reaction: "Pick your reaction" @@ -127,8 +164,24 @@ common: mk-uploader: waiting: "Waiting" +ch: + tags: + mk-index: + new: "Create new channel" + channel-title: "Channel title" + + mk-channel-form: + textarea: "Write here" + upload: "Upload" + drive: "Drive" + post: "Do" + posting: "Doing" + desktop: tags: + mk-api-info: + regenerate-token: "Please enter the password" + mk-drive-browser-base-contextmenu: create-folder: "Create a folder" upload: "Upload a file" @@ -189,9 +242,19 @@ desktop: mk-drive-browser-nav-folder: drive: "Drive" + mk-nav-home-widget: + about: "About" + stats: "Stats" + status: "Status" + wiki: "Wiki" + donors: "Donors" + repository: "Repository" + develop: "Developers" + mk-ui-header-nav: home: "Home" messaging: "Messages" + ch: "Channels" info: "News" mk-ui-header-search: @@ -204,6 +267,14 @@ desktop: settings: "Settings" signout: "Sign out" + mk-password-setting: + reset: "Change your password" + enter-current-password: "Enter the current password" + enter-new-password: "Enter the new password" + enter-new-password-again: "Enter the new password again" + not-match: "New password not matched" + changed: "Password updated successfully" + mk-post-form: post-placeholder: "What's happening?" reply-placeholder: "Reply to this post..." @@ -231,6 +302,13 @@ desktop: attaches: "{} media attached" uploading-media: "Uploading {} media" + mk-post-page: + prev: "Previous post" + next: "Next post" + + mk-settings: + password: "Password" + mk-timeline-post: reposted-by: "Reposted by {}" reply: "Reply" @@ -289,6 +367,9 @@ desktop: mobile: tags: + mk-selectdrive-page: + select-file: "Select file(s)" + mk-drive-file-viewer: download: "Download" rename: "Rename" @@ -325,19 +406,46 @@ mobile: mk-notifications-page: notifications: "Notifications" + read-all: "Are you sure you want to mark all unread notifications as read?" mk-post-page: - submit: "Post" + title: "Post" + prev: "Previous post" + next: "Next post" mk-search-page: search: "Search" + mk-settings: + signed-in-as: "Signed in as {}" + mk-settings-page: profile: "Profile" applications: "Applications" twitter-integration: "Twitter integration" signin-history: "Sign in history" + api: "API" + link: "MisskeyLink" settings: "Settings" + signout: "Sign out" + + mk-profile-setting-page: + title: "Profile Settings" + + mk-profile-setting: + will-be-published: "These profiles will be published." + name: "Name" + location: "Location" + description: "Description" + birthday: "Birthday" + avatar: "Avatar" + banner: "Banner" + avatar-saved: "Avatar updated successfully" + banner-saved: "Banner updated successfully" + set-avatar: "Choose an avatar" + set-banner: "Choose a banner" + save: "Save" + saved: "Profile updated successfully" mk-user-followers-page: followers-of: "Followers of {}" @@ -400,6 +508,7 @@ mobile: home: "Home" notifications: "Notifications" messaging: "Messages" + ch: "Channels" drive: "Drive" settings: "Settings" about: "About Misskey" @@ -416,12 +525,46 @@ mobile: no-posts-with-media: "There is no posts with media" mk-user: - is-followed: "Followed you" + follows-you: "Follows you" following: "Following" followers: "Followers" - posts: "Timeline" + posts: "Posts" + overview: "Overview" + timeline: "Timeline" media: "Media" + mk-user-overview: + recent-posts: "Recent posts" + images: "Images" + activity: "Activity" + keywords: "Keywords" + domains: "Domains" + frequently-replied-users: "Frequently talking users" + followers-you-know: "Followers you know" + last-used-at: "Last used at" + + mk-user-overview-posts: + loading: "Loading" + no-posts: "No posts" + + mk-user-overview-photos: + loading: "Loading" + no-photos: "No photos" + + mk-user-overview-keywords: + no-keywords: "No keywords" + + mk-user-overview-domains: + no-domains: "No domains" + + mk-user-overview-frequently-replied-users: + loading: "Loading" + no-users: "No users" + + mk-user-overview-followers-you-know: + loading: "Loading" + no-users: "No users" + mk-users-list: all: "All" known: "You know" diff --git a/locales/ja.yml b/locales/ja.yml index e5b2beaed..dcd012bb8 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -1,4 +1,6 @@ common: + misskey: "Misskeyに何でも投稿して皆と共有しましょう。" + time: unknown: "なぞのじかん" future: "未来" @@ -22,12 +24,21 @@ common: confused: "こまこまのこまり" pudding: "Pudding" + post_categories: + music: "音楽" + game: "ゲーム" + anime: "アニメ" + it: "IT" + gadgets: "ガジェット" + photography: "写真" + input-message-here: "ここにメッセージを入力" send: "送信" delete: "削除" loading: "読み込み中" ok: "わかった" update-available: "Misskeyの新しいバージョンがあります({newer}。現在{current}を利用中)。ページを再度読み込みすると更新が適用されます。" + my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。" tags: mk-messaging-form: @@ -55,8 +66,27 @@ common: mk-error: title: "サーバーに接続できません" - description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" + description: "インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから{再度お試し}ください。" thanks: "いつもMisskeyをご利用いただきありがとうございます。" + troubleshoot: "トラブルシュート" + + troubleshooter: + title: "トラブルシューティング" + network: "ネットワーク接続" + checking-network: "ネットワーク接続を確認中" + internet: "インターネット接続" + checking-internet: "インターネット接続を確認中" + server: "サーバー接続" + checking-server: "サーバー接続を確認中" + finding: "問題を調べています" + no-network: "ネットワークに接続されていません" + no-network-desc: "お使いのPCのネットワーク接続が正常か確認してください。" + no-internet: "インターネットに接続されていません" + no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。" + no-server: "Misskeyのサーバーに接続できません" + no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。" + success: "Misskeyのサーバーに接続できました" + success-desc: "正常に接続できるようです。ページを再度読み込みしてください。" mk-forkit: open-github-link: "View source on Github" @@ -76,6 +106,13 @@ common: show-result: "結果を見る" voted: "投票済み" + mk-post-menu: + pin: "ピン留め" + pinned: "ピン留めしました" + select: "カテゴリを選択" + categorize: "決定" + categorized: "カテゴリを報告しました。これによりMisskeyが賢くなり、投稿の自動カテゴライズに役立てられます。ご協力ありがとうございました。" + mk-reaction-picker: choose-reaction: "リアクションを選択" @@ -127,8 +164,24 @@ common: mk-uploader: waiting: "待機中" +ch: + tags: + mk-index: + new: "チャンネルを作成" + channel-title: "チャンネルのタイトル" + + mk-channel-form: + textarea: "書いて" + upload: "アップロード" + drive: "ドライブ" + post: "やる" + posting: "やってます" + desktop: tags: + mk-api-info: + regenerate-token: "パスワードを入力してください" + mk-drive-browser-base-contextmenu: create-folder: "フォルダーを作成" upload: "ファイルをアップロード" @@ -189,9 +242,19 @@ desktop: mk-drive-browser-nav-folder: drive: "ドライブ" + mk-nav-home-widget: + about: "Misskeyについて" + stats: "統計" + status: "ステータス" + wiki: "Wiki" + donors: "ドナー" + repository: "リポジトリ" + develop: "開発者" + mk-ui-header-nav: home: "ホーム" messaging: "メッセージ" + ch: "チャンネル" info: "お知らせ" mk-ui-header-search: @@ -204,6 +267,14 @@ desktop: settings: "設定" signout: "サインアウト" + mk-password-setting: + reset: "パスワードを変更する" + enter-current-password: "現在のパスワードを入力してください" + enter-new-password: "新しいパスワードを入力してください" + enter-new-password-again: "もう一度新しいパスワードを入力してください" + not-match: "新しいパスワードが一致しません" + changed: "パスワードを変更しました" + mk-post-form: post-placeholder: "いまどうしてる?" reply-placeholder: "この投稿への返信..." @@ -231,6 +302,13 @@ desktop: attaches: "添付: {}メディア" uploading-media: "{}個のメディアをアップロード中" + mk-post-page: + prev: "前の投稿" + next: "次の投稿" + + mk-settings: + password: "パスワード" + mk-timeline-post: reposted-by: "{}がRepost" reply: "返信" @@ -289,6 +367,9 @@ desktop: mobile: tags: + mk-selectdrive-page: + select-file: "ファイルを選択" + mk-drive-file-viewer: download: "ダウンロード" rename: "名前を変更" @@ -325,19 +406,46 @@ mobile: mk-notifications-page: notifications: "通知" + read-all: "すべての通知を既読にしますか?" mk-post-page: - submit: "投稿" + title: "投稿" + prev: "前の投稿" + next: "次の投稿" mk-search-page: search: "検索" + mk-settings: + signed-in-as: "{}としてサインイン中" + mk-settings-page: profile: "プロフィール" applications: "アプリケーション" twitter-integration: "Twitter連携" signin-history: "ログイン履歴" + api: "API" + link: "Misskeyリンク" settings: "設定" + signout: "サインアウト" + + mk-profile-setting-page: + title: "プロフィール設定" + + mk-profile-setting: + will-be-published: "これらのプロフィールは公開されます。" + name: "名前" + location: "場所" + description: "自己紹介" + birthday: "誕生日" + avatar: "アバター" + banner: "バナー" + avatar-saved: "アバターを保存しました" + banner-saved: "バナーを保存しました" + set-avatar: "アバターを選択する" + set-banner: "バナーを選択する" + save: "保存" + saved: "プロフィールを保存しました" mk-user-followers-page: followers-of: "{}のフォロワー" @@ -400,6 +508,7 @@ mobile: home: "ホーム" notifications: "通知" messaging: "メッセージ" + ch: "チャンネル" search: "検索" drive: "ドライブ" settings: "設定" @@ -416,13 +525,46 @@ mobile: no-posts-with-media: "メディア付き投稿はありません。" mk-user: - is-followed: "フォローされています" + follows-you: "フォローされています" following: "フォロー" followers: "フォロワー" - posts: "タイムライン" - posts-count: "ポスト" + posts: "投稿" + overview: "概要" + timeline: "タイムライン" media: "メディア" + mk-user-overview: + recent-posts: "最近の投稿" + images: "画像" + activity: "アクティビティ" + keywords: "キーワード" + domains: "頻出ドメイン" + frequently-replied-users: "よく会話するユーザー" + followers-you-know: "知り合いのフォロワー" + last-used-at: "最終ログイン" + + mk-user-overview-posts: + loading: "読み込み中" + no-posts: "投稿はありません" + + mk-user-overview-photos: + loading: "読み込み中" + no-photos: "写真はありません" + + mk-user-overview-keywords: + no-keywords: "キーワードはありません(十分な数の投稿をしていない可能性があります)" + + mk-user-overview-domains: + no-domains: "よく表れるドメインは検出されませんでした" + + mk-user-overview-frequently-replied-users: + loading: "読み込み中" + no-users: "よく会話するユーザーはいません" + + mk-user-overview-followers-you-know: + loading: "読み込み中" + no-users: "知り合いのユーザーはいません" + mk-users-list: all: "すべて" known: "知り合い" diff --git a/package.json b/package.json index f16c3c001..05cd786f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo ", - "version": "0.0.2380", + "version": "0.0.2807", "license": "MIT", "description": "A miniblog-based SNS", "bugs": "https://github.com/syuilo/misskey/issues", @@ -18,22 +18,22 @@ "clean": "gulp clean", "cleanall": "gulp cleanall", "lint": "gulp lint", - "test": "gulp test" + "test": "gulp test" }, "devDependencies": { - "@types/bcryptjs": "2.4.0", + "@types/bcryptjs": "2.4.1", "@types/body-parser": "1.16.5", "@types/chai": "4.0.4", - "@types/chai-http": "3.0.2", - "@types/chalk": "0.4.31", + "@types/chai-http": "3.0.3", + "@types/chalk": "2.2.0", "@types/compression": "0.0.34", "@types/cors": "2.8.1", "@types/debug": "0.0.30", "@types/deep-equal": "1.0.1", - "@types/elasticsearch": "5.0.14", - "@types/event-stream": "3.3.31", - "@types/express": "4.0.36", - "@types/gm": "1.17.32", + "@types/elasticsearch": "5.0.17", + "@types/event-stream": "3.3.32", + "@types/express": "4.0.37", + "@types/gm": "1.17.33", "@types/gulp": "4.0.3", "@types/gulp-htmlmin": "1.3.30", "@types/gulp-mocha": "0.0.30", @@ -47,82 +47,88 @@ "@types/is-root": "1.0.0", "@types/is-url": "1.2.28", "@types/js-yaml": "3.9.0", - "@types/mocha": "2.2.41", - "@types/mongodb": "2.2.10", - "@types/monk": "1.0.5", - "@types/morgan": "1.7.32", - "@types/ms": "0.7.29", - "@types/multer": "1.3.2", - "@types/node": "8.0.24", + "@types/mocha": "2.2.44", + "@types/mongodb": "2.2.13", + "@types/monk": "1.0.6", + "@types/morgan": "1.7.35", + "@types/ms": "0.7.30", + "@types/multer": "1.3.5", + "@types/node": "8.0.47", "@types/ratelimiter": "2.1.28", - "@types/redis": "2.6.0", - "@types/request": "2.0.1", + "@types/redis": "2.8.1", + "@types/request": "2.0.7", "@types/rimraf": "2.0.2", - "@types/riot": "3.6.0", - "@types/serve-favicon": "2.2.28", - "@types/uuid": "3.4.0", - "@types/webpack": "3.0.9", - "@types/webpack-stream": "3.2.7", + "@types/riot": "3.6.1", + "@types/serve-favicon": "2.2.29", + "@types/uuid": "3.4.3", + "@types/webpack": "3.0.14", + "@types/uuid": "3.4.3", + "@types/webpack": "3.0.13", + "@types/webpack-stream": "3.2.8", "@types/websocket": "0.0.34", - "chai": "4.1.1", + "awesome-typescript-loader": "3.3.0", + "chai": "4.1.2", "chai-http": "3.0.0", - "css-loader": "0.28.5", + "css-loader": "0.28.7", "event-stream": "3.3.4", "gulp": "3.9.1", "gulp-cssnano": "2.1.2", - "gulp-imagemin": "3.3.0", "gulp-htmlmin": "3.0.0", + "gulp-imagemin": "3.4.0", "gulp-mocha": "4.3.1", "gulp-pug": "3.3.0", "gulp-rename": "1.2.2", "gulp-replace": "0.6.1", "gulp-tslint": "8.1.2", - "gulp-typescript": "3.2.1", + "gulp-typescript": "3.2.2", "gulp-uglify": "3.0.0", "gulp-util": "3.0.8", - "mocha": "3.5.0", + "mocha": "3.5.3", "riot-tag-loader": "1.0.0", "string-replace-webpack-plugin": "0.1.3", - "style-loader": "0.18.2", + "style-loader": "0.19.0", "stylus": "0.54.5", "stylus-loader": "3.0.1", "swagger-jsdoc": "1.9.7", - "tslint": "5.6.0", + "tslint": "5.7.0", "uglify-es": "3.0.27", - "uglify-es-webpack-plugin": "0.10.0", "uglify-js": "git+https://github.com/mishoo/UglifyJS2.git#harmony", - "webpack": "3.5.5" + "uglifyjs-webpack-plugin": "1.0.1", + "webpack": "3.8.1" }, "dependencies": { + "@prezzemolo/rap": "0.1.2", "accesses": "2.5.0", - "animejs": "2.0.2", + "animejs": "2.2.0", "autwh": "0.0.1", "bcryptjs": "2.4.3", - "body-parser": "1.17.2", - "cafy": "2.4.0", - "chalk": "2.1.0", - "compression": "1.7.0", + "body-parser": "1.18.2", + "cafy": "3.0.0", + "chalk": "2.3.0", + "compression": "1.7.1", "cors": "2.8.4", - "cropperjs": "1.0.0-rc.3", + "cropperjs": "1.1.3", "crypto": "1.0.1", - "debug": "3.0.0", + "debug": "3.1.0", "deep-equal": "1.0.1", "deepcopy": "0.6.3", - "diskusage": "^0.2.2", + "diskusage": "0.2.2", "download": "6.2.5", "elasticsearch": "13.3.1", "escape-regexp": "0.0.1", "express": "4.15.4", - "file-type": "6.1.0", + "file-type": "7.2.0", "fuckadblock": "3.2.1", "gm": "1.23.0", - "inquirer": "3.2.2", + "inquirer": "3.3.0", "is-root": "1.0.0", "is-url": "1.2.2", - "js-yaml": "3.9.1", - "mongodb": "2.2.31", - "monk": "6.0.3", - "morgan": "1.8.2", + "js-yaml": "3.10.0", + "mecab-async": "^0.1.0", + "moji": "^0.5.1", + "mongodb": "2.2.33", + "monk": "6.0.5", + "morgan": "1.9.0", "ms": "2.0.0", "multer": "1.3.0", "nprogress": "0.2.0", @@ -130,26 +136,26 @@ "page": "1.7.1", "pictograph": "2.0.4", "prominence": "0.2.0", - "pug": "2.0.0-rc.3", + "pug": "2.0.0-rc.4", "ratelimiter": "3.0.3", "recaptcha-promise": "0.1.3", - "reconnecting-websocket": "3.2.0", + "reconnecting-websocket": "3.2.2", "redis": "2.8.0", - "request": "2.81.0", - "rimraf": "2.6.1", - "riot": "3.6.2", + "request": "2.83.0", + "rimraf": "2.6.2", + "riot": "3.7.4", "rndstr": "1.0.0", "s-age": "1.1.0", - "serve-favicon": "2.4.3", + "serve-favicon": "2.4.5", "summaly": "2.0.3", "syuilo-password-strength": "0.0.1", "tcp-port-used": "0.1.2", "textarea-caret": "3.0.2", "ts-node": "3.3.0", - "typescript": "2.4.2", + "typescript": "2.6.1", "uuid": "3.1.0", "vhost": "3.0.2", - "websocket": "1.0.24", + "websocket": "1.0.25", "xev": "2.0.0" } } diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts index d4cc3fc41..b289959ac 100644 --- a/src/api/authenticate.ts +++ b/src/api/authenticate.ts @@ -1,6 +1,6 @@ import * as express from 'express'; import App from './models/app'; -import User from './models/user'; +import { default as User, IUser } from './models/user'; import AccessToken from './models/access-token'; import isNativeToken from './common/is-native-token'; @@ -13,10 +13,10 @@ export interface IAuthContext { /** * Authenticated user */ - user: any; + user: IUser; /** - * Weather if the request is via the User-Native Token or not + * Whether requested with a User-Native Token */ isSecure: boolean; } @@ -25,11 +25,15 @@ export default (req: express.Request) => new Promise(async (resolv const token = req.body['i'] as string; if (token == null) { - return resolve({ app: null, user: null, isSecure: false }); + return resolve({ + app: null, + user: null, + isSecure: false + }); } if (isNativeToken(token)) { - const user = await User + const user: IUser = await User .findOne({ token: token }); if (user === null) { @@ -56,6 +60,10 @@ export default (req: express.Request) => new Promise(async (resolv const user = await User .findOne({ _id: accessToken.user_id }); - return resolve({ app: app, user: user, isSecure: false }); + return resolve({ + app: app, + user: user, + isSecure: false + }); } }); diff --git a/src/api/bot/core.ts b/src/api/bot/core.ts new file mode 100644 index 000000000..53fb18119 --- /dev/null +++ b/src/api/bot/core.ts @@ -0,0 +1,398 @@ +import * as EventEmitter from 'events'; +import * as bcrypt from 'bcryptjs'; + +import User, { IUser, init as initUser } from '../models/user'; + +import getPostSummary from '../../common/get-post-summary'; +import getUserSummary from '../../common/get-user-summary'; + +import Othello, { ai as othelloAi } from '../../common/othello'; + +const hmm = [ + '?', + 'ふぅ~む...?', + 'ちょっと何言ってるかわからないです', + '「ヘルプ」と言うと利用可能な操作が確認できますよ' +]; + +/** + * Botの頭脳 + */ +export default class BotCore extends EventEmitter { + public user: IUser = null; + + private context: Context = null; + + constructor(user?: IUser) { + super(); + + this.user = user; + } + + public clearContext() { + this.setContext(null); + } + + public setContext(context: Context) { + this.context = context; + this.emit('updated'); + + if (context) { + context.on('updated', () => { + this.emit('updated'); + }); + } + } + + public export() { + return { + user: this.user, + context: this.context ? this.context.export() : null + }; + } + + protected _import(data) { + this.user = data.user ? initUser(data.user) : null; + this.setContext(data.context ? Context.import(this, data.context) : null); + } + + public static import(data) { + const bot = new BotCore(); + bot._import(data); + return bot; + } + + public async q(query: string): Promise { + if (this.context != null) { + return await this.context.q(query); + } + + if (/^@[a-zA-Z0-9-]+$/.test(query)) { + return await this.showUserCommand(query); + } + + switch (query) { + case 'ping': + return 'PONG'; + + case 'help': + case 'ヘルプ': + return '利用可能なコマンド一覧です:\n' + + 'help: これです\n' + + 'me: アカウント情報を見ます\n' + + 'login, signin: サインインします\n' + + 'logout, signout: サインアウトします\n' + + 'post: 投稿します\n' + + 'tl: タイムラインを見ます\n' + + '@<ユーザー名>: ユーザーを表示します'; + + case 'me': + return this.user ? `${this.user.name}としてサインインしています。\n\n${getUserSummary(this.user)}` : 'サインインしていません'; + + case 'login': + case 'signin': + case 'ログイン': + case 'サインイン': + if (this.user != null) return '既にサインインしていますよ!'; + this.setContext(new SigninContext(this)); + return await this.context.greet(); + + case 'logout': + case 'signout': + case 'ログアウト': + case 'サインアウト': + if (this.user == null) return '今はサインインしてないですよ!'; + this.signout(); + return 'ご利用ありがとうございました <3'; + + case 'post': + case '投稿': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new PostContext(this)); + return await this.context.greet(); + + case 'tl': + case 'タイムライン': + return await this.tlCommand(); + + case 'guessing-game': + case '数当てゲーム': + this.setContext(new GuessingGameContext(this)); + return await this.context.greet(); + + case 'othello': + case 'オセロ': + this.setContext(new OthelloContext(this)); + return await this.context.greet(); + + default: + return hmm[Math.floor(Math.random() * hmm.length)]; + } + } + + public signin(user: IUser) { + this.user = user; + this.emit('signin', user); + this.emit('updated'); + } + + public signout() { + const user = this.user; + this.user = null; + this.emit('signout', user); + this.emit('updated'); + } + + public async refreshUser() { + this.user = await User.findOne({ + _id: this.user._id + }, { + fields: { + data: false + } + }); + + this.emit('updated'); + } + + public async tlCommand(): Promise { + if (this.user == null) return 'まずサインインしてください。'; + + const tl = await require('../endpoints/posts/timeline')({ + limit: 5 + }, this.user); + + const text = tl + .map(post => getPostSummary(post)) + .join('\n-----\n'); + + return text; + } + + public async showUserCommand(q: string): Promise { + try { + const user = await require('../endpoints/users/show')({ + username: q.substr(1) + }, this.user); + + const text = getUserSummary(user); + + return text; + } catch (e) { + return `問題が発生したようです...: ${e}`; + } + } +} + +abstract class Context extends EventEmitter { + protected bot: BotCore; + + public abstract async greet(): Promise; + public abstract async q(query: string): Promise; + public abstract export(): any; + + constructor(bot: BotCore) { + super(); + this.bot = bot; + } + + public static import(bot: BotCore, data: any) { + if (data.type == 'guessing-game') return GuessingGameContext.import(bot, data.content); + if (data.type == 'othello') return OthelloContext.import(bot, data.content); + if (data.type == 'post') return PostContext.import(bot, data.content); + if (data.type == 'signin') return SigninContext.import(bot, data.content); + return null; + } +} + +class SigninContext extends Context { + private temporaryUser: IUser = null; + + public async greet(): Promise { + return 'まずユーザー名を教えてください:'; + } + + public async q(query: string): Promise { + if (this.temporaryUser == null) { + // Fetch user + const user: IUser = await User.findOne({ + username_lower: query.toLowerCase() + }, { + fields: { + data: false + } + }); + + if (user === null) { + return `${query}というユーザーは存在しませんでした... もう一度教えてください:`; + } else { + this.temporaryUser = user; + this.emit('updated'); + return `パスワードを教えてください:`; + } + } else { + // Compare password + const same = bcrypt.compareSync(query, this.temporaryUser.password); + + if (same) { + this.bot.signin(this.temporaryUser); + this.bot.clearContext(); + return `${this.temporaryUser.name}さん、おかえりなさい!`; + } else { + return `パスワードが違います... もう一度教えてください:`; + } + } + } + + public export() { + return { + type: 'signin', + content: { + temporaryUser: this.temporaryUser + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new SigninContext(bot); + context.temporaryUser = data.temporaryUser; + return context; + } +} + +class PostContext extends Context { + public async greet(): Promise { + return '内容:'; + } + + public async q(query: string): Promise { + await require('../endpoints/posts/create')({ + text: query + }, this.bot.user); + this.bot.clearContext(); + return '投稿しましたよ!'; + } + + public export() { + return { + type: 'post' + }; + } + + public static import(bot: BotCore, data: any) { + const context = new PostContext(bot); + return context; + } +} + +class GuessingGameContext extends Context { + private secret: number; + private history: number[] = []; + + public async greet(): Promise { + this.secret = Math.floor(Math.random() * 100); + this.emit('updated'); + return '0~100の秘密の数を当ててみてください:'; + } + + public async q(query: string): Promise { + if (query == 'やめる') { + this.bot.clearContext(); + return 'やめました。'; + } + + const guess = parseInt(query, 10); + + if (isNaN(guess)) { + return '整数で推測してください。「やめる」と言うとゲームをやめます。'; + } + + const firsttime = this.history.indexOf(guess) === -1; + + this.history.push(guess); + this.emit('updated'); + + if (this.secret < guess) { + return firsttime ? `${guess}よりも小さいですね` : `もう一度言いますが${guess}より小さいですよ`; + } else if (this.secret > guess) { + return firsttime ? `${guess}よりも大きいですね` : `もう一度言いますが${guess}より大きいですよ`; + } else { + this.bot.clearContext(); + return `正解です🎉 (${this.history.length}回目で当てました)`; + } + } + + public export() { + return { + type: 'guessing-game', + content: { + secret: this.secret, + history: this.history + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new GuessingGameContext(bot); + context.secret = data.secret; + context.history = data.history; + return context; + } +} + +class OthelloContext extends Context { + private othello: Othello = null; + + constructor(bot: BotCore) { + super(bot); + + this.othello = new Othello(); + } + + public async greet(): Promise { + return this.othello.toPatternString('black'); + } + + public async q(query: string): Promise { + if (query == 'やめる') { + this.bot.clearContext(); + return 'オセロをやめました。'; + } + + const n = parseInt(query, 10); + + if (isNaN(n)) { + return '番号で指定してください。「やめる」と言うとゲームをやめます。'; + } + + this.othello.setByNumber('black', n); + const s = this.othello.toString() + '\n\n...(AI)...\n\n'; + othelloAi('white', this.othello); + if (this.othello.getPattern('black').length === 0) { + this.bot.clearContext(); + const blackCount = this.othello.board.map(row => row.filter(s => s == 'black').length).reduce((a, b) => a + b); + const whiteCount = this.othello.board.map(row => row.filter(s => s == 'white').length).reduce((a, b) => a + b); + const winner = blackCount == whiteCount ? '引き分け' : blackCount > whiteCount ? '黒の勝ち' : '白の勝ち'; + return this.othello.toString() + `\n\n~終了~\n\n黒${blackCount}、白${whiteCount}で${winner}です。`; + } else { + this.emit('updated'); + return s + this.othello.toPatternString('black'); + } + } + + public export() { + return { + type: 'othello', + content: { + board: this.othello.board + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new OthelloContext(bot); + context.othello = new Othello(); + context.othello.board = data.board; + return context; + } +} diff --git a/src/api/bot/interfaces/line.ts b/src/api/bot/interfaces/line.ts new file mode 100644 index 000000000..0caa71ed2 --- /dev/null +++ b/src/api/bot/interfaces/line.ts @@ -0,0 +1,234 @@ +import * as EventEmitter from 'events'; +import * as express from 'express'; +import * as request from 'request'; +import * as crypto from 'crypto'; +import User from '../../models/user'; +import config from '../../../conf'; +import BotCore from '../core'; +import _redis from '../../../db/redis'; +import prominence = require('prominence'); +import getPostSummary from '../../../common/get-post-summary'; + +const redis = prominence(_redis); + +// SEE: https://developers.line.me/media/messaging-api/messages/sticker_list.pdf +const stickers = [ + '297', + '298', + '299', + '300', + '301', + '302', + '303', + '304', + '305', + '306', + '307' +]; + +class LineBot extends BotCore { + private replyToken: string; + + private reply(messages: any[]) { + request.post({ + url: 'https://api.line.me/v2/bot/message/reply', + headers: { + 'Authorization': `Bearer ${config.line_bot.channel_access_token}` + }, + json: { + replyToken: this.replyToken, + messages: messages + } + }, (err, res, body) => { + if (err) { + console.error(err); + return; + } + }); + } + + public async react(ev: any): Promise { + this.replyToken = ev.replyToken; + + switch (ev.type) { + // メッセージ + case 'message': + switch (ev.message.type) { + // テキスト + case 'text': + const res = await this.q(ev.message.text); + if (res == null) return; + // 返信 + this.reply([{ + type: 'text', + text: res + }]); + break; + + // スタンプ + case 'sticker': + // スタンプで返信 + this.reply([{ + type: 'sticker', + packageId: '4', + stickerId: stickers[Math.floor(Math.random() * stickers.length)] + }]); + break; + } + break; + + // postback + case 'postback': + const data = ev.postback.data; + const cmd = data.split('|')[0]; + const arg = data.split('|')[1]; + switch (cmd) { + case 'showtl': + this.showUserTimelinePostback(arg); + break; + } + break; + } + } + + public static import(data) { + const bot = new LineBot(); + bot._import(data); + return bot; + } + + public async showUserCommand(q: string) { + const user = await require('../../endpoints/users/show')({ + username: q.substr(1) + }, this.user); + + const actions = []; + + actions.push({ + type: 'postback', + label: 'タイムラインを見る', + data: `showtl|${user.id}` + }); + + if (user.twitter) { + actions.push({ + type: 'uri', + label: 'Twitterアカウントを見る', + uri: `https://twitter.com/${user.twitter.screen_name}` + }); + } + + actions.push({ + type: 'uri', + label: 'Webで見る', + uri: `${config.url}/${user.username}` + }); + + this.reply([{ + type: 'template', + altText: await super.showUserCommand(q), + template: { + type: 'buttons', + thumbnailImageUrl: `${user.avatar_url}?thumbnail&size=1024`, + title: `${user.name} (@${user.username})`, + text: user.description || '(no description)', + actions: actions + } + }]); + } + + public async showUserTimelinePostback(userId: string) { + const tl = await require('../../endpoints/users/posts')({ + user_id: userId, + limit: 5 + }, this.user); + + const text = `${tl[0].user.name}さんのタイムラインはこちらです:\n\n` + tl + .map(post => getPostSummary(post)) + .join('\n-----\n'); + + this.reply([{ + type: 'text', + text: text + }]); + } +} + +module.exports = async (app: express.Application) => { + if (config.line_bot == null) return; + + const handler = new EventEmitter(); + + handler.on('event', async (ev) => { + + const sourceId = ev.source.userId; + const sessionId = `line-bot-sessions:${sourceId}`; + + const session = await redis.get(sessionId); + let bot: LineBot; + + if (session == null) { + const user = await User.findOne({ + line: { + user_id: sourceId + } + }); + + bot = new LineBot(user); + + bot.on('signin', user => { + User.update(user._id, { + $set: { + line: { + user_id: sourceId + } + } + }); + }); + + bot.on('signout', user => { + User.update(user._id, { + $set: { + line: { + user_id: null + } + } + }); + }); + + redis.set(sessionId, JSON.stringify(bot.export())); + } else { + bot = LineBot.import(JSON.parse(session)); + } + + bot.on('updated', () => { + redis.set(sessionId, JSON.stringify(bot.export())); + }); + + if (session != null) bot.refreshUser(); + + bot.react(ev); + }); + + app.post('/hooks/line', (req, res, next) => { + // req.headers['x-line-signature'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const sig1 = req.headers['x-line-signature'] as string; + + const hash = crypto.createHmac('SHA256', config.line_bot.channel_secret) + .update((req as any).rawBody); + + const sig2 = hash.digest('base64'); + + // シグネチャ比較 + if (sig1 === sig2) { + req.body.events.forEach(ev => { + handler.emit('event', ev); + }); + + res.sendStatus(200); + } else { + res.sendStatus(400); + } + }); +}; diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts index 714eeb520..f9c22ccac 100644 --- a/src/api/common/add-file-to-drive.ts +++ b/src/api/common/add-file-to-drive.ts @@ -4,14 +4,27 @@ import * as gm from 'gm'; import * as debug from 'debug'; import fileType = require('file-type'); import prominence = require('prominence'); -import DriveFile from '../models/drive-file'; +import DriveFile, { getGridFSBucket } from '../models/drive-file'; import DriveFolder from '../models/drive-folder'; import serialize from '../serializers/drive-file'; import event from '../event'; import config from '../../conf'; +import { Duplex } from 'stream'; const log = debug('misskey:register-drive-file'); +const addToGridFS = (name, binary, metadata): Promise => new Promise(async (resolve, reject) => { + const dataStream = new Duplex(); + dataStream.push(binary); + dataStream.push(null); + + const bucket = await getGridFSBucket(); + const writeStream = bucket.openUploadStream(name, { metadata }); + writeStream.once('finish', (doc) => { resolve(doc); }); + writeStream.on('error', reject); + dataStream.pipe(writeStream); +}); + /** * Add file to drive * @@ -58,7 +71,7 @@ export default ( // Generate hash const hash = crypto - .createHash('sha256') + .createHash('md5') .update(data) .digest('hex') as string; @@ -67,8 +80,8 @@ export default ( if (!force) { // Check if there is a file with the same hash const much = await DriveFile.findOne({ - user_id: user._id, - hash: hash + md5: hash, + 'metadata.user_id': user._id }); if (much !== null) { @@ -82,13 +95,13 @@ export default ( // Calculate drive usage const usage = ((await DriveFile .aggregate([ - { $match: { user_id: user._id } }, + { $match: { 'metadata.user_id': user._id } }, { $project: { - datasize: true + length: true }}, { $group: { _id: null, - usage: { $sum: '$datasize' } + usage: { $sum: '$length' } }} ]))[0] || { usage: 0 @@ -131,21 +144,15 @@ export default ( } // Create DriveFile document - const file = await DriveFile.insert({ - created_at: new Date(), + const file = await addToGridFS(`${user._id}/${name}`, data, { user_id: user._id, folder_id: folder !== null ? folder._id : null, - data: data, - datasize: size, type: mime, name: name, comment: comment, - hash: hash, properties: properties }); - delete file.data; - log(`drive file has been created ${file._id}`); resolve(file); diff --git a/src/api/common/generate-native-user-token.ts b/src/api/common/generate-native-user-token.ts new file mode 100644 index 000000000..2082b89a5 --- /dev/null +++ b/src/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import rndstr from 'rndstr'; + +export default () => `!${rndstr('a-zA-Z0-9', 32)}`; diff --git a/src/api/common/read-notification.ts b/src/api/common/read-notification.ts new file mode 100644 index 000000000..3009cc5d0 --- /dev/null +++ b/src/api/common/read-notification.ts @@ -0,0 +1,52 @@ +import * as mongo from 'mongodb'; +import { default as Notification, INotification } from '../models/notification'; +import publishUserStream from '../event'; + +/** + * Mark as read notification(s) + */ +export default ( + user: string | mongo.ObjectID, + message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? (message as mongo.ObjectID[]) + : typeof message[0] === 'string' + ? (message as string[]).map(m => new mongo.ObjectID(m)) + : (message as INotification[]).map(m => m._id) + : mongo.ObjectID.prototype.isPrototypeOf(message) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as INotification)._id]; + + // Update documents + await Notification.update({ + _id: { $in: ids }, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Calc count of my unread notifications + const count = await Notification + .count({ + notifiee_id: userId, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_notifications'); + } +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 5bbc480a8..afefce39e 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -159,6 +159,18 @@ const endpoints: Endpoint[] = [ }, kind: 'account-write' }, + { + name: 'i/change_password', + withCredential: true + }, + { + name: 'i/regenerate_token', + withCredential: true + }, + { + name: 'i/pin', + kind: 'account-write' + }, { name: 'i/appdata/get', withCredential: true @@ -183,6 +195,11 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-read' }, + { + name: 'notifications/get_unread_count', + withCredential: true, + kind: 'notification-read' + }, { name: 'notifications/delete', withCredential: true, @@ -193,11 +210,6 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-write' }, - { - name: 'notifications/mark_as_read', - withCredential: true, - kind: 'notification-write' - }, { name: 'notifications/mark_as_read_all', withCredential: true, @@ -314,6 +326,9 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'account-read' }, + { + name: 'users/get_frequently_replied_users' + }, { name: 'following/create', @@ -382,6 +397,10 @@ const endpoints: Endpoint[] = [ name: 'posts/trend', withCredential: true }, + { + name: 'posts/categorize', + withCredential: true + }, { name: 'posts/reactions', withCredential: true @@ -455,8 +474,33 @@ const endpoints: Endpoint[] = [ name: 'messaging/messages/create', withCredential: true, kind: 'messaging-write' - } - + }, + { + name: 'channels/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 3, + minInterval: ms('10seconds') + } + }, + { + name: 'channels/show' + }, + { + name: 'channels/posts' + }, + { + name: 'channels/watch', + withCredential: true + }, + { + name: 'channels/unwatch', + withCredential: true + }, + { + name: 'channels' + }, ]; export default endpoints; diff --git a/src/api/endpoints/aggregation/posts.ts b/src/api/endpoints/aggregation/posts.ts index 48ee22512..9d8bccbdb 100644 --- a/src/api/endpoints/aggregation/posts.ts +++ b/src/api/endpoints/aggregation/posts.ts @@ -19,7 +19,7 @@ module.exports = params => new Promise(async (res, rej) => { .aggregate([ { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -34,7 +34,7 @@ module.exports = params => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/aggregation/posts/reply.ts b/src/api/endpoints/aggregation/posts/reply.ts index 02a60c896..b114c34e1 100644 --- a/src/api/endpoints/aggregation/posts/reply.ts +++ b/src/api/endpoints/aggregation/posts/reply.ts @@ -26,7 +26,7 @@ module.exports = (params) => new Promise(async (res, rej) => { const datas = await Post .aggregate([ - { $match: { reply_to: post._id } }, + { $match: { reply: post._id } }, { $project: { created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, diff --git a/src/api/endpoints/aggregation/users/activity.ts b/src/api/endpoints/aggregation/users/activity.ts index 5a3e78c44..102a71d7c 100644 --- a/src/api/endpoints/aggregation/users/activity.ts +++ b/src/api/endpoints/aggregation/users/activity.ts @@ -40,7 +40,7 @@ module.exports = (params) => new Promise(async (res, rej) => { { $match: { user_id: user._id } }, { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -55,7 +55,7 @@ module.exports = (params) => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/aggregation/users/post.ts b/src/api/endpoints/aggregation/users/post.ts index c964815a0..c6a75eee3 100644 --- a/src/api/endpoints/aggregation/users/post.ts +++ b/src/api/endpoints/aggregation/users/post.ts @@ -34,7 +34,7 @@ module.exports = (params) => new Promise(async (res, rej) => { { $match: { user_id: user._id } }, { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -49,7 +49,7 @@ module.exports = (params) => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/channels.ts b/src/api/endpoints/channels.ts new file mode 100644 index 000000000..e10c94389 --- /dev/null +++ b/src/api/endpoints/channels.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../models/channel'; +import serialize from '../serializers/channel'; + +/** + * Get all channels + * + * @param {any} params + * @param {any} me + * @return {Promise} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'max_id' parameter + const [maxId, maxIdErr] = $(params.max_id).optional.id().$; + if (maxIdErr) return rej('invalid max_id param'); + + // Check if both of since_id and max_id is specified + if (sinceId && maxId) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (maxId) { + query._id = { + $lt: maxId + }; + } + + // Issue query + const channels = await Channel + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(channels.map(async channel => + await serialize(channel, me)))); +}); diff --git a/src/api/endpoints/channels/create.ts b/src/api/endpoints/channels/create.ts new file mode 100644 index 000000000..a8d7c29dc --- /dev/null +++ b/src/api/endpoints/channels/create.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; +import serialize from '../../serializers/channel'; + +/** + * Create a channel + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $(params.title).string().range(1, 100).$; + if (titleErr) return rej('invalid title param'); + + // Create a channel + const channel = await Channel.insert({ + created_at: new Date(), + user_id: user._id, + title: title, + index: 0, + watching_count: 1 + }); + + // Response + res(await serialize(channel)); + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); +}); diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts new file mode 100644 index 000000000..fa91fb93e --- /dev/null +++ b/src/api/endpoints/channels/posts.ts @@ -0,0 +1,79 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import { default as Post, IPost } from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a posts of a channel + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'max_id' parameter + const [maxId, maxIdErr] = $(params.max_id).optional.id().$; + if (maxIdErr) return rej('invalid max_id param'); + + // Check if both of since_id and max_id is specified + if (sinceId && maxId) { + return rej('cannot set since_id and max_id'); + } + + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + channel_id: channel._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (maxId) { + query._id = { + $lt: maxId + }; + } + //#endregion Construct query + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await serialize(post, user) + ))); +}); diff --git a/src/api/endpoints/channels/show.ts b/src/api/endpoints/channels/show.ts new file mode 100644 index 000000000..8861e5459 --- /dev/null +++ b/src/api/endpoints/channels/show.ts @@ -0,0 +1,31 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import serialize from '../../serializers/channel'; + +/** + * Show a channel + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // Serialize + res(await serialize(channel, user)); +}); diff --git a/src/api/endpoints/channels/unwatch.ts b/src/api/endpoints/channels/unwatch.ts new file mode 100644 index 000000000..19d3be118 --- /dev/null +++ b/src/api/endpoints/channels/unwatch.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Unwatch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether not watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not watching'); + } + //#endregion + + // Delete watching + await Watching.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement watching count + Channel.update(channel._id, { + $inc: { + watching_count: -1 + } + }); +}); diff --git a/src/api/endpoints/channels/watch.ts b/src/api/endpoints/channels/watch.ts new file mode 100644 index 000000000..030e0dd41 --- /dev/null +++ b/src/api/endpoints/channels/watch.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Watch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether already watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already watching'); + } + //#endregion + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); + + // Send response + res(); + + // Increment watching count + Channel.update(channel._id, { + $inc: { + watching_count: 1 + } + }); +}); diff --git a/src/api/endpoints/drive.ts b/src/api/endpoints/drive.ts index 41ad6301d..d92473633 100644 --- a/src/api/endpoints/drive.ts +++ b/src/api/endpoints/drive.ts @@ -14,16 +14,16 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Calculate drive usage const usage = ((await DriveFile .aggregate([ - { $match: { user_id: user._id } }, + { $match: { 'metadata.user_id': user._id } }, { $project: { - datasize: true + length: true } }, { $group: { _id: null, - usage: { $sum: '$datasize' } + usage: { $sum: '$length' } } } ]))[0] || { diff --git a/src/api/endpoints/drive/files.ts b/src/api/endpoints/drive/files.ts index a68ae3481..53b48a8be 100644 --- a/src/api/endpoints/drive/files.ts +++ b/src/api/endpoints/drive/files.ts @@ -13,35 +13,35 @@ import serialize from '../../serializers/drive-file'; * @param {any} app * @return {Promise} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = async (params, user, app) => { // Get 'limit' parameter const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; - if (limitErr) return rej('invalid limit param'); + if (limitErr) throw 'invalid limit param'; // Get 'since_id' parameter const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; - if (sinceIdErr) return rej('invalid since_id param'); + if (sinceIdErr) throw 'invalid since_id param'; // Get 'max_id' parameter const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) return rej('invalid max_id param'); + if (maxIdErr) throw 'invalid max_id param'; // Check if both of since_id and max_id is specified if (sinceId && maxId) { - return rej('cannot set since_id and max_id'); + throw 'cannot set since_id and max_id'; } // Get 'folder_id' parameter const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; - if (folderIdErr) return rej('invalid folder_id param'); + if (folderIdErr) throw 'invalid folder_id param'; // Construct query const sort = { _id: -1 }; const query = { - user_id: user._id, - folder_id: folderId + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId } as any; if (sinceId) { sort._id = 1; @@ -57,14 +57,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Issue query const files = await DriveFile .find(query, { - fields: { - data: false - }, limit: limit, sort: sort }); // Serialize - res(await Promise.all(files.map(async file => - await serialize(file)))); -}); + const _files = await Promise.all(files.map(file => serialize(file))); + return _files; +}; diff --git a/src/api/endpoints/drive/files/find.ts b/src/api/endpoints/drive/files/find.ts index cd0b33f2c..1c818131d 100644 --- a/src/api/endpoints/drive/files/find.ts +++ b/src/api/endpoints/drive/files/find.ts @@ -24,13 +24,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Issue query const files = await DriveFile .find({ - name: name, - user_id: user._id, - folder_id: folderId - }, { - fields: { - data: false - } + 'metadata.name': name, + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId }); // Serialize diff --git a/src/api/endpoints/drive/files/show.ts b/src/api/endpoints/drive/files/show.ts index 8dbc297e4..3c7cf774f 100644 --- a/src/api/endpoints/drive/files/show.ts +++ b/src/api/endpoints/drive/files/show.ts @@ -12,28 +12,26 @@ import serialize from '../../../serializers/drive-file'; * @param {any} user * @return {Promise} */ -module.exports = (params, user) => new Promise(async (res, rej) => { +module.exports = async (params, user) => { // Get 'file_id' parameter const [fileId, fileIdErr] = $(params.file_id).id().$; - if (fileIdErr) return rej('invalid file_id param'); + if (fileIdErr) throw 'invalid file_id param'; // Fetch file const file = await DriveFile .findOne({ _id: fileId, - user_id: user._id - }, { - fields: { - data: false - } + 'metadata.user_id': user._id }); if (file === null) { - return rej('file-not-found'); + throw 'file-not-found'; } // Serialize - res(await serialize(file, { + const _file = await serialize(file, { detail: true - })); -}); + }); + + return _file; +}; diff --git a/src/api/endpoints/drive/files/update.ts b/src/api/endpoints/drive/files/update.ts index 1cfbdd8f0..d7b858c2b 100644 --- a/src/api/endpoints/drive/files/update.ts +++ b/src/api/endpoints/drive/files/update.ts @@ -24,11 +24,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const file = await DriveFile .findOne({ _id: fileId, - user_id: user._id - }, { - fields: { - data: false - } + 'metadata.user_id': user._id }); if (file === null) { @@ -38,7 +34,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$; if (nameErr) return rej('invalid name param'); - if (name) file.name = name; + if (name) file.metadata.name = name; // Get 'folder_id' parameter const [folderId, folderIdErr] = $(params.folder_id).optional.nullable.id().$; @@ -46,7 +42,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (folderId !== undefined) { if (folderId === null) { - file.folder_id = null; + file.metadata.folder_id = null; } else { // Fetch folder const folder = await DriveFolder @@ -59,14 +55,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => { return rej('folder-not-found'); } - file.folder_id = folder._id; + file.metadata.folder_id = folder._id; } } - DriveFile.update(file._id, { + await DriveFile.update(file._id, { $set: { - name: file.name, - folder_id: file.folder_id + 'metadata.name': file.metadata.name, + 'metadata.folder_id': file.metadata.folder_id } }); diff --git a/src/api/endpoints/drive/folders/find.ts b/src/api/endpoints/drive/folders/find.ts index cdf055839..a5eb8e015 100644 --- a/src/api/endpoints/drive/folders/find.ts +++ b/src/api/endpoints/drive/folders/find.ts @@ -30,6 +30,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(folders.map(async folder => - await serialize(folder)))); + res(await Promise.all(folders.map(folder => serialize(folder)))); }); diff --git a/src/api/endpoints/drive/folders/update.ts b/src/api/endpoints/drive/folders/update.ts index eec275787..4f2e3d2a7 100644 --- a/src/api/endpoints/drive/folders/update.ts +++ b/src/api/endpoints/drive/folders/update.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import DriveFolder from '../../../models/drive-folder'; import { isValidFolderName } from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-file'; +import serialize from '../../../serializers/drive-folder'; import event from '../../../event'; /** diff --git a/src/api/endpoints/i/appdata/set.ts b/src/api/endpoints/i/appdata/set.ts index 24f192de6..9c3dbe185 100644 --- a/src/api/endpoints/i/appdata/set.ts +++ b/src/api/endpoints/i/appdata/set.ts @@ -21,7 +21,7 @@ module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) = const [data, dataError] = $(params.data).optional.object() .pipe(obj => { const hasInvalidData = Object.entries(obj).some(([k, v]) => - $(k).string().match(/^[a-z_]+$/).isNg() && $(v).string().isNg()); + $(k).string().match(/^[a-z_]+$/).nok() && $(v).string().nok()); return !hasInvalidData; }).$; if (dataError) return rej('invalid data param'); diff --git a/src/api/endpoints/i/change_password.ts b/src/api/endpoints/i/change_password.ts new file mode 100644 index 000000000..faceded29 --- /dev/null +++ b/src/api/endpoints/i/change_password.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../models/user'; + +/** + * Change password + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'current_password' parameter + const [currentPassword, currentPasswordErr] = $(params.current_password).string().$; + if (currentPasswordErr) return rej('invalid current_password param'); + + // Get 'new_password' parameter + const [newPassword, newPasswordErr] = $(params.new_password).string().$; + if (newPasswordErr) return rej('invalid new_password param'); + + // Compare password + const same = bcrypt.compareSync(currentPassword, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate hash of password + const salt = bcrypt.genSaltSync(8); + const hash = bcrypt.hashSync(newPassword, salt); + + await User.update(user._id, { + $set: { + password: hash + } + }); + + res(); +}); diff --git a/src/api/endpoints/i/notifications.ts b/src/api/endpoints/i/notifications.ts index 5575fb741..607e0768a 100644 --- a/src/api/endpoints/i/notifications.ts +++ b/src/api/endpoints/i/notifications.ts @@ -5,6 +5,7 @@ import $ from 'cafy'; import Notification from '../../models/notification'; import serialize from '../../serializers/notification'; import getFriends from '../../common/get-friends'; +import read from '../../common/read-notification'; /** * Get notifications @@ -91,17 +92,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Mark as read all if (notifications.length > 0 && markAsRead) { - const ids = notifications - .filter(x => x.is_read == false) - .map(x => x._id); - - // Update documents - await Notification.update({ - _id: { $in: ids } - }, { - $set: { is_read: true } - }, { - multi: true - }); + read(user._id, notifications); } }); diff --git a/src/api/endpoints/i/pin.ts b/src/api/endpoints/i/pin.ts new file mode 100644 index 000000000..a94950d22 --- /dev/null +++ b/src/api/endpoints/i/pin.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Post from '../../models/post'; +import serialize from '../../serializers/user'; + +/** + * Pin post + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Fetch pinee + const post = await Post.findOne({ + _id: postId, + user_id: user._id + }); + + if (post === null) { + return rej('post not found'); + } + + await User.update(user._id, { + $set: { + pinned_post_id: post._id + } + }); + + // Serialize + const iObj = await serialize(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/api/endpoints/i/regenerate_token.ts b/src/api/endpoints/i/regenerate_token.ts new file mode 100644 index 000000000..f96d10ebf --- /dev/null +++ b/src/api/endpoints/i/regenerate_token.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../models/user'; +import event from '../../event'; +import generateUserToken from '../../common/generate-native-user-token'; + +/** + * Regenerate native token + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = bcrypt.compareSync(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await User.update(user._id, { + $set: { + token: secret + } + }); + + res(); + + // Publish event + event(user._id, 'my_token_regenerated'); +}); diff --git a/src/api/endpoints/messaging/messages/create.ts b/src/api/endpoints/messaging/messages/create.ts index 8af55d850..149852c09 100644 --- a/src/api/endpoints/messaging/messages/create.ts +++ b/src/api/endpoints/messaging/messages/create.ts @@ -54,9 +54,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (fileId !== undefined) { file = await DriveFile.findOne({ _id: fileId, - user_id: user._id - }, { - data: false + 'metadata.user_id': user._id }); if (file === null) { diff --git a/src/api/endpoints/notifications/get_unread_count.ts b/src/api/endpoints/notifications/get_unread_count.ts new file mode 100644 index 000000000..9514e7871 --- /dev/null +++ b/src/api/endpoints/notifications/get_unread_count.ts @@ -0,0 +1,23 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; + +/** + * Get count of unread notifications + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const count = await Notification + .count({ + notifiee_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/api/endpoints/notifications/mark_as_read.ts b/src/api/endpoints/notifications/mark_as_read.ts deleted file mode 100644 index 5cce33e85..000000000 --- a/src/api/endpoints/notifications/mark_as_read.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Module dependencies - */ -import $ from 'cafy'; -import Notification from '../../models/notification'; -import serialize from '../../serializers/notification'; -import event from '../../event'; - -/** - * Mark as read a notification - * - * @param {any} params - * @param {any} user - * @return {Promise} - */ -module.exports = (params, user) => new Promise(async (res, rej) => { - const [notificationId, notificationIdErr] = $(params.notification_id).id().$; - if (notificationIdErr) return rej('invalid notification_id param'); - - // Get notification - const notification = await Notification - .findOne({ - _id: notificationId, - i: user._id - }); - - if (notification === null) { - return rej('notification-not-found'); - } - - // Update - notification.is_read = true; - Notification.update({ _id: notification._id }, { - $set: { - is_read: true - } - }); - - // Response - res(); - - // Serialize - const notificationObj = await serialize(notification); - - // Publish read_notification event - event(user._id, 'read_notification', notificationObj); -}); diff --git a/src/api/endpoints/notifications/mark_as_read_all.ts b/src/api/endpoints/notifications/mark_as_read_all.ts new file mode 100644 index 000000000..3550e344c --- /dev/null +++ b/src/api/endpoints/notifications/mark_as_read_all.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; +import event from '../../event'; + +/** + * Mark as read all notifications + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Update documents + await Notification.update({ + notifiee_id: user._id, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Response + res(); + + // 全ての通知を読みましたよというイベントを発行 + event(user._id, 'read_all_notifications'); +}); diff --git a/src/api/endpoints/posts.ts b/src/api/endpoints/posts.ts index 23b9bd0b6..f6efcc108 100644 --- a/src/api/endpoints/posts.ts +++ b/src/api/endpoints/posts.ts @@ -62,7 +62,7 @@ module.exports = (params) => new Promise(async (res, rej) => { } if (reply != undefined) { - query.reply_to_id = reply ? { $exists: true, $ne: null } : null; + query.reply_id = reply ? { $exists: true, $ne: null } : null; } if (repost != undefined) { diff --git a/src/api/endpoints/posts/categorize.ts b/src/api/endpoints/posts/categorize.ts new file mode 100644 index 000000000..3530ba6bc --- /dev/null +++ b/src/api/endpoints/posts/categorize.ts @@ -0,0 +1,52 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; + +/** + * Categorize a post + * + * @param {any} params + * @param {any} user + * @return {Promise} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + if (!user.is_pro) { + return rej('This endpoint is available only from a Pro account'); + } + + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get categorizee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + if (post.is_category_verified) { + return rej('This post already has the verified category'); + } + + // Get 'category' parameter + const [category, categoryErr] = $(params.category).string().or([ + 'music', 'game', 'anime', 'it', 'gadgets', 'photography' + ]).$; + if (categoryErr) return rej('invalid category param'); + + // Set category + Post.update({ _id: post._id }, { + $set: { + category: category, + is_category_verified: true + } + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/context.ts b/src/api/endpoints/posts/context.ts index cd5f15f48..bad59a6be 100644 --- a/src/api/endpoints/posts/context.ts +++ b/src/api/endpoints/posts/context.ts @@ -49,13 +49,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { return; } - if (p.reply_to_id) { - await get(p.reply_to_id); + if (p.reply_id) { + await get(p.reply_id); } } - if (post.reply_to_id) { - await get(post.reply_to_id); + if (post.reply_id) { + await get(post.reply_id); } // Serialize diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts index eb979402c..4f4b7e2e8 100644 --- a/src/api/endpoints/posts/create.ts +++ b/src/api/endpoints/posts/create.ts @@ -4,16 +4,17 @@ import $ from 'cafy'; import deepEqual = require('deep-equal'); import parse from '../../common/text'; -import Post from '../../models/post'; -import { isValidText } from '../../models/post'; -import User from '../../models/user'; +import { default as Post, IPost, isValidText } from '../../models/post'; +import { default as User, IUser } from '../../models/user'; +import { default as Channel, IChannel } from '../../models/channel'; import Following from '../../models/following'; import DriveFile from '../../models/drive-file'; import Watching from '../../models/post-watching'; +import ChannelWatching from '../../models/channel-watching'; import serialize from '../../serializers/post'; import notify from '../../common/notify'; import watch from '../../common/watch-post'; -import event from '../../event'; +import { default as event, publishChannelStream } from '../../event'; import config from '../../../conf'; /** @@ -24,7 +25,7 @@ import config from '../../../conf'; * @param {any} app * @return {Promise} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // Get 'text' parameter const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; if (textErr) return rej('invalid text'); @@ -43,9 +44,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // SELECT _id const entity = await DriveFile.findOne({ _id: mediaId, - user_id: user._id - }, { - _id: true + 'metadata.user_id': user._id }); if (entity === null) { @@ -62,7 +61,8 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { const [repostId, repostIdErr] = $(params.repost_id).optional.id().$; if (repostIdErr) return rej('invalid repost_id'); - let repost = null; + let repost: IPost = null; + let isQuote = false; if (repostId !== undefined) { // Fetch repost to post repost = await Post.findOne({ @@ -84,43 +84,86 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } }); + isQuote = text != null || files != null; + // 直近と同じRepost対象かつ引用じゃなかったらエラー if (latestPost && latestPost.repost_id && latestPost.repost_id.equals(repost._id) && - text === undefined && files === null) { + !isQuote) { return rej('cannot repost same post that already reposted in your latest post'); } // 直近がRepost対象かつ引用じゃなかったらエラー if (latestPost && latestPost._id.equals(repost._id) && - text === undefined && files === null) { + !isQuote) { return rej('cannot repost your latest post'); } } - // Get 'in_reply_to_post_id' parameter - const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$; - if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id'); + // Get 'reply_id' parameter + const [replyId, replyIdErr] = $(params.reply_id).optional.id().$; + if (replyIdErr) return rej('invalid reply_id'); - let inReplyToPost = null; - if (inReplyToPostId !== undefined) { + let reply: IPost = null; + if (replyId !== undefined) { // Fetch reply - inReplyToPost = await Post.findOne({ - _id: inReplyToPostId + reply = await Post.findOne({ + _id: replyId }); - if (inReplyToPost === null) { + if (reply === null) { return rej('in reply to post is not found'); } // 返信対象が引用でないRepostだったらエラー - if (inReplyToPost.repost_id && !inReplyToPost.text && !inReplyToPost.media_ids) { + if (reply.repost_id && !reply.text && !reply.media_ids) { return rej('cannot reply to repost'); } } + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).optional.id().$; + if (channelIdErr) return rej('invalid channel_id'); + + let channel: IChannel = null; + if (channelId !== undefined) { + // Fetch channel + channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // 返信対象の投稿がこのチャンネルじゃなかったらダメ + if (reply && !channelId.equals(reply.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); + } + + // Repost対象の投稿がこのチャンネルじゃなかったらダメ + if (repost && !channelId.equals(repost.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません'); + } + + // 引用ではないRepostはダメ + if (repost && !isQuote) { + return rej('チャンネル内部では引用ではないRepostをすることはできません'); + } + } else { + // 返信対象の投稿がチャンネルへの投稿だったらダメ + if (reply && reply.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); + } + + // Repost対象の投稿がチャンネルへの投稿だったらダメ + if (repost && repost.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません'); + } + } + // Get 'poll' parameter const [poll, pollErr] = $(params.poll).optional.strict.object() .have('choices', $().array('string') @@ -148,15 +191,15 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { if (user.latest_post) { if (deepEqual({ text: user.latest_post.text, - reply: user.latest_post.reply_to_id ? user.latest_post.reply_to_id.toString() : null, + reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null, repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null, media_ids: (user.latest_post.media_ids || []).map(id => id.toString()) }, { - text: text, - reply: inReplyToPost ? inReplyToPost._id.toString() : null, - repost: repost ? repost._id.toString() : null, - media_ids: (files || []).map(file => file._id.toString()) - })) { + text: text, + reply: reply ? reply._id.toString() : null, + repost: repost ? repost._id.toString() : null, + media_ids: (files || []).map(file => file._id.toString()) + })) { return rej('duplicate'); } } @@ -164,8 +207,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // 投稿を作成 const post = await Post.insert({ created_at: new Date(), + channel_id: channel ? channel._id : undefined, + index: channel ? channel.index + 1 : undefined, media_ids: files ? files.map(file => file._id) : undefined, - reply_to_id: inReplyToPost ? inReplyToPost._id : undefined, + reply_id: reply ? reply._id : undefined, repost_id: repost ? repost._id : undefined, poll: poll, text: text, @@ -179,8 +224,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Reponse res(postObj); - // ----------------------------------------------------------- - // Post processes + //#region Post processes User.update({ _id: user._id }, { $set: { @@ -203,23 +247,51 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } } - // Publish event to myself's stream - event(user._id, 'post', postObj); + // タイムラインへの投稿 + if (!channel) { + // Publish event to myself's stream + event(user._id, 'post', postObj); - // Fetch all followers - const followers = await Following - .find({ - followee_id: user._id, - // 削除されたドキュメントは除く - deleted_at: { $exists: false } - }, { - follower_id: true, - _id: false + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + } + + // チャンネルへの投稿 + if (channel) { + // Increment channel index(posts count) + Channel.update({ _id: channel._id }, { + $inc: { + index: 1 + } }); - // Publish event to followers stream - followers.forEach(following => - event(following.follower_id, 'post', postObj)); + // Publish event to channel + publishChannelStream(channel._id, 'post', postObj); + + // Get channel watchers + const watches = await ChannelWatching.find({ + channel_id: channel._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }); + + // チャンネルの視聴者(のタイムライン)に配信 + watches.forEach(w => { + event(w.user_id, 'post', postObj); + }); + } // Increment my posts count User.update({ _id: user._id }, { @@ -229,23 +301,23 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }); // If has in reply to post - if (inReplyToPost) { + if (reply) { // Increment replies count - Post.update({ _id: inReplyToPost._id }, { + Post.update({ _id: reply._id }, { $inc: { replies_count: 1 } }); // 自分自身へのリプライでない限りは通知を作成 - notify(inReplyToPost.user_id, user._id, 'reply', { + notify(reply.user_id, user._id, 'reply', { post_id: post._id }); // Fetch watchers Watching .find({ - post_id: inReplyToPost._id, + post_id: reply._id, user_id: { $ne: user._id }, // 削除されたドキュメントは除く deleted_at: { $exists: false } @@ -265,10 +337,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // この投稿をWatchする // TODO: ユーザーが「返信したときに自動でWatchする」設定を // オフにしていた場合はしない - watch(user._id, inReplyToPost); + watch(user._id, reply); // Add mention - addMention(inReplyToPost.user_id, 'reply'); + addMention(reply.user_id, 'reply'); } // If it is repost @@ -369,7 +441,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { if (mentionee == null) return; // 既に言及されたユーザーに対する返信や引用repostの場合も無視 - if (inReplyToPost && inReplyToPost.user_id.equals(mentionee._id)) return; + if (reply && reply.user_id.equals(mentionee._id)) return; if (repost && repost.user_id.equals(mentionee._id)) return; // Add mention @@ -406,4 +478,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } }); } + + //#endregion }); diff --git a/src/api/endpoints/posts/replies.ts b/src/api/endpoints/posts/replies.ts index 89f4d9984..3fd6a4676 100644 --- a/src/api/endpoints/posts/replies.ts +++ b/src/api/endpoints/posts/replies.ts @@ -40,7 +40,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Issue query const replies = await Post - .find({ reply_to_id: post._id }, { + .find({ reply_id: post._id }, { limit: limit, skip: offset, sort: { diff --git a/src/api/endpoints/posts/timeline.ts b/src/api/endpoints/posts/timeline.ts index 314e99234..203413e23 100644 --- a/src/api/endpoints/posts/timeline.ts +++ b/src/api/endpoints/posts/timeline.ts @@ -2,7 +2,9 @@ * Module dependencies */ import $ from 'cafy'; +import rap from '@prezzemolo/rap'; import Post from '../../models/post'; +import ChannelWatching from '../../models/channel-watching'; import getFriends from '../../common/get-friends'; import serialize from '../../serializers/post'; @@ -14,36 +16,62 @@ import serialize from '../../serializers/post'; * @param {any} app * @return {Promise} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = async (params, user, app) => { // Get 'limit' parameter const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; - if (limitErr) return rej('invalid limit param'); + if (limitErr) throw 'invalid limit param'; // Get 'since_id' parameter const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; - if (sinceIdErr) return rej('invalid since_id param'); + if (sinceIdErr) throw 'invalid since_id param'; // Get 'max_id' parameter const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) return rej('invalid max_id param'); + if (maxIdErr) throw 'invalid max_id param'; // Check if both of since_id and max_id is specified if (sinceId && maxId) { - return rej('cannot set since_id and max_id'); + throw 'cannot set since_id and max_id'; } - // ID list of the user $self and other users who the user follows - const followingIds = await getFriends(user._id); + const { followingIds, watchChannelIds } = await rap({ + // ID list of the user itself and other users who the user follows + followingIds: getFriends(user._id), + // Watchしているチャンネルを取得 + watchChannelIds: ChannelWatching.find({ + user_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(watches => watches.map(w => w.channel_id)) + }); - // Construct query + //#region Construct query const sort = { _id: -1 }; + const query = { - user_id: { - $in: followingIds - } + $or: [{ + // フォローしている人のタイムラインへの投稿 + user_id: { + $in: followingIds + }, + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channel_id: { + $exists: false + } + }, { + channel_id: null + }] + }, { + // Watchしているチャンネルへの投稿 + channel_id: { + $in: watchChannelIds + } + }] } as any; + if (sinceId) { sort._id = 1; query._id = { @@ -54,6 +82,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { $lt: maxId }; } + //#endregion // Issue query const timeline = await Post @@ -63,7 +92,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(timeline.map(async post => - await serialize(post, user) - ))); -}); + const _timeline = await Promise.all(timeline.map(post => serialize(post, user))); + return _timeline; +}; diff --git a/src/api/endpoints/posts/trend.ts b/src/api/endpoints/posts/trend.ts index 3277206d2..64a195dff 100644 --- a/src/api/endpoints/posts/trend.ts +++ b/src/api/endpoints/posts/trend.ts @@ -48,7 +48,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } as any; if (reply != undefined) { - query.reply_to_id = reply ? { $exists: true, $ne: null } : null; + query.reply_id = reply ? { $exists: true, $ne: null } : null; } if (repost != undefined) { diff --git a/src/api/endpoints/users/get_frequently_replied_users.ts b/src/api/endpoints/users/get_frequently_replied_users.ts new file mode 100644 index 000000000..bb0f3b4ce --- /dev/null +++ b/src/api/endpoints/users/get_frequently_replied_users.ts @@ -0,0 +1,96 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: user._id, + reply_id: { + $exists: true, + $ne: null + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + reply_id: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length === 0) { + return res([]); + } + + const replyTargetPosts = await Post.find({ + _id: { + $in: recentPosts.map(p => p.reply_id) + }, + user_id: { + $ne: user._id + } + }, { + fields: { + _id: false, + user_id: true + } + }); + + const repliedUsers = {}; + + // Extract replies from recent posts + replyTargetPosts.forEach(post => { + const userId = post.user_id.toString(); + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + }); + + // Calc peak + let peak = 0; + Object.keys(repliedUsers).forEach(user => { + if (repliedUsers[user] > peak) peak = repliedUsers[user]; + }); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Lookup top 10 replies + const topRepliedUsers = repliedUsersSorted.slice(0, 10); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await serialize(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + // Response + res(repliesObj); +}); diff --git a/src/api/endpoints/users/posts.ts b/src/api/endpoints/users/posts.ts index e37b66077..d8204b8b8 100644 --- a/src/api/endpoints/users/posts.ts +++ b/src/api/endpoints/users/posts.ts @@ -85,7 +85,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { } if (!includeReplies) { - query.reply_to_id = null; + query.reply_id = null; } if (withMedia) { diff --git a/src/api/event.ts b/src/api/event.ts index 9613a9f7c..909b0d255 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -25,6 +25,10 @@ class MisskeyEvent { this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } + public publishChannelStream(channelId: ID, type: string, value?: any): void { + this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + private publish(channel: string, type: string, value?: any): void { const message = value == null ? { type: type } : @@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev); export const publishPostStream = ev.publishPostStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev); + +export const publishChannelStream = ev.publishChannelStream.bind(ev); diff --git a/src/api/models/access-token.ts b/src/api/models/access-token.ts index 2a8a512dd..9985be501 100644 --- a/src/api/models/access-token.ts +++ b/src/api/models/access-token.ts @@ -2,7 +2,7 @@ import db from '../../db/mongodb'; const collection = db.get('access_tokens'); -(collection as any).index('token'); // fuck type definition -(collection as any).index('hash'); // fuck type definition +(collection as any).createIndex('token'); // fuck type definition +(collection as any).createIndex('hash'); // fuck type definition export default collection as any; // fuck type definition diff --git a/src/api/models/app.ts b/src/api/models/app.ts index bf5dc80c2..68f2f448b 100644 --- a/src/api/models/app.ts +++ b/src/api/models/app.ts @@ -2,9 +2,9 @@ import db from '../../db/mongodb'; const collection = db.get('apps'); -(collection as any).index('name_id'); // fuck type definition -(collection as any).index('name_id_lower'); // fuck type definition -(collection as any).index('secret'); // fuck type definition +(collection as any).createIndex('name_id'); // fuck type definition +(collection as any).createIndex('name_id_lower'); // fuck type definition +(collection as any).createIndex('secret'); // fuck type definition export default collection as any; // fuck type definition diff --git a/src/api/models/channel-watching.ts b/src/api/models/channel-watching.ts new file mode 100644 index 000000000..6184ae408 --- /dev/null +++ b/src/api/models/channel-watching.ts @@ -0,0 +1,3 @@ +import db from '../../db/mongodb'; + +export default db.get('channel_watching') as any; // fuck type definition diff --git a/src/api/models/channel.ts b/src/api/models/channel.ts new file mode 100644 index 000000000..c80e84dbc --- /dev/null +++ b/src/api/models/channel.ts @@ -0,0 +1,14 @@ +import * as mongo from 'mongodb'; +import db from '../../db/mongodb'; + +const collection = db.get('channels'); + +export default collection as any; // fuck type definition + +export type IChannel = { + _id: mongo.ObjectID; + created_at: Date; + title: string; + user_id: mongo.ObjectID; + index: number; +}; diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts index 4c7204b1f..8968d065c 100644 --- a/src/api/models/drive-file.ts +++ b/src/api/models/drive-file.ts @@ -1,11 +1,22 @@ -import db from '../../db/mongodb'; +import * as mongodb from 'mongodb'; +import monkDb, { nativeDbConn } from '../../db/mongodb'; -const collection = db.get('drive_files'); +const collection = monkDb.get('drive_files.files'); -(collection as any).index('hash'); // fuck type definition +(collection as any).createIndex('hash'); // fuck type definition export default collection as any; // fuck type definition +const getGridFSBucket = async (): Promise => { + const db = await nativeDbConn(); + const bucket = new mongodb.GridFSBucket(db, { + bucketName: 'drive_files' + }); + return bucket; +}; + +export { getGridFSBucket }; + export function validateFileName(name: string): boolean { return ( (name.trim().length > 0) && diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts index 1c1f429a0..1065e8baa 100644 --- a/src/api/models/notification.ts +++ b/src/api/models/notification.ts @@ -1,3 +1,8 @@ +import * as mongo from 'mongodb'; import db from '../../db/mongodb'; export default db.get('notifications') as any; // fuck type definition + +export interface INotification { + _id: mongo.ObjectID; +} diff --git a/src/api/models/post.ts b/src/api/models/post.ts index baab63f99..7584ce182 100644 --- a/src/api/models/post.ts +++ b/src/api/models/post.ts @@ -1,3 +1,5 @@ +import * as mongo from 'mongodb'; + import db from '../../db/mongodb'; export default db.get('posts') as any; // fuck type definition @@ -5,3 +7,16 @@ export default db.get('posts') as any; // fuck type definition export function isValidText(text: string): boolean { return text.length <= 1000 && text.trim() != ''; } + +export type IPost = { + _id: mongo.ObjectID; + channel_id: mongo.ObjectID; + created_at: Date; + media_ids: mongo.ObjectID[]; + reply_id: mongo.ObjectID; + repost_id: mongo.ObjectID; + poll: {}; // todo + text: string; + user_id: mongo.ObjectID; + app_id: mongo.ObjectID; +}; diff --git a/src/api/models/user.ts b/src/api/models/user.ts index cd1645989..b2f3af09f 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -1,9 +1,12 @@ +import * as mongo from 'mongodb'; + import db from '../../db/mongodb'; +import { IPost } from './post'; const collection = db.get('users'); -(collection as any).index('username'); // fuck type definition -(collection as any).index('token'); // fuck type definition +(collection as any).createIndex('username'); // fuck type definition +(collection as any).createIndex('token'); // fuck type definition export default collection as any; // fuck type definition @@ -31,6 +34,50 @@ export function isValidBirthday(birthday: string): boolean { return typeof birthday == 'string' && /^([0-9]{4})\-([0-9]{2})-([0-9]{2})$/.test(birthday); } -export interface IUser { +export type IUser = { + _id: mongo.ObjectID; + created_at: Date; + email: string; + followers_count: number; + following_count: number; + links: string[]; name: string; + password: string; + posts_count: number; + drive_capacity: number; + username: string; + username_lower: string; + token: string; + avatar_id: mongo.ObjectID; + banner_id: mongo.ObjectID; + data: any; + twitter: { + access_token: string; + access_token_secret: string; + user_id: string; + screen_name: string; + }; + line: { + user_id: string; + }; + description: string; + profile: { + location: string; + birthday: string; // 'YYYY-MM-DD' + tags: string[]; + }; + last_used_at: Date; + latest_post: IPost; + pinned_post_id: mongo.ObjectID; + is_pro: boolean; + is_suspended: boolean; + keywords: string[]; +}; + +export function init(user): IUser { + user._id = new mongo.ObjectID(user._id); + user.avatar_id = new mongo.ObjectID(user.avatar_id); + user.banner_id = new mongo.ObjectID(user.banner_id); + user.pinned_post_id = new mongo.ObjectID(user.pinned_post_id); + return user; } diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts index afa83e50c..c7dc24398 100644 --- a/src/api/private/signin.ts +++ b/src/api/private/signin.ts @@ -1,6 +1,6 @@ import * as express from 'express'; import * as bcrypt from 'bcryptjs'; -import User from '../models/user'; +import { default as User, IUser } from '../models/user'; import Signin from '../models/signin'; import serialize from '../serializers/signin'; import event from '../event'; @@ -23,7 +23,7 @@ export default async (req: express.Request, res: express.Response) => { } // Fetch user - const user = await User.findOne({ + const user: IUser = await User.findOne({ username_lower: username.toLowerCase() }, { fields: { diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts index 2375c2284..bcc17a876 100644 --- a/src/api/private/signup.ts +++ b/src/api/private/signup.ts @@ -1,10 +1,10 @@ import * as express from 'express'; import * as bcrypt from 'bcryptjs'; -import rndstr from 'rndstr'; import recaptcha = require('recaptcha-promise'); -import User from '../models/user'; +import { default as User, IUser } from '../models/user'; import { validateUsername, validatePassword } from '../models/user'; import serialize from '../serializers/user'; +import generateUserToken from '../common/generate-native-user-token'; import config from '../../conf'; recaptcha.init({ @@ -58,10 +58,10 @@ export default async (req: express.Request, res: express.Response) => { const hash = bcrypt.hashSync(password, salt); // Generate secret - const secret = `!${rndstr('a-zA-Z0-9', 32)}`; + const secret = generateUserToken(); // Create account - const account = await User.insert({ + const account: IUser = await User.insert({ token: secret, avatar_id: null, banner_id: null, diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts new file mode 100644 index 000000000..3cba39aa1 --- /dev/null +++ b/src/api/serializers/channel.ts @@ -0,0 +1,66 @@ +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { IUser } from '../models/user'; +import { default as Channel, IChannel } from '../models/channel'; +import Watching from '../models/channel-watching'; + +/** + * Serialize a channel + * + * @param channel target + * @param me? serializee + * @return response + */ +export default ( + channel: string | mongo.ObjectID | IChannel, + me?: string | mongo.ObjectID | IUser +) => new Promise(async (resolve, reject) => { + + let _channel: any; + + // Populate the channel if 'channel' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(channel)) { + _channel = await Channel.findOne({ + _id: channel + }); + } else if (typeof channel === 'string') { + _channel = await Channel.findOne({ + _id: new mongo.ObjectID(channel) + }); + } else { + _channel = deepcopy(channel); + } + + // Rename _id to id + _channel.id = _channel._id; + delete _channel._id; + + // Remove needless properties + delete _channel.user_id; + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + if (me) { + //#region Watchしているかどうか + const watch = await Watching.findOne({ + user_id: meId, + channel_id: _channel.id, + deleted_at: { $exists: false } + }); + + _channel.is_watching = watch !== null; + //#endregion + } + + resolve(_channel); +}); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts index b4e2ab064..2af7db572 100644 --- a/src/api/serializers/drive-file.ts +++ b/src/api/serializers/drive-file.ts @@ -31,44 +31,40 @@ export default ( if (mongo.ObjectID.prototype.isPrototypeOf(file)) { _file = await DriveFile.findOne({ _id: file - }, { - fields: { - data: false - } - }); + }); } else if (typeof file === 'string') { _file = await DriveFile.findOne({ _id: new mongo.ObjectID(file) - }, { - fields: { - data: false - } - }); + }); } else { _file = deepcopy(file); } - // Rename _id to id - _file.id = _file._id; - delete _file._id; + if (!_file) return reject('invalid file arg.'); - delete _file.data; + // rendered target + let _target: any = {}; - _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + _target.id = _file._id; + _target.created_at = _file.uploadDate; - if (opts.detail && _file.folder_id) { + _target = Object.assign(_target, _file.metadata); + + _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; + + if (opts.detail && _target.folder_id) { // Populate folder - _file.folder = await serializeDriveFolder(_file.folder_id, { + _target.folder = await serializeDriveFolder(_target.folder_id, { detail: true }); } - if (opts.detail && _file.tags) { + if (opts.detail && _target.tags) { // Populate tags - _file.tags = await _file.tags.map(async (tag: any) => + _target.tags = await _target.tags.map(async (tag: any) => await serializeDriveTag(tag) ); } - resolve(_file); + resolve(_target); }); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts index a42846410..6ebf454a2 100644 --- a/src/api/serializers/drive-folder.ts +++ b/src/api/serializers/drive-folder.ts @@ -44,7 +44,7 @@ const self = ( }); const childFilesCount = await DriveFile.count({ - folder_id: _folder.id + 'metadata.folder_id': _folder.id }); _folder.folders_count = childFoldersCount; diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts index 3c96884dd..03fd12077 100644 --- a/src/api/serializers/post.ts +++ b/src/api/serializers/post.ts @@ -3,33 +3,45 @@ */ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); -import Post from '../models/post'; +import { default as Post, IPost } from '../models/post'; import Reaction from '../models/post-reaction'; +import { IUser } from '../models/user'; import Vote from '../models/poll-vote'; import serializeApp from './app'; +import serializeChannel from './channel'; import serializeUser from './user'; import serializeDriveFile from './drive-file'; import parse from '../common/text'; +import rap from '@prezzemolo/rap'; /** * Serialize a post * - * @param {any} post - * @param {any} me? - * @param {any} options? - * @return {Promise} + * @param post target + * @param me? serializee + * @param options? serialize options + * @return response */ -const self = ( - post: any, - me?: any, +const self = async ( + post: string | mongo.ObjectID | IPost, + me?: string | mongo.ObjectID | IUser, options?: { detail: boolean } -) => new Promise(async (resolve, reject) => { +) => { const opts = options || { detail: true, }; + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + let _post: any; // Populate the post if 'post' is ID @@ -45,6 +57,8 @@ const self = ( _post = deepcopy(post); } + if (!_post) throw 'invalid post arg.'; + const id = _post._id; // Rename _id to id @@ -59,62 +73,120 @@ const self = ( } // Populate user - _post.user = await serializeUser(_post.user_id, me); + _post.user = serializeUser(_post.user_id, meId); // Populate app if (_post.app_id) { - _post.app = await serializeApp(_post.app_id); + _post.app = serializeApp(_post.app_id); } + // Populate channel + if (_post.channel_id) { + _post.channel = serializeChannel(_post.channel_id); + } + + // Populate media if (_post.media_ids) { - // Populate media - _post.media = await Promise.all(_post.media_ids.map(async fileId => - await serializeDriveFile(fileId) + _post.media = Promise.all(_post.media_ids.map(fileId => + serializeDriveFile(fileId) )); } - if (_post.reply_to_id && opts.detail) { - // Populate reply to post - _post.reply_to = await self(_post.reply_to_id, me, { - detail: false - }); - } - - if (_post.repost_id && opts.detail) { - // Populate repost - _post.repost = await self(_post.repost_id, me, { - detail: _post.text == null - }); - } - - // Poll - if (me && _post.poll && opts.detail) { - const vote = await Vote - .findOne({ - user_id: me._id, - post_id: id + // When requested a detailed post data + if (opts.detail) { + // Get previous post info + _post.prev = (async () => { + const prev = await Post.findOne({ + user_id: _post.user_id, + _id: { + $lt: id + } + }, { + fields: { + _id: true + }, + sort: { + _id: -1 + } }); + return prev ? prev._id : null; + })(); - if (vote != null) { - _post.poll.choices.filter(c => c.id == vote.choice)[0].is_voted = true; + // Get next post info + _post.next = (async () => { + const next = await Post.findOne({ + user_id: _post.user_id, + _id: { + $gt: id + } + }, { + fields: { + _id: true + }, + sort: { + _id: 1 + } + }); + return next ? next._id : null; + })(); + + if (_post.reply_id) { + // Populate reply to post + _post.reply = self(_post.reply_id, meId, { + detail: false + }); + } + + if (_post.repost_id) { + // Populate repost + _post.repost = self(_post.repost_id, meId, { + detail: _post.text == null + }); + } + + // Poll + if (meId && _post.poll) { + _post.poll = (async (poll) => { + const vote = await Vote + .findOne({ + user_id: meId, + post_id: id + }); + + if (vote != null) { + const myChoice = poll.choices + .filter(c => c.id == vote.choice)[0]; + + myChoice.is_voted = true; + } + + return poll; + })(_post.poll); + } + + // Fetch my reaction + if (meId) { + _post.my_reaction = (async () => { + const reaction = await Reaction + .findOne({ + user_id: meId, + post_id: id, + deleted_at: { $exists: false } + }); + + if (reaction) { + return reaction.reaction; + } + + return null; + })(); } } - // Fetch my reaction - if (me && opts.detail) { - const reaction = await Reaction - .findOne({ - user_id: me._id, - post_id: id, - deleted_at: { $exists: false } - }); + // resolve promises in _post object + _post = await rap(_post); - if (reaction) { - _post.my_reaction = reaction.reaction; - } - } - - resolve(_post); -}); + return _post; +}; export default self; diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts index bdbc74958..0d24d6cc0 100644 --- a/src/api/serializers/user.ts +++ b/src/api/serializers/user.ts @@ -3,22 +3,24 @@ */ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); -import User from '../models/user'; +import { default as User, IUser } from '../models/user'; +import serializePost from './post'; import Following from '../models/following'; import getFriends from '../common/get-friends'; import config from '../../conf'; +import rap from '@prezzemolo/rap'; /** * Serialize a user * - * @param {any} user - * @param {any} me? - * @param {any} options? - * @return {Promise} + * @param user target + * @param me? serializee + * @param options? serialize options + * @return response */ export default ( - user: any, - me?: any, + user: string | mongo.ObjectID | IUser, + me?: string | mongo.ObjectID | IUser, options?: { detail?: boolean, includeSecrets?: boolean @@ -36,7 +38,9 @@ export default ( data: false } : { data: false, - profile: false + profile: false, + keywords: false, + domains: false }; // Populate the user if 'user' is ID @@ -52,14 +56,16 @@ export default ( _user = deepcopy(user); } + if (!_user) return reject('invalid user arg.'); + // Me - if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { - if (typeof me === 'string') { - me = new mongo.ObjectID(me); - } else { - me = me._id; - } - } + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; // Rename _id to id _user.id = _user._id; @@ -76,6 +82,7 @@ export default ( delete _user.twitter.access_token; delete _user.twitter.access_token_secret; } + delete _user.line; // Visible via only the official client if (!opts.includeSecrets) { @@ -91,51 +98,65 @@ export default ( ? `${config.drive_url}/${_user.banner_id}` : null; - if (!me || !me.equals(_user.id) || !opts.detail) { + if (!meId || !meId.equals(_user.id) || !opts.detail) { delete _user.avatar_id; delete _user.banner_id; delete _user.drive_capacity; } - if (me && !me.equals(_user.id)) { + if (meId && !meId.equals(_user.id)) { // If the user is following - const follow = await Following.findOne({ - follower_id: me, - followee_id: _user.id, - deleted_at: { $exists: false } - }); - _user.is_following = follow !== null; + _user.is_following = (async () => { + const follow = await Following.findOne({ + follower_id: meId, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + return follow !== null; + })(); // If the user is followed - const follow2 = await Following.findOne({ - follower_id: _user.id, - followee_id: me, - deleted_at: { $exists: false } - }); - _user.is_followed = follow2 !== null; + _user.is_followed = (async () => { + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: meId, + deleted_at: { $exists: false } + }); + return follow2 !== null; + })(); } - if (me && !me.equals(_user.id) && opts.detail) { - const myFollowingIds = await getFriends(me); + if (opts.detail) { + if (_user.pinned_post_id) { + // Populate pinned post + _user.pinned_post = serializePost(_user.pinned_post_id, meId, { + detail: true + }); + } - // Get following you know count - const followingYouKnowCount = await Following.count({ - followee_id: { $in: myFollowingIds }, - follower_id: _user.id, - deleted_at: { $exists: false } - }); - _user.following_you_know_count = followingYouKnowCount; + if (meId && !meId.equals(_user.id)) { + const myFollowingIds = await getFriends(meId); - // Get followers you know count - const followersYouKnowCount = await Following.count({ - followee_id: _user.id, - follower_id: { $in: myFollowingIds }, - deleted_at: { $exists: false } - }); - _user.followers_you_know_count = followersYouKnowCount; + // Get following you know count + _user.following_you_know_count = Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + + // Get followers you know count + _user.followers_you_know_count = Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + } } + // resolve promises in _user object + _user = await rap(_user); + resolve(_user); }); /* diff --git a/src/api/server.ts b/src/api/server.ts index c98167eb3..3de32d9ea 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -19,7 +19,12 @@ app.disable('x-powered-by'); app.set('etag', false); app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json({ - type: ['application/json', 'text/plain'] + type: ['application/json', 'text/plain'], + verify: (req, res, buf, encoding) => { + if (buf && buf.length) { + (req as any).rawBody = buf.toString(encoding || 'utf8'); + } + } })); app.use(cors({ origin: true @@ -54,4 +59,6 @@ app.use((req, res, next) => { require('./service/github')(app); require('./service/twitter')(app); +require('./bot/interfaces/line')(app); + module.exports = app; diff --git a/src/api/service/github.ts b/src/api/service/github.ts index a631808ba..1c78267c0 100644 --- a/src/api/service/github.ts +++ b/src/api/service/github.ts @@ -111,12 +111,12 @@ module.exports = async (app: express.Application) => { handler.on('watch', event => { const sender = event.sender; - post(`Starred by **${sender.login}**`); + post(`⭐️ Starred by **${sender.login}** ⭐️`); }); handler.on('fork', event => { const repo = event.forkee; - post(`Forked:\n${repo.html_url}`); + post(`🍴 Forked:\n${repo.html_url} 🍴`); }); handler.on('pull_request', event => { diff --git a/src/api/stream/channel.ts b/src/api/stream/channel.ts new file mode 100644 index 000000000..d67d77cbf --- /dev/null +++ b/src/api/stream/channel.ts @@ -0,0 +1,12 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void { + const channel = request.resourceURL.query.channel; + + // Subscribe channel stream + subscriber.subscribe(`misskey:channel-stream:${channel}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts index 2ab8d3025..7c8f3bfec 100644 --- a/src/api/stream/home.ts +++ b/src/api/stream/home.ts @@ -2,7 +2,9 @@ import * as websocket from 'websocket'; import * as redis from 'redis'; import * as debug from 'debug'; +import User from '../models/user'; import serializePost from '../serializers/post'; +import readNotification from '../common/read-notification'; const log = debug('misskey'); @@ -35,6 +37,20 @@ export default function homeStream(request: websocket.request, connection: webso const msg = JSON.parse(data.utf8Data); switch (msg.type) { + case 'alive': + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + last_used_at: new Date() + } + }); + break; + + case 'read_notification': + if (!msg.id) return; + readNotification(user._id, msg.id); + break; + case 'capture': if (!msg.id) return; const postId = msg.id; diff --git a/src/api/stream/server.ts b/src/api/stream/server.ts index 6de533749..0db6643d4 100644 --- a/src/api/stream/server.ts +++ b/src/api/stream/server.ts @@ -14,7 +14,6 @@ export default function homeStream(request: websocket.request, connection: webso ev.addListener('stats', onStats); connection.on('close', () => { - console.log('yooo'); ev.removeListener('stats', onStats); }); } diff --git a/src/api/streaming.ts b/src/api/streaming.ts index c71132100..0e512fb21 100644 --- a/src/api/streaming.ts +++ b/src/api/streaming.ts @@ -2,13 +2,14 @@ import * as http from 'http'; import * as websocket from 'websocket'; import * as redis from 'redis'; import config from '../conf'; -import User from './models/user'; +import { default as User, IUser } from './models/user'; import AccessToken from './models/access-token'; import isNativeToken from './common/is-native-token'; import homeStream from './stream/home'; import messagingStream from './stream/messaging'; import serverStream from './stream/server'; +import channelStream from './stream/channel'; module.exports = (server: http.Server) => { /** @@ -26,14 +27,6 @@ module.exports = (server: http.Server) => { return; } - const user = await authenticate(connection, request.resourceURL.query.i); - - if (user == null) { - connection.send('authentication-failed'); - connection.close(); - return; - } - // Connect to Redis const subscriber = redis.createClient( config.redis.port, config.redis.host); @@ -43,6 +36,19 @@ module.exports = (server: http.Server) => { subscriber.quit(); }); + if (request.resourceURL.pathname === '/channel') { + channelStream(request, connection, subscriber); + return; + } + + const user = await authenticate(request.resourceURL.query.i); + + if (user == null) { + connection.send('authentication-failed'); + connection.close(); + return; + } + const channel = request.resourceURL.pathname === '/' ? homeStream : request.resourceURL.pathname === '/messaging' ? messagingStream : @@ -56,7 +62,11 @@ module.exports = (server: http.Server) => { }); }; -function authenticate(connection: websocket.connection, token: string): Promise { +/** + * 接続してきたユーザーを取得します + * @param token 送信されてきたトークン + */ +function authenticate(token: string): Promise { if (token == null) { return Promise.resolve(null); } @@ -64,8 +74,7 @@ function authenticate(connection: websocket.connection, token: string): Promise< return new Promise(async (resolve, reject) => { if (isNativeToken(token)) { // Fetch user - // SELECT _id - const user = await User + const user: IUser = await User .findOne({ token: token }); @@ -81,13 +90,8 @@ function authenticate(connection: websocket.connection, token: string): Promise< } // Fetch user - // SELECT _id - const user = await User - .findOne({ _id: accessToken.user_id }, { - fields: { - _id: true - } - }); + const user: IUser = await User + .findOne({ _id: accessToken.user_id }); resolve(user); } diff --git a/src/web/app/common/scripts/get-post-summary.js b/src/common/get-post-summary.ts similarity index 57% rename from src/web/app/common/scripts/get-post-summary.js rename to src/common/get-post-summary.ts index 83eda8f6b..6e8f65708 100644 --- a/src/web/app/common/scripts/get-post-summary.js +++ b/src/common/get-post-summary.ts @@ -1,5 +1,15 @@ -const summarize = post => { - let summary = post.text ? post.text : ''; +/** + * 投稿を表す文字列を取得します。 + * @param {*} post 投稿 + */ +const summarize = (post: any): string => { + let summary = ''; + + // チャンネル + summary += post.channel ? `${post.channel.title}:` : ''; + + // 本文 + summary += post.text ? post.text : ''; // メディアが添付されているとき if (post.media) { @@ -12,9 +22,9 @@ const summarize = post => { } // 返信のとき - if (post.reply_to_id) { - if (post.reply_to) { - summary += ` RE: ${summarize(post.reply_to)}`; + if (post.reply_id) { + if (post.reply) { + summary += ` RE: ${summarize(post.reply)}`; } else { summary += ' RE: ...'; } diff --git a/src/common/get-user-summary.ts b/src/common/get-user-summary.ts new file mode 100644 index 000000000..1bec2f9a2 --- /dev/null +++ b/src/common/get-user-summary.ts @@ -0,0 +1,12 @@ +import { IUser } from '../api/models/user'; + +/** + * ユーザーを表す文字列を取得します。 + * @param user ユーザー + */ +export default function(user: IUser): string { + return `${user.name} (@${user.username})\n` + + `${user.posts_count}投稿、${user.following_count}フォロー、${user.followers_count}フォロワー\n` + + `場所: ${user.profile.location}、誕生日: ${user.profile.birthday}\n` + + `「${user.description}」`; +} diff --git a/src/common/othello.ts b/src/common/othello.ts new file mode 100644 index 000000000..858fc3315 --- /dev/null +++ b/src/common/othello.ts @@ -0,0 +1,268 @@ +const BOARD_SIZE = 8; + +export default class Othello { + public board: Array>; + + /** + * ゲームを初期化します + */ + constructor() { + this.board = [ + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, 'black', 'white', null, null, null], + [null, null, null, 'white', 'black', null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null], + [null, null, null, null, null, null, null, null] + ]; + } + + public setByNumber(color, n) { + const ps = this.getPattern(color); + this.set(color, ps[n][0], ps[n][1]); + } + + private write(color, x, y) { + this.board[y][x] = color; + } + + /** + * 石を配置します + */ + public set(color, x, y) { + this.write(color, x, y); + + const reverses = this.getReverse(color, x, y); + + reverses.forEach(r => { + switch (r[0]) { + case 0: // 上 + for (let c = 0, _y = y - 1; c < r[1]; c++, _y--) { + this.write(color, x, _y); + } + break; + + case 1: // 右上 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x + i, y - i); + } + break; + + case 2: // 右 + for (let c = 0, _x = x + 1; c < r[1]; c++, _x++) { + this.write(color, _x, y); + } + break; + + case 3: // 右下 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x + i, y + i); + } + break; + + case 4: // 下 + for (let c = 0, _y = y + 1; c < r[1]; c++, _y++) { + this.write(color, x, _y); + } + break; + + case 5: // 左下 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x - i, y + i); + } + break; + + case 6: // 左 + for (let c = 0, _x = x - 1; c < r[1]; c++, _x--) { + this.write(color, _x, y); + } + break; + + case 7: // 左上 + for (let c = 0, i = 1; c < r[1]; c++, i++) { + this.write(color, x - i, y - i); + } + break; + } + }); + } + + /** + * 打つことができる場所を取得します + */ + public getPattern(myColor): number[][] { + const result = []; + this.board.forEach((stones, y) => stones.forEach((stone, x) => { + if (stone != null) return; + if (this.canReverse(myColor, x, y)) result.push([x, y]); + })); + return result; + } + + /** + * 指定の位置に石を打つことができるかどうか(相手の石を1つでも反転させられるか)を取得します + */ + public canReverse(myColor, targetx, targety): boolean { + return this.getReverse(myColor, targetx, targety) !== null; + } + + private getReverse(myColor, targetx, targety): number[] { + const opponentColor = myColor == 'black' ? 'white' : 'black'; + + const createIterater = () => { + let opponentStoneFound = false; + let breaked = false; + return (x, y): any => { + if (breaked) { + return; + } else if (this.board[y][x] == myColor && opponentStoneFound) { + return true; + } else if (this.board[y][x] == myColor && !opponentStoneFound) { + breaked = true; + } else if (this.board[y][x] == opponentColor) { + opponentStoneFound = true; + } else { + breaked = true; + } + }; + }; + + const res = []; + + let iterate; + + // 上 + iterate = createIterater(); + for (let c = 0, y = targety - 1; y >= 0; c++, y--) { + if (iterate(targetx, y)) { + res.push([0, c]); + break; + } + } + + // 右上 + iterate = createIterater(); + for (let c = 0, i = 1; i <= Math.min(BOARD_SIZE - targetx, targety); c++, i++) { + if (iterate(targetx + i, targety - i)) { + res.push([1, c]); + break; + } + } + + // 右 + iterate = createIterater(); + for (let c = 0, x = targetx + 1; x < BOARD_SIZE; c++, x++) { + if (iterate(x, targety)) { + res.push([2, c]); + break; + } + } + + // 右下 + iterate = createIterater(); + for (let c = 0, i = 1; i < Math.min(BOARD_SIZE - targetx, BOARD_SIZE - targety); c++, i++) { + if (iterate(targetx + i, targety + i)) { + res.push([3, c]); + break; + } + } + + // 下 + iterate = createIterater(); + for (let c = 0, y = targety + 1; y < BOARD_SIZE; c++, y++) { + if (iterate(targetx, y)) { + res.push([4, c]); + break; + } + } + + // 左下 + iterate = createIterater(); + for (let c = 0, i = 1; i < Math.min(targetx, BOARD_SIZE - targety); c++, i++) { + if (iterate(targetx - i, targety + i)) { + res.push([5, c]); + break; + } + } + + // 左 + iterate = createIterater(); + for (let c = 0, x = targetx - 1; x >= 0; c++, x--) { + if (iterate(x, targety)) { + res.push([6, c]); + break; + } + } + + // 左上 + iterate = createIterater(); + for (let c = 0, i = 1; i <= Math.min(targetx, targety); c++, i++) { + if (iterate(targetx - i, targety - i)) { + res.push([7, c]); + break; + } + } + + return res.length === 0 ? null : res; + } + + public toString(): string { + //return this.board.map(row => row.map(state => state === 'black' ? '●' : state === 'white' ? '○' : '┼').join('')).join('\n'); + return this.board.map(row => row.map(state => state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : '🔹').join('')).join('\n'); + } + + public toPatternString(color): string { + //const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + const num = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🍍']; + + const pattern = this.getPattern(color); + + return this.board.map((row, y) => row.map((state, x) => { + const i = pattern.findIndex(p => p[0] == x && p[1] == y); + //return state === 'black' ? '●' : state === 'white' ? '○' : i != -1 ? num[i] : '┼'; + return state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : i != -1 ? num[i] : '🔹'; + }).join('')).join('\n'); + } +} + +export function ai(color: string, othello: Othello) { + const opponentColor = color == 'black' ? 'white' : 'black'; + + function think() { + // 打てる場所を取得 + const ps = othello.getPattern(color); + + if (ps.length > 0) { // 打てる場所がある場合 + // 角を取得 + const corners = ps.filter(p => + // 左上 + (p[0] == 0 && p[1] == 0) || + // 右上 + (p[0] == (BOARD_SIZE - 1) && p[1] == 0) || + // 右下 + (p[0] == (BOARD_SIZE - 1) && p[1] == (BOARD_SIZE - 1)) || + // 左下 + (p[0] == 0 && p[1] == (BOARD_SIZE - 1)) + ); + + if (corners.length > 0) { // どこかしらの角に打てる場合 + // 打てる角からランダムに選択して打つ + const p = corners[Math.floor(Math.random() * corners.length)]; + othello.set(color, p[0], p[1]); + } else { // 打てる角がない場合 + // 打てる場所からランダムに選択して打つ + const p = ps[Math.floor(Math.random() * ps.length)]; + othello.set(color, p[0], p[1]); + } + + // 相手の打つ場所がない場合続けてAIのターン + if (othello.getPattern(opponentColor).length === 0) { + think(); + } + } + } + + think(); +} diff --git a/src/config.ts b/src/config.ts index 8f4ada5af..d37d227a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,13 @@ type Source = { hook_secret: string; username: string; }; + line_bot?: { + channel_secret: string; + channel_access_token: string; + }; + analysis?: { + mecab_command?: string; + }; }; /** @@ -81,6 +88,7 @@ type Mixin = { api_url: string; auth_url: string; about_url: string; + ch_url: string; stats_url: string; status_url: string; dev_url: string; @@ -115,6 +123,7 @@ export default function load() { mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://')); mixin.api_url = `${mixin.scheme}://api.${mixin.host}`; mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`; + mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`; mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`; mixin.about_url = `${mixin.scheme}://about.${mixin.host}`; mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`; diff --git a/src/const.json b/src/const.json index 1032ed538..eeb304c9f 100644 --- a/src/const.json +++ b/src/const.json @@ -1,5 +1,4 @@ { - "themeColor": "#87bb35", - "themeColorForeground": "#fff", - "idea": ["#f13049", "#f43636"] + "themeColor": "#f43636", + "themeColorForeground": "#fff" } diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts index 6ee7f4534..c978e6460 100644 --- a/src/db/mongodb.ts +++ b/src/db/mongodb.ts @@ -1,11 +1,38 @@ -import * as mongo from 'monk'; - import config from '../conf'; const uri = config.mongodb.user && config.mongodb.pass - ? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` - : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; +? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` +: `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; + +/** + * monk + */ +import * as mongo from 'monk'; const db = mongo(uri); export default db; + +/** + * MongoDB native module (officialy) + */ +import * as mongodb from 'mongodb'; + +let mdb: mongodb.Db; + +const nativeDbConn = async (): Promise => { + if (mdb) return mdb; + + const db = await ((): Promise => new Promise((resolve, reject) => { + mongodb.MongoClient.connect(uri, (e, db) => { + if (e) return reject(e); + resolve(db); + }); + }))(); + + mdb = db; + + return db; +}; + +export { nativeDbConn }; diff --git a/src/docs/api/entities/post.pug b/src/docs/api/entities/post.pug index e505d3fcb..954f17271 100644 --- a/src/docs/api/entities/post.pug +++ b/src/docs/api/entities/post.pug @@ -52,11 +52,11 @@ block content td Number td 返信数 tr.optional - td reply_to + td reply td: a(href='./post', target='_blank') Post td 返信先の投稿 tr.nullable - td reply_to_id + td reply_id td ID td 返信先の投稿のID tr.optional @@ -90,7 +90,7 @@ block content { "created_at": "2016-12-10T00:28:50.114Z", "media_ids": null, - "reply_to_id": "584a16b15860fc52320137e3", + "reply_id": "584a16b15860fc52320137e3", "repost_id": null, "text": "小日向美穂だぞ!", "user_id": "5848bf7764e572683f4402f8", @@ -117,10 +117,10 @@ block content "is_following": true, "is_followed": true }, - "reply_to": { + "reply": { "created_at": "2016-12-09T02:28:01.563Z", "media_ids": null, - "reply_to_id": "5849d35e547e4249be329884", + "reply_id": "5849d35e547e4249be329884", "repost_id": null, "text": "アイコン小日向美穂?", "user_id": "57d01a501fdf2d07be417afe", diff --git a/src/file/server.ts b/src/file/server.ts index ee67cf786..375f29487 100644 --- a/src/file/server.ts +++ b/src/file/server.ts @@ -9,7 +9,7 @@ import * as cors from 'cors'; import * as mongodb from 'mongodb'; import * as gm from 'gm'; -import File from '../api/models/drive-file'; +import DriveFile, { getGridFSBucket } from '../api/models/drive-file'; /** * Init app @@ -97,17 +97,28 @@ app.get('/:id', async (req, res) => { return; } - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + const fileId = new mongodb.ObjectID(req.params.id); + const file = await DriveFile.findOne({ _id: fileId }); if (file == null) { - res.status(404).sendFile(`${__dirname} / assets / dummy.png`); - return; - } else if (file.data == null) { - res.sendStatus(400); + res.status(404).sendFile(`${__dirname}/assets/dummy.png`); return; } - send(file.data.buffer, file.type, req, res); + const bucket = await getGridFSBucket(); + + const buffer = await ((id): Promise => new Promise((resolve, reject) => { + const chunks = []; + const readableStream = bucket.openDownloadStream(id); + readableStream.on('data', chunk => { + chunks.push(chunk); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }))(fileId); + + send(buffer, file.metadata.type, req, res); }); app.get('/:id/:name', async (req, res) => { @@ -117,17 +128,28 @@ app.get('/:id/:name', async (req, res) => { return; } - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + const fileId = new mongodb.ObjectID(req.params.id); + const file = await DriveFile.findOne({ _id: fileId }); if (file == null) { res.status(404).sendFile(`${__dirname}/assets/dummy.png`); return; - } else if (file.data == null) { - res.sendStatus(400); - return; } - send(file.data.buffer, file.type, req, res); + const bucket = await getGridFSBucket(); + + const buffer = await ((id): Promise => new Promise((resolve, reject) => { + const chunks = []; + const readableStream = bucket.openDownloadStream(id); + readableStream.on('data', chunk => { + chunks.push(chunk); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }))(fileId); + + send(buffer, file.metadata.type, req, res); }); module.exports = app; diff --git a/src/tools/analysis/core.ts b/src/tools/analysis/core.ts new file mode 100644 index 000000000..20e5fa6c5 --- /dev/null +++ b/src/tools/analysis/core.ts @@ -0,0 +1,49 @@ +const bayes = require('./naive-bayes.js'); + +const MeCab = require('./mecab'); +import Post from '../../api/models/post'; + +/** + * 投稿を学習したり与えられた投稿のカテゴリを予測します + */ +export default class Categorizer { + private classifier: any; + private mecab: any; + + constructor() { + this.mecab = new MeCab(); + + // BIND ----------------------------------- + this.tokenizer = this.tokenizer.bind(this); + } + + private tokenizer(text: string) { + const tokens = this.mecab.parseSync(text) + // 名詞だけに制限 + .filter(token => token[1] === '名詞') + // 取り出し + .map(token => token[0]); + + return tokens; + } + + public async init() { + this.classifier = bayes({ + tokenizer: this.tokenizer + }); + + // 訓練データ取得 + const verifiedPosts = await Post.find({ + is_category_verified: true + }); + + // 学習 + verifiedPosts.forEach(post => { + this.classifier.learn(post.text, post.category); + }); + } + + public async predict(text) { + return this.classifier.categorize(text); + } +} diff --git a/src/tools/analysis/extract-user-domains.ts b/src/tools/analysis/extract-user-domains.ts new file mode 100644 index 000000000..bc120f5c1 --- /dev/null +++ b/src/tools/analysis/extract-user-domains.ts @@ -0,0 +1,120 @@ +import * as URL from 'url'; + +import Post from '../../api/models/post'; +import User from '../../api/models/user'; +import parse from '../../api/common/text'; + +process.on('unhandledRejection', console.dir); + +function tokenize(text: string) { + if (text == null) return []; + + // パース + const ast = parse(text); + + const domains = ast + // URLを抽出 + .filter(t => t.type == 'url' || t.type == 'link') + .map(t => URL.parse(t.url).hostname); + + return domains; +} + +// Fetch all users +User.find({}, { + fields: { + _id: true + } +}).then(users => { + let i = -1; + + const x = cb => { + if (++i == users.length) return cb(); + extractDomainsOne(users[i]._id).then(() => x(cb), err => { + console.error(err); + setTimeout(() => { + i--; + x(cb); + }, 1000); + }); + }; + + x(() => { + console.log('complete'); + }); +}); + +function extractDomainsOne(id) { + return new Promise(async (resolve, reject) => { + process.stdout.write(`extracting domains of ${id} ...`); + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: id, + text: { + $exists: true + } + }, { + sort: { + _id: -1 + }, + limit: 10000, + fields: { + _id: false, + text: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length < 100) { + process.stdout.write(' >>> -\n'); + return resolve(); + } + + const domains = {}; + + // Extract domains from recent posts + recentPosts.forEach(post => { + const domainsOfPost = tokenize(post.text); + + domainsOfPost.forEach(domain => { + if (domains[domain]) { + domains[domain]++; + } else { + domains[domain] = 1; + } + }); + }); + + // Calc peak + let peak = 0; + Object.keys(domains).forEach(domain => { + if (domains[domain] > peak) peak = domains[domain]; + }); + + // Sort domains by frequency + const domainsSorted = Object.keys(domains).sort((a, b) => domains[b] - domains[a]); + + // Lookup top 10 domains + const topDomains = domainsSorted.slice(0, 10); + + process.stdout.write(' >>> ' + topDomains.join(', ') + '\n'); + + // Make domains object (includes weights) + const domainsObj = topDomains.map(domain => ({ + domain: domain, + weight: domains[domain] / peak + })); + + // Save + User.update({ _id: id }, { + $set: { + domains: domainsObj + } + }).then(() => { + resolve(); + }, err => { + reject(err); + }); + }); +} diff --git a/src/tools/analysis/extract-user-keywords.ts b/src/tools/analysis/extract-user-keywords.ts new file mode 100644 index 000000000..b99ca9321 --- /dev/null +++ b/src/tools/analysis/extract-user-keywords.ts @@ -0,0 +1,154 @@ +const moji = require('moji'); + +const MeCab = require('./mecab'); +import Post from '../../api/models/post'; +import User from '../../api/models/user'; +import parse from '../../api/common/text'; + +process.on('unhandledRejection', console.dir); + +const stopwords = [ + 'ー', + + 'の', 'に', 'は', 'を', 'た', 'が', 'で', 'て', 'と', 'し', 'れ', 'さ', + 'ある', 'いる', 'も', 'する', 'から', 'な', 'こと', 'として', 'い', 'や', 'れる', + 'など', 'なっ', 'ない', 'この', 'ため', 'その', 'あっ', 'よう', 'また', 'もの', + 'という', 'あり', 'まで', 'られ', 'なる', 'へ', 'か', 'だ', 'これ', 'によって', + 'により', 'おり', 'より', 'による', 'ず', 'なり', 'られる', 'において', 'ば', 'なかっ', + 'なく', 'しかし', 'について', 'せ', 'だっ', 'その後', 'できる', 'それ', 'う', 'ので', + 'なお', 'のみ', 'でき', 'き', 'つ', 'における', 'および', 'いう', 'さらに', 'でも', + 'ら', 'たり', 'その他', 'に関する', 'たち', 'ます', 'ん', 'なら', 'に対して', '特に', + 'せる', '及び', 'これら', 'とき', 'では', 'にて', 'ほか', 'ながら', 'うち', 'そして', + 'とともに', 'ただし', 'かつて', 'それぞれ', 'または', 'お', 'ほど', 'ものの', 'に対する', + 'ほとんど', 'と共に', 'といった', 'です', 'とも', 'ところ', 'ここ', '感じ', '気持ち', + 'あと', '自分', 'すき', '()', + + 'about', 'after', 'all', 'also', 'am', 'an', 'and', 'another', 'any', 'are', 'as', 'at', 'be', + 'because', 'been', 'before', 'being', 'between', 'both', 'but', 'by', 'came', 'can', + 'come', 'could', 'did', 'do', 'each', 'for', 'from', 'get', 'got', 'has', 'had', + 'he', 'have', 'her', 'here', 'him', 'himself', 'his', 'how', 'if', 'in', 'into', + 'is', 'it', 'like', 'make', 'many', 'me', 'might', 'more', 'most', 'much', 'must', + 'my', 'never', 'now', 'of', 'on', 'only', 'or', 'other', 'our', 'out', 'over', + 'said', 'same', 'see', 'should', 'since', 'some', 'still', 'such', 'take', 'than', + 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', + 'through', 'to', 'too', 'under', 'up', 'very', 'was', 'way', 'we', 'well', 'were', + 'what', 'where', 'which', 'while', 'who', 'with', 'would', 'you', 'your', 'a', 'i' +]; + +const mecab = new MeCab(); + +function tokenize(text: string) { + if (text == null) return []; + + // パース + const ast = parse(text); + + const plain = ast + // テキストのみ(URLなどを除外するという意) + .filter(t => t.type == 'text' || t.type == 'bold') + .map(t => t.content) + .join(''); + + const tokens = mecab.parseSync(plain) + // キーワードのみ + .filter(token => token[1] == '名詞' && (token[2] == '固有名詞' || token[2] == '一般')) + // 取り出し(&整形(全角を半角にしたり大文字を小文字で統一したり)) + .map(token => moji(token[0]).convert('ZE', 'HE').convert('HK', 'ZK').toString().toLowerCase()) + // ストップワードなど + .filter(word => + stopwords.indexOf(word) === -1 && + word.length > 1 && + word.indexOf('!') === -1 && + word.indexOf('!') === -1 && + word.indexOf('?') === -1 && + word.indexOf('?') === -1); + + return tokens; +} + +// Fetch all users +User.find({}, { + fields: { + _id: true + } +}).then(users => { + let i = -1; + + const x = cb => { + if (++i == users.length) return cb(); + extractKeywordsOne(users[i]._id).then(() => x(cb), err => { + console.error(err); + setTimeout(() => { + i--; + x(cb); + }, 1000); + }); + }; + + x(() => { + console.log('complete'); + }); +}); + +function extractKeywordsOne(id) { + return new Promise(async (resolve, reject) => { + process.stdout.write(`extracting keywords of ${id} ...`); + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: id, + text: { + $exists: true + } + }, { + sort: { + _id: -1 + }, + limit: 10000, + fields: { + _id: false, + text: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length < 300) { + process.stdout.write(' >>> -\n'); + return resolve(); + } + + const keywords = {}; + + // Extract keywords from recent posts + recentPosts.forEach(post => { + const keywordsOfPost = tokenize(post.text); + + keywordsOfPost.forEach(keyword => { + if (keywords[keyword]) { + keywords[keyword]++; + } else { + keywords[keyword] = 1; + } + }); + }); + + // Sort keywords by frequency + const keywordsSorted = Object.keys(keywords).sort((a, b) => keywords[b] - keywords[a]); + + // Lookup top 10 keywords + const topKeywords = keywordsSorted.slice(0, 10); + + process.stdout.write(' >>> ' + topKeywords.join(', ') + '\n'); + + // Save + User.update({ _id: id }, { + $set: { + keywords: topKeywords + } + }).then(() => { + resolve(); + }, err => { + reject(err); + }); + }); +} diff --git a/src/tools/analysis/mecab.js b/src/tools/analysis/mecab.js new file mode 100644 index 000000000..82f7d6d52 --- /dev/null +++ b/src/tools/analysis/mecab.js @@ -0,0 +1,85 @@ +// Original source code: https://github.com/hecomi/node-mecab-async +// CUSTOMIZED BY SYUILO + +var exec = require('child_process').exec; +var execSync = require('child_process').execSync; +var sq = require('shell-quote'); + +const config = require('../../conf').default; + +// for backward compatibility +var MeCab = function() {}; + +MeCab.prototype = { + command : config.analysis.mecab_command ? config.analysis.mecab_command : 'mecab', + _format: function(arrayResult) { + var result = []; + if (!arrayResult) { return result; } + // Reference: http://mecab.googlecode.com/svn/trunk/mecab/doc/index.html + // 表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用形,活用型,原形,読み,発音 + arrayResult.forEach(function(parsed) { + if (parsed.length <= 8) { return; } + result.push({ + kanji : parsed[0], + lexical : parsed[1], + compound : parsed[2], + compound2 : parsed[3], + compound3 : parsed[4], + conjugation : parsed[5], + inflection : parsed[6], + original : parsed[7], + reading : parsed[8], + pronunciation : parsed[9] || '' + }); + }); + return result; + }, + _shellCommand : function(str) { + return sq.quote(['echo', str]) + ' | ' + this.command; + }, + _parseMeCabResult : function(result) { + return result.split('\n').map(function(line) { + return line.replace('\t', ',').split(','); + }); + }, + parse : function(str, callback) { + process.nextTick(function() { // for bug + exec(MeCab._shellCommand(str), function(err, result) { + if (err) { return callback(err); } + callback(err, MeCab._parseMeCabResult(result).slice(0,-2)); + }); + }); + }, + parseSync : function(str) { + var result = execSync(MeCab._shellCommand(str)); + return MeCab._parseMeCabResult(String(result)).slice(0, -2); + }, + parseFormat : function(str, callback) { + MeCab.parse(str, function(err, result) { + if (err) { return callback(err); } + callback(err, MeCab._format(result)); + }); + }, + parseSyncFormat : function(str) { + return MeCab._format(MeCab.parseSync(str)); + }, + _wakatsu : function(arr) { + return arr.map(function(data) { return data[0]; }); + }, + wakachi : function(str, callback) { + MeCab.parse(str, function(err, arr) { + if (err) { return callback(err); } + callback(null, MeCab._wakatsu(arr)); + }); + }, + wakachiSync : function(str) { + var arr = MeCab.parseSync(str); + return MeCab._wakatsu(arr); + } +}; + +for (var x in MeCab.prototype) { + MeCab[x] = MeCab.prototype[x]; +} + +module.exports = MeCab; diff --git a/src/tools/analysis/naive-bayes.js b/src/tools/analysis/naive-bayes.js new file mode 100644 index 000000000..78f07153c --- /dev/null +++ b/src/tools/analysis/naive-bayes.js @@ -0,0 +1,302 @@ +// Original source code: https://github.com/ttezel/bayes/blob/master/lib/naive_bayes.js (commit: 2c20d3066e4fc786400aaedcf3e42987e52abe3c) +// CUSTOMIZED BY SYUILO + +/* + Expose our naive-bayes generator function +*/ +module.exports = function (options) { + return new Naivebayes(options) +} + +// keys we use to serialize a classifier's state +var STATE_KEYS = module.exports.STATE_KEYS = [ + 'categories', 'docCount', 'totalDocuments', 'vocabulary', 'vocabularySize', + 'wordCount', 'wordFrequencyCount', 'options' +]; + +/** + * Initializes a NaiveBayes instance from a JSON state representation. + * Use this with classifier.toJson(). + * + * @param {String} jsonStr state representation obtained by classifier.toJson() + * @return {NaiveBayes} Classifier + */ +module.exports.fromJson = function (jsonStr) { + var parsed; + try { + parsed = JSON.parse(jsonStr) + } catch (e) { + throw new Error('Naivebayes.fromJson expects a valid JSON string.') + } + // init a new classifier + var classifier = new Naivebayes(parsed.options) + + // override the classifier's state + STATE_KEYS.forEach(function (k) { + if (!parsed[k]) { + throw new Error('Naivebayes.fromJson: JSON string is missing an expected property: `'+k+'`.') + } + classifier[k] = parsed[k] + }) + + return classifier +} + +/** + * Given an input string, tokenize it into an array of word tokens. + * This is the default tokenization function used if user does not provide one in `options`. + * + * @param {String} text + * @return {Array} + */ +var defaultTokenizer = function (text) { + //remove punctuation from text - remove anything that isn't a word char or a space + var rgxPunctuation = /[^(a-zA-ZA-Яa-я0-9_)+\s]/g + + var sanitized = text.replace(rgxPunctuation, ' ') + + return sanitized.split(/\s+/) +} + +/** + * Naive-Bayes Classifier + * + * This is a naive-bayes classifier that uses Laplace Smoothing. + * + * Takes an (optional) options object containing: + * - `tokenizer` => custom tokenization function + * + */ +function Naivebayes (options) { + // set options object + this.options = {} + if (typeof options !== 'undefined') { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw TypeError('NaiveBayes got invalid `options`: `' + options + '`. Pass in an object.') + } + this.options = options + } + + this.tokenizer = this.options.tokenizer || defaultTokenizer + + //initialize our vocabulary and its size + this.vocabulary = {} + this.vocabularySize = 0 + + //number of documents we have learned from + this.totalDocuments = 0 + + //document frequency table for each of our categories + //=> for each category, how often were documents mapped to it + this.docCount = {} + + //for each category, how many words total were mapped to it + this.wordCount = {} + + //word frequency table for each category + //=> for each category, how frequent was a given word mapped to it + this.wordFrequencyCount = {} + + //hashmap of our category names + this.categories = {} +} + +/** + * Initialize each of our data structure entries for this new category + * + * @param {String} categoryName + */ +Naivebayes.prototype.initializeCategory = function (categoryName) { + if (!this.categories[categoryName]) { + this.docCount[categoryName] = 0 + this.wordCount[categoryName] = 0 + this.wordFrequencyCount[categoryName] = {} + this.categories[categoryName] = true + } + return this +} + +/** + * train our naive-bayes classifier by telling it what `category` + * the `text` corresponds to. + * + * @param {String} text + * @param {String} class + */ +Naivebayes.prototype.learn = function (text, category) { + var self = this + + //initialize category data structures if we've never seen this category + self.initializeCategory(category) + + //update our count of how many documents mapped to this category + self.docCount[category]++ + + //update the total number of documents we have learned from + self.totalDocuments++ + + //normalize the text into a word array + var tokens = self.tokenizer(text) + + //get a frequency count for each token in the text + var frequencyTable = self.frequencyTable(tokens) + + /* + Update our vocabulary and our word frequency count for this category + */ + + Object + .keys(frequencyTable) + .forEach(function (token) { + //add this word to our vocabulary if not already existing + if (!self.vocabulary[token]) { + self.vocabulary[token] = true + self.vocabularySize++ + } + + var frequencyInText = frequencyTable[token] + + //update the frequency information for this word in this category + if (!self.wordFrequencyCount[category][token]) + self.wordFrequencyCount[category][token] = frequencyInText + else + self.wordFrequencyCount[category][token] += frequencyInText + + //update the count of all words we have seen mapped to this category + self.wordCount[category] += frequencyInText + }) + + return self +} + +/** + * Determine what category `text` belongs to. + * + * @param {String} text + * @return {String} category + */ +Naivebayes.prototype.categorize = function (text) { + var self = this + , maxProbability = -Infinity + , chosenCategory = null + + var tokens = self.tokenizer(text) + var frequencyTable = self.frequencyTable(tokens) + + //iterate thru our categories to find the one with max probability for this text + Object + .keys(self.categories) + .forEach(function (category) { + + //start by calculating the overall probability of this category + //=> out of all documents we've ever looked at, how many were + // mapped to this category + var categoryProbability = self.docCount[category] / self.totalDocuments + + //take the log to avoid underflow + var logProbability = Math.log(categoryProbability) + + //now determine P( w | c ) for each word `w` in the text + Object + .keys(frequencyTable) + .forEach(function (token) { + var frequencyInText = frequencyTable[token] + var tokenProbability = self.tokenProbability(token, category) + + // console.log('token: %s category: `%s` tokenProbability: %d', token, category, tokenProbability) + + //determine the log of the P( w | c ) for this word + logProbability += frequencyInText * Math.log(tokenProbability) + }) + + if (logProbability > maxProbability) { + maxProbability = logProbability + chosenCategory = category + } + }) + + return chosenCategory +} + +/** + * Calculate probability that a `token` belongs to a `category` + * + * @param {String} token + * @param {String} category + * @return {Number} probability + */ +Naivebayes.prototype.tokenProbability = function (token, category) { + //how many times this word has occurred in documents mapped to this category + var wordFrequencyCount = this.wordFrequencyCount[category][token] || 0 + + //what is the count of all words that have ever been mapped to this category + var wordCount = this.wordCount[category] + + //use laplace Add-1 Smoothing equation + return ( wordFrequencyCount + 1 ) / ( wordCount + this.vocabularySize ) +} + +/** + * Build a frequency hashmap where + * - the keys are the entries in `tokens` + * - the values are the frequency of each entry in `tokens` + * + * @param {Array} tokens Normalized word array + * @return {Object} + */ +Naivebayes.prototype.frequencyTable = function (tokens) { + var frequencyTable = Object.create(null) + + tokens.forEach(function (token) { + if (!frequencyTable[token]) + frequencyTable[token] = 1 + else + frequencyTable[token]++ + }) + + return frequencyTable +} + +/** + * Dump the classifier's state as a JSON string. + * @return {String} Representation of the classifier. + */ +Naivebayes.prototype.toJson = function () { + var state = {} + var self = this + STATE_KEYS.forEach(function (k) { + state[k] = self[k] + }) + + var jsonStr = JSON.stringify(state) + + return jsonStr +} + +// (original method) +Naivebayes.prototype.export = function () { + var state = {} + var self = this + STATE_KEYS.forEach(function (k) { + state[k] = self[k] + }) + + return state +} + +module.exports.import = function (data) { + var parsed = data + + // init a new classifier + var classifier = new Naivebayes() + + // override the classifier's state + STATE_KEYS.forEach(function (k) { + if (!parsed[k]) { + throw new Error('Naivebayes.import: data is missing an expected property: `'+k+'`.') + } + classifier[k] = parsed[k] + }) + + return classifier +} diff --git a/src/tools/analysis/predict-all-post-category.ts b/src/tools/analysis/predict-all-post-category.ts new file mode 100644 index 000000000..058c4f99e --- /dev/null +++ b/src/tools/analysis/predict-all-post-category.ts @@ -0,0 +1,35 @@ +import Post from '../../api/models/post'; +import Core from './core'; + +const c = new Core(); + +c.init().then(() => { + // 全ての(人間によって証明されていない)投稿を取得 + Post.find({ + text: { + $exists: true + }, + is_category_verified: { + $ne: true + } + }, { + sort: { + _id: -1 + }, + fields: { + _id: true, + text: true + } + }).then(posts => { + posts.forEach(post => { + console.log(`predicting... ${post._id}`); + const category = c.predict(post.text); + + Post.update({ _id: post._id }, { + $set: { + category: category + } + }); + }); + }); +}); diff --git a/src/tools/analysis/predict-user-interst.ts b/src/tools/analysis/predict-user-interst.ts new file mode 100644 index 000000000..99bdfa420 --- /dev/null +++ b/src/tools/analysis/predict-user-interst.ts @@ -0,0 +1,45 @@ +import Post from '../../api/models/post'; +import User from '../../api/models/user'; + +export async function predictOne(id) { + console.log(`predict interest of ${id} ...`); + + // TODO: repostなども含める + const recentPosts = await Post.find({ + user_id: id, + category: { + $exists: true + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + category: true + } + }); + + const categories = {}; + + recentPosts.forEach(post => { + if (categories[post.category]) { + categories[post.category]++; + } else { + categories[post.category] = 1; + } + }); +} + +export async function predictAll() { + const allUsers = await User.find({}, { + fields: { + _id: true + } + }); + + allUsers.forEach(user => { + predictOne(user._id); + }); +} diff --git a/src/tsconfig.json b/src/tsconfig.json index ecff047a7..36600eed2 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "noEmitOnError": false, "noImplicitAny": false, "noImplicitReturns": true, diff --git a/src/utils/type.ts b/src/utils/type.ts new file mode 100644 index 000000000..ba6ea0be7 --- /dev/null +++ b/src/utils/type.ts @@ -0,0 +1,3 @@ +// https://github.com/Microsoft/TypeScript/issues/12215 +export type Diff = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]; +export type Omit = { [P in Diff]: T[P] }; diff --git a/src/web/app/base.styl b/src/web/app/app.styl similarity index 94% rename from src/web/app/base.styl rename to src/web/app/app.styl index 81c039f0a..94faba73d 100644 --- a/src/web/app/base.styl +++ b/src/web/app/app.styl @@ -5,8 +5,6 @@ json('../../const.json') $theme-color = themeColor $theme-color-foreground = themeColorForeground -@import './reset' - /* ::selection background $theme-color @@ -14,6 +12,9 @@ $theme-color-foreground = themeColorForeground */ * + position relative + box-sizing border-box + background-clip padding-box !important tap-highlight-color rgba($theme-color, 0.7) -webkit-tap-highlight-color rgba($theme-color, 0.7) @@ -29,6 +30,9 @@ html &, * cursor progress !important +body + overflow-wrap break-word + #error padding 32px color #fff diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl index 046a5ff6e..bd25e1b57 100644 --- a/src/web/app/auth/style.styl +++ b/src/web/app/auth/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html background #eee diff --git a/src/web/app/ch/router.js b/src/web/app/ch/router.js new file mode 100644 index 000000000..424158f40 --- /dev/null +++ b/src/web/app/ch/router.js @@ -0,0 +1,32 @@ +import * as riot from 'riot'; +const route = require('page'); +let page = null; + +export default me => { + route('/', index); + route('/:channel', channel); + route('*', notFound); + + function index() { + mount(document.createElement('mk-index')); + } + + function channel(ctx) { + const el = document.createElement('mk-channel'); + el.setAttribute('id', ctx.params.channel); + mount(el); + } + + function notFound() { + mount(document.createElement('mk-not-found')); + } + + // EXEC + route(); +}; + +function mount(content) { + if (page) page.unmount(); + const body = document.getElementById('app'); + page = riot.mount(body.appendChild(content))[0]; +} diff --git a/src/web/app/ch/script.js b/src/web/app/ch/script.js new file mode 100644 index 000000000..760d405c5 --- /dev/null +++ b/src/web/app/ch/script.js @@ -0,0 +1,18 @@ +/** + * Channels + */ + +// Style +import './style.styl'; + +require('./tags'); +import init from '../init'; +import route from './router'; + +/** + * init + */ +init(me => { + // Start routing + route(me); +}); diff --git a/src/web/app/ch/style.styl b/src/web/app/ch/style.styl new file mode 100644 index 000000000..21ca648cb --- /dev/null +++ b/src/web/app/ch/style.styl @@ -0,0 +1,10 @@ +@import "../app" + +html + padding 8px + background #efefef + +#wait + top auto + bottom 15px + left 15px diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag new file mode 100644 index 000000000..4ae62e7b3 --- /dev/null +++ b/src/web/app/ch/tags/channel.tag @@ -0,0 +1,403 @@ + + +
+
+

{ channel.title }

+ +
+

このチャンネルをウォッチしています ウォッチ解除

+

このチャンネルをウォッチする

+
+ + + +
+

読み込み中

+
+

まだ投稿がありません

+ + + +
+
+
+ +
+

参加するにはログインまたは新規登録してください

+
+
+
+ Misskey ver { version } (葵 aoi) +
+
+ + +
+ + +
+ { post.index }: + { post.user.name } + + + ID:{ post.user.username } +
+
+ >>{ post.reply.index } + { post.text } +
+ + + { + + +
+
+ + +
+ + +

>>{ reply.index } ({ reply.user.name }): [x]

+ +
+ + + +
+ +
    +
  1. { name }
  2. +
+ + + +
+ + + + + + + + + + diff --git a/src/web/app/ch/tags/header.tag b/src/web/app/ch/tags/header.tag new file mode 100644 index 000000000..5cdcbd09c --- /dev/null +++ b/src/web/app/ch/tags/header.tag @@ -0,0 +1,20 @@ + +
+ Index | Misskey +
+ + + +
diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.js new file mode 100644 index 000000000..12ffdaeb8 --- /dev/null +++ b/src/web/app/ch/tags/index.js @@ -0,0 +1,3 @@ +require('./index.tag'); +require('./channel.tag'); +require('./header.tag'); diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag new file mode 100644 index 000000000..50ccc0d91 --- /dev/null +++ b/src/web/app/ch/tags/index.tag @@ -0,0 +1,35 @@ + + +
+ +
+ + + +
diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js new file mode 100644 index 000000000..17944dbe4 --- /dev/null +++ b/src/web/app/common/scripts/channel-stream.js @@ -0,0 +1,16 @@ +'use strict'; + +import Stream from './stream'; + +/** + * Channel stream connection + */ +class Connection extends Stream { + constructor(channelId) { + super('channel', { + channel: channelId + }); + } +} + +export default Connection; diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js index 75a7abba2..c5015622f 100644 --- a/src/web/app/common/scripts/config.js +++ b/src/web/app/common/scripts/config.js @@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U const scheme = Url.protocol; const url = `${scheme}//${host}`; const apiUrl = `${scheme}//api.${host}`; +const chUrl = `${scheme}//ch.${host}`; const devUrl = `${scheme}//dev.${host}`; const aboutUrl = `${scheme}//about.${host}`; const statsUrl = `${scheme}//stats.${host}`; @@ -16,6 +17,7 @@ export default { scheme, url, apiUrl, + chUrl, devUrl, aboutUrl, statsUrl, diff --git a/src/web/app/common/scripts/home-stream.js b/src/web/app/common/scripts/home-stream.js index 24f13cd29..de9ceb3b5 100644 --- a/src/web/app/common/scripts/home-stream.js +++ b/src/web/app/common/scripts/home-stream.js @@ -1,6 +1,7 @@ 'use strict'; import Stream from './stream'; +import signout from './signout'; /** * Home stream connection @@ -11,7 +12,17 @@ class Connection extends Stream { i: me.token }); + // 最終利用日時を更新するため定期的にaliveメッセージを送信 + setInterval(() => { + this.send({ type: 'alive' }); + }, 1000 * 60); + this.on('i_updated', me.update); + + this.on('my_token_regenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + signout(); + }); } } diff --git a/src/web/app/common/tags/activity-table.tag b/src/web/app/common/tags/activity-table.tag index 6331e7c9c..1d26d1788 100644 --- a/src/web/app/common/tags/activity-table.tag +++ b/src/web/app/common/tags/activity-table.tag @@ -17,7 +17,6 @@ display block max-width 600px margin 0 auto - background #fff > svg display block diff --git a/src/web/app/common/tags/api-info.tag b/src/web/app/common/tags/api-info.tag deleted file mode 100644 index 612f20a7a..000000000 --- a/src/web/app/common/tags/api-info.tag +++ /dev/null @@ -1,27 +0,0 @@ - -

Token:{ I.token }

-

APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。

-

アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。

-

万が一このトークンが漏れたりその可能性がある場合は - できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) -

- - -
diff --git a/src/web/app/common/tags/error.tag b/src/web/app/common/tags/error.tag index e4e0272a4..62f4563e5 100644 --- a/src/web/app/common/tags/error.tag +++ b/src/web/app/common/tags/error.tag @@ -1,7 +1,15 @@ - +

%i18n:common.tags.mk-error.title%

-

%i18n:common.tags.mk-error.description%

+

{ + '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) + }{ + '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] + }{ + '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) + }

+ +

%i18n:common.tags.mk-error.thanks%

+ + +

%i18n:common.tags.mk-error.troubleshooter.title%

+
+

{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }

+

{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }

+

{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }

+
+

%i18n:common.tags.mk-error.troubleshooter.finding%

+

%i18n:common.tags.mk-error.troubleshooter.no-network%
%i18n:common.tags.mk-error.troubleshooter.no-network-desc%

+

%i18n:common.tags.mk-error.troubleshooter.no-internet%
%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%

+

%i18n:common.tags.mk-error.troubleshooter.no-server%
%i18n:common.tags.mk-error.troubleshooter.no-server-desc%

+

%i18n:common.tags.mk-error.troubleshooter.success%
%i18n:common.tags.mk-error.troubleshooter.success-desc%

+ + + +
diff --git a/src/web/app/common/tags/index.js b/src/web/app/common/tags/index.js index 5dc4ef454..35a9f4586 100644 --- a/src/web/app/common/tags/index.js +++ b/src/web/app/common/tags/index.js @@ -14,7 +14,6 @@ require('./forkit.tag'); require('./introduction.tag'); require('./copyright.tag'); require('./signin-history.tag'); -require('./api-info.tag'); require('./twitter-setting.tag'); require('./authorized-apps.tag'); require('./poll.tag'); @@ -28,3 +27,4 @@ require('./activity-table.tag'); require('./reaction-picker.tag'); require('./reactions-viewer.tag'); require('./reaction-icon.tag'); +require('./post-menu.tag'); diff --git a/src/web/app/common/tags/post-menu.tag b/src/web/app/common/tags/post-menu.tag new file mode 100644 index 000000000..be4468a21 --- /dev/null +++ b/src/web/app/common/tags/post-menu.tag @@ -0,0 +1,157 @@ + +
+
+ +
+ + +
+
+ + +
diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag index 0359f4fab..17de0347f 100644 --- a/src/web/app/common/tags/signup.tag +++ b/src/web/app/common/tags/signup.tag @@ -3,7 +3,7 @@