diff --git a/.travis.yml b/.travis.yml index ed53af9e2..6e33a2d12 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ # travis file # https://docs.travis-ci.com/user/customizing-the-build +notifications: + email: false + branches: except: - release diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7f6b59e..6e69a7319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,113 @@ ChangeLog (Release Notes) ========================= 主に notable な changes を書いていきます +3493 (2018/01/01) +----------------- +* なんか + +3460 (2017/12/23) +----------------- +* 検索で複数のユーザーを指定できるように +* 検索でユーザーを除外できるように +* など + +3451 (2017/12/22) +----------------- +* ミュート機能 + +3430 (2017/12/21) +----------------- +* oops + +3428 (2017/12/21) +----------------- +* バグ修正 + +3426 (2017/12/21) +----------------- +* 検索にpoll追加 + +3424 (2017/12/21) +----------------- +* 検索にrepost追加 +* など + +3422 (2017/12/21) +----------------- +* 検索にfollow追加 #1023 + +3420 (2017/12/21) +----------------- +* 検索機能を大幅に強化 + +3415 (2017/12/19) +----------------- +* デザインの調整 + +3404 (2017/12/17) +----------------- +* なんか + +3400 (2017/12/17) +----------------- +* なんか + +3392 (2017/12/17) +----------------- +* ドキュメントなど + +3390 (2017/12/16) +----------------- +* ドキュメントなど + +3347 (2017/12/11) +----------------- +* バグ修正 + +3342 (2017/12/11) +----------------- +* なんか + +3339 (2017/12/11) +----------------- +* なんか + +3334 (2017/12/10) +----------------- +* いい感じにした + +3322 (2017/12/10) +----------------- +* :art: + +3320 (2017/12/10) +----------------- +* なんか + +3310 (2017/12/09) +----------------- +* i18nなど + +3308 (2017/12/09) +----------------- +* :art: + +3294 (2017/12/09) +----------------- +* バグ修正 + +3292 (2017/12/09) +----------------- +* ユーザビリティの向上 + +3281 (2017/12/08) +----------------- +* 二段階認証の実装 (#967) + +3278 (2017/12/08) +----------------- +* :v: + 3272 (2017/12/08) ----------------- * Fix bug diff --git a/LICENSE b/LICENSE index e3733b396..0b6e30e45 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014-2017 syuilo +Copyright (c) 2014-2018 syuilo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index e3c93b749..68a8f4b38 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Key features ---------------------------------------------------------------- * Automatically updated timeline * Private messages -* Free 1GB storage for each all users +* Two-Factor Authentication support * ServiceWorker support * Web API for third-party applications * No ads diff --git a/docs/backup.md b/docs/backup.md index 484564b31..74ec2678e 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -7,7 +7,7 @@ Make sure **mongodb-tools** installed. In your shell: ``` shell -$ mongodump --archive=db-backup +$ mongodump --archive=db-backup -u <YourUserName> -p <YourPassword> ``` For details, plese see [mongodump docs](https://docs.mongodb.com/manual/reference/program/mongodump/). diff --git a/docs/config.md b/docs/config.md index 653fff1a7..a9987c9ce 100644 --- a/docs/config.md +++ b/docs/config.md @@ -49,4 +49,12 @@ sw: # VAPIDの秘密鍵 private_key: +# Twitterインテグレーションの設定(利用しない場合は省略可能) +twitter: + # インテグレーション用アプリのコンシューマーキー + consumer_key: + + # インテグレーション用アプリのコンシューマーシークレット + consumer_secret: + ``` diff --git a/docs/setup.en.md b/docs/setup.en.md index 9c31e4f17..13b0bdaeb 100644 --- a/docs/setup.en.md +++ b/docs/setup.en.md @@ -24,7 +24,7 @@ Note that Misskey uses following subdomains: * **api**.*{primary domain}* * **auth**.*{primary domain}* -* **about**.*{primary domain}* +* **docs**.*{primary domain}* * **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* @@ -53,7 +53,7 @@ Please install and setup these softwares: * *Node.js* and *npm* * **[MongoDB](https://www.mongodb.com/)** * **[Redis](https://redis.io/)** -* **[GraphicsMagick](http://www.graphicsmagick.org/)** +* **[ImageMagick](http://www.imagemagick.org/script/index.php)** ##### Optional * [Elasticsearch](https://www.elastic.co/) - used to provide searching feature instead of MongoDB diff --git a/docs/setup.ja.md b/docs/setup.ja.md index 1e8bb553f..564c79097 100644 --- a/docs/setup.ja.md +++ b/docs/setup.ja.md @@ -25,7 +25,7 @@ Misskeyは以下のサブドメインを使います: * **api**.*{primary domain}* * **auth**.*{primary domain}* -* **about**.*{primary domain}* +* **docs**.*{primary domain}* * **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* @@ -54,7 +54,7 @@ web-push generate-vapid-keys * *Node.js* と *npm* * **[MongoDB](https://www.mongodb.com/)** * **[Redis](https://redis.io/)** -* **[GraphicsMagick](http://www.graphicsmagick.org/)** +* **[ImageMagick](http://www.imagemagick.org/script/index.php)** ##### オプション * [Elasticsearch](https://www.elastic.co/) - 検索機能を向上させるために用います。 diff --git a/gulpfile.ts b/gulpfile.ts index cb7227213..21870473e 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -3,12 +3,12 @@ */ import * as childProcess from 'child_process'; +import * as fs from 'fs'; import * as Path from 'path'; import * as gulp from 'gulp'; import * as gutil from 'gulp-util'; import * as ts from 'gulp-typescript'; import tslint from 'gulp-tslint'; -import * as es from 'event-stream'; import cssnano = require('gulp-cssnano'); import * as uglifyComposer from 'gulp-uglify/composer'; import pug = require('gulp-pug'); @@ -20,17 +20,10 @@ import * as mocha from 'gulp-mocha'; import * as replace from 'gulp-replace'; import * as htmlmin from 'gulp-htmlmin'; const uglifyes = require('uglify-es'); -import * as fontawesome from '@fortawesome/fontawesome'; -import * as regular from '@fortawesome/fontawesome-free-regular'; -import * as solid from '@fortawesome/fontawesome-free-solid'; -import * as brands from '@fortawesome/fontawesome-free-brands'; - -// Add icons -fontawesome.library.add(regular); -fontawesome.library.add(solid); -fontawesome.library.add(brands); +import { fa } from './src/common/build/fa'; import version from './src/version'; +import config from './src/conf'; const uglify = uglifyComposer(uglifyes, console); @@ -45,11 +38,14 @@ if (isDebug) { const constants = require('./src/const.json'); +require('./src/web/docs/gulpfile.ts'); + gulp.task('build', [ 'build:js', 'build:ts', 'build:copy', - 'build:client' + 'build:client', + 'doc' ]); gulp.task('rebuild', ['clean', 'build']); @@ -69,16 +65,10 @@ gulp.task('build:ts', () => { }); gulp.task('build:copy', () => - es.merge( - gulp.src([ - './src/**/assets/**/*', - '!./src/web/app/**/assets/**/*' - ]).pipe(gulp.dest('./built/')) as any, - gulp.src([ - './src/web/about/**/*', - '!./src/web/about/**/*.pug' - ]).pipe(gulp.dest('./built/web/about/')) as any - ) + gulp.src([ + './src/**/assets/**/*', + '!./src/web/app/**/assets/**/*' + ]).pipe(gulp.dest('./built/')) ); gulp.task('test', ['lint', 'mocha']); @@ -141,6 +131,7 @@ gulp.task('webpack', done => { gulp.task('build:client:script', () => gulp.src(['./src/web/app/boot.js', './src/web/app/safe.js']) .pipe(replace('VERSION', JSON.stringify(version))) + .pipe(replace('API', JSON.stringify(config.api_url))) .pipe(isProduction ? uglify({ toplevel: true } as any) : gutil.noop()) @@ -180,7 +171,9 @@ gulp.task('build:client:pug', [ .pipe(pug({ locals: { themeColor: constants.themeColor, - facss: fontawesome.dom.css() + facss: fa.dom.css(), + //hljscss: fs.readFileSync('./node_modules/highlight.js/styles/default.css', 'utf8') + hljscss: fs.readFileSync('./src/web/assets/code-highlight.css', 'utf8') } })) .pipe(htmlmin({ diff --git a/locales/en.yml b/locales/en.yml index 9a54eed67..e55984677 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -137,6 +137,7 @@ common: mk-signin: username: "Username" password: "Password" + token: "Token" signing-in: "Signing in..." signin: "Sign in" @@ -182,6 +183,24 @@ common: mk-uploader: waiting: "Waiting" +docs: + edit-this-page-on-github: "Caught a mistake or want to contribute to the documentation? " + edit-this-page-on-github-link: "Edit this page on Github!" + + api: + entities: + properties: "Properties" + endpoints: + params: "Parameters" + res: "Response" + props: + name: "Name" + type: "Type" + optional: "Optional" + description: "Description" + yes: "Yes" + no: "No" + ch: tags: mk-index: @@ -198,7 +217,11 @@ ch: desktop: tags: mk-api-info: - regenerate-token: "Please enter the password" + intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。" + caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。" + regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。" + regenerate-token: "Regenerate the token" + enter-password: "Please enter the password" mk-drive-browser-base-contextmenu: create-folder: "Create a folder" @@ -287,6 +310,15 @@ desktop: mk-ui-header-notifications: title: "Notifications" + mk-profile-setting: + avatar: "Avatar" + choice-avatar: "Choice an image" + name: "Name" + location: "Location" + description: "Description" + birthday: "Birthday" + save: "Update profile" + mk-password-setting: reset: "Change your password" enter-current-password: "Enter the current password" @@ -295,6 +327,28 @@ desktop: not-match: "New password not matched" changed: "Password updated successfully" + mk-2fa-setting: + intro: "If you set up 2-step verification, you will need not only a password at sign-in but also a pre-registered physical device (such as your smartphone), which will improve security. " + detail: "See details..." + url: "https://www.google.com/landing/2step/" + caution: "As a caveat, security improves, but you can not sign in to Misskey if you lose a registered device, etc." + register: "Register a device" + already-registered: "The setting has already been completed." + unregister: "Disable" + unregistered: "Two-step authentication has been disabled." + enter-password: "Enter the password" + authenticator: "First, you need install Google Authenticator to your device:" + howtoinstall: "How to install" + scan: "Next, please scan displayed QR code:" + done: "Please enter the token displaying in your device:" + submit: "Submit" + success: "Setup completed successfully!" + failed: "Failed to setup. please ensure that the token is correct." + info: "From the next sign in, enter the token that is displayed on the device in addition to the password." + + mk-mute-setting: + no-users: "No muted users" + mk-post-form: post-placeholder: "What's happening?" reply-placeholder: "Reply to this post..." @@ -327,7 +381,14 @@ desktop: next: "Next post" mk-settings: + profile: "Profile" + mute: "Mute" + drive: "Drive" + security: "Security" password: "Password" + 2fa: "Two-factor authentication" + other: "Other" + license: "License" mk-timeline-post: reposted-by: "Reposted by {}" @@ -416,6 +477,11 @@ desktop: mk-user: last-used-at: "Last used at" + follows-you: "Follows you" + mute: "Mute" + muted: "Muting" + unmute: "Unmute" + photos: title: "Photos" loading: "Loading" @@ -441,6 +507,7 @@ mobile: rename: "Rename" move: "Move" hash: "Hash (md5)" + exif: "EXIF" mk-entrance-signin: signup: "Sign up" @@ -490,7 +557,6 @@ mobile: applications: "Applications" twitter-integration: "Twitter integration" signin-history: "Sign in history" - api: "API" link: "MisskeyLink" settings: "Settings" signout: "Sign out" @@ -554,7 +620,6 @@ mobile: submit: "Post" reply-placeholder: "Reply to this post..." post-placeholder: "What's happening?" - attach-media-from-local: "Attach media from your device" mk-search-posts: empty: "There is no post related to the 「{}」" diff --git a/webpack/langs.ts b/locales/index.ts similarity index 85% rename from webpack/langs.ts rename to locales/index.ts index 409b25504..0593af366 100644 --- a/webpack/langs.ts +++ b/locales/index.ts @@ -10,12 +10,12 @@ const loadLang = lang => yaml.safeLoad( const native = loadLang('ja'); -const langs = Object.entries({ +const langs = { 'en': loadLang('en'), 'ja': native -}); +}; -langs.map(([, locale]) => { +Object.entries(langs).map(([, locale]) => { // Extend native language (Japanese) locale = Object.assign({}, native, locale); }); diff --git a/locales/ja.yml b/locales/ja.yml index dcf466339..70ff8739f 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -137,6 +137,7 @@ common: mk-signin: username: "ユーザー名" password: "パスワード" + token: "トークン" signing-in: "やってます..." signin: "サインイン" @@ -182,6 +183,24 @@ common: mk-uploader: waiting: "待機中" +docs: + edit-this-page-on-github: "間違いや改善点を見つけましたか?" + edit-this-page-on-github-link: "このページをGitHubで編集" + + api: + entities: + properties: "プロパティ" + endpoints: + params: "パラメータ" + res: "レスポンス" + props: + name: "名前" + type: "型" + optional: "オプション" + description: "説明" + yes: "はい" + no: "いいえ" + ch: tags: mk-index: @@ -198,7 +217,11 @@ ch: desktop: tags: mk-api-info: - regenerate-token: "パスワードを入力してください" + intro: "APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。" + caution: "アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。" + regeneration-of-token: "万が一このトークンが漏れたりその可能性がある場合はトークンを再生成できます。" + regenerate-token: "トークンを再生成" + enter-password: "パスワードを入力してください" mk-drive-browser-base-contextmenu: create-folder: "フォルダーを作成" @@ -287,6 +310,15 @@ desktop: mk-ui-header-notifications: title: "通知" + mk-profile-setting: + avatar: "アバター" + choice-avatar: "画像を選択" + name: "名前" + location: "場所" + description: "自己紹介" + birthday: "誕生日" + save: "保存" + mk-password-setting: reset: "パスワードを変更する" enter-current-password: "現在のパスワードを入力してください" @@ -295,6 +327,28 @@ desktop: not-match: "新しいパスワードが一致しません" changed: "パスワードを変更しました" + mk-2fa-setting: + intro: "二段階認証を設定すると、サインイン時にパスワードだけでなく、予め登録しておいた物理的なデバイス(例えばあなたのスマートフォンなど)も必要になり、よりセキュリティが向上します。" + detail: "詳細..." + url: "https://www.google.co.jp/intl/ja/landing/2step/" + caution: "登録したデバイスを紛失するなどした場合、Misskeyにサインインできなくなりますのでご注意ください。" + register: "デバイスを登録する" + already-registered: "既に設定は完了しています。" + unregister: "設定を解除" + unregistered: "二段階認証が無効になりました。" + enter-password: "パスワードを入力してください" + authenticator: "まず、Google Authenticatorをお使いのデバイスにインストールします:" + howtoinstall: "インストール方法はこちら" + scan: "次に、表示されているQRコードをスキャンします:" + done: "お使いのデバイスに表示されているトークンを入力して完了します:" + submit: "完了" + success: "設定が完了しました!" + failed: "設定に失敗しました。トークンに誤りがないかご確認ください。" + info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。" + + mk-mute-setting: + no-users: "ミュートしているユーザーはいません" + mk-post-form: post-placeholder: "いまどうしてる?" reply-placeholder: "この投稿への返信..." @@ -327,7 +381,14 @@ desktop: next: "次の投稿" mk-settings: + profile: "プロフィール" + mute: "ミュート" + drive: "ドライブ" + security: "セキュリティ" password: "パスワード" + 2fa: "二段階認証" + other: "その他" + license: "ライセンス" mk-timeline-post: reposted-by: "{}がRepost" @@ -416,6 +477,11 @@ desktop: mk-user: last-used-at: "最終アクセス" + follows-you: "フォローされています" + mute: "ミュートする" + muted: "ミュートしています" + unmute: "ミュート解除" + photos: title: "フォト" loading: "読み込み中" @@ -441,6 +507,7 @@ mobile: rename: "名前を変更" move: "移動" hash: "ハッシュ (md5)" + exif: "EXIF" mk-entrance-signin: signup: "新規登録" @@ -489,8 +556,7 @@ mobile: profile: "プロフィール" applications: "アプリケーション" twitter-integration: "Twitter連携" - signin-history: "ログイン履歴" - api: "API" + signin-history: "サインイン履歴" link: "Misskeyリンク" settings: "設定" signout: "サインアウト" @@ -554,7 +620,6 @@ mobile: submit: "投稿" reply-placeholder: "この投稿への返信..." post-placeholder: "いまどうしてる?" - attach-media-from-local: "デバイスからメディアを添付" mk-search-posts: empty: "「{}」に関する投稿は見つかりませんでした。" diff --git a/package.json b/package.json index 6de160288..bd5114480 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo <i@syuilo.com>", - "version": "0.0.3272", + "version": "0.0.3493", "license": "MIT", "description": "A miniblog-based SNS", "bugs": "https://github.com/syuilo/misskey/issues", @@ -22,131 +22,141 @@ "format": "gulp format" }, "dependencies": { - "@fortawesome/fontawesome": "^1.0.0", - "@fortawesome/fontawesome-free-brands": "^5.0.0", - "@fortawesome/fontawesome-free-regular": "^5.0.0", - "@fortawesome/fontawesome-free-solid": "^5.0.0", + "@fortawesome/fontawesome": "1.0.1", + "@fortawesome/fontawesome-free-brands": "5.0.2", + "@fortawesome/fontawesome-free-regular": "5.0.2", + "@fortawesome/fontawesome-free-solid": "5.0.2", "@prezzemolo/rap": "0.1.2", "@prezzemolo/zip": "0.0.3", "@types/bcryptjs": "2.4.1", "@types/body-parser": "1.16.8", - "@types/chai": "4.0.6", + "@types/chai": "4.1.2", "@types/chai-http": "3.0.3", "@types/compression": "0.0.35", "@types/cookie": "0.3.1", "@types/cors": "2.8.3", "@types/debug": "0.0.30", "@types/deep-equal": "1.0.1", - "@types/elasticsearch": "5.0.18", - "@types/event-stream": "3.3.33", + "@types/elasticsearch": "5.0.19", "@types/eventemitter3": "2.0.2", - "@types/express": "4.0.39", + "@types/express": "4.11.1", "@types/gm": "1.17.33", - "@types/gulp": "4.0.3", + "@types/gulp": "3.8.36", "@types/gulp-htmlmin": "1.3.31", "@types/gulp-mocha": "0.0.31", "@types/gulp-rename": "0.0.33", "@types/gulp-replace": "0.0.31", "@types/gulp-uglify": "3.0.3", "@types/gulp-util": "3.0.34", - "@types/inquirer": "0.0.35", + "@types/inquirer": "0.0.36", "@types/is-root": "1.0.0", "@types/is-url": "1.2.28", "@types/js-yaml": "3.10.1", - "@types/mocha": "2.2.44", - "@types/mongodb": "2.2.16", - "@types/monk": "1.0.6", + "@types/license-checker": "^15.0.0", + "@types/mkdirp": "0.5.2", + "@types/mocha": "2.2.47", + "@types/mongodb": "3.0.5", + "@types/monk": "6.0.0", "@types/morgan": "1.7.35", "@types/ms": "0.7.30", "@types/multer": "1.3.6", - "@types/node": "8.0.57", + "@types/node": "9.4.0", "@types/page": "1.5.32", "@types/proxy-addr": "2.0.0", + "@types/pug": "2.0.4", + "@types/qrcode": "0.8.0", "@types/ratelimiter": "2.1.28", - "@types/redis": "2.8.1", - "@types/request": "2.0.8", + "@types/redis": "2.8.5", + "@types/request": "2.47.0", "@types/rimraf": "2.0.2", "@types/riot": "3.6.1", "@types/seedrandom": "2.4.27", "@types/serve-favicon": "2.2.30", + "@types/speakeasy": "2.0.2", "@types/tmp": "0.0.33", "@types/uuid": "3.4.3", - "@types/webpack": "3.8.1", + "@types/webpack": "3.8.4", "@types/webpack-stream": "3.2.8", - "@types/websocket": "0.0.35", + "@types/websocket": "0.0.36", "accesses": "2.5.0", "animejs": "2.2.0", "autwh": "0.0.1", "awesome-typescript-loader": "3.4.1", "bcryptjs": "2.4.3", "body-parser": "1.18.2", - "cafy": "3.2.0", + "cafy": "3.2.1", "chai": "4.1.2", "chai-http": "3.0.0", "chalk": "2.3.0", "compression": "1.7.1", "cookie": "0.3.1", "cors": "2.8.4", - "cropperjs": "1.1.3", - "css-loader": "0.28.7", + "cropperjs": "1.2.2", + "css-loader": "0.28.9", "debug": "3.1.0", "deep-equal": "1.0.1", "deepcopy": "0.6.3", "diskusage": "0.2.4", - "elasticsearch": "14.0.0", + "elasticsearch": "14.1.0", "escape-regexp": "0.0.1", - "event-stream": "3.3.4", "eventemitter3": "3.0.0", + "exif-js": "2.3.0", "express": "4.16.2", - "file-type": "7.4.0", + "file-type": "7.5.0", "fuckadblock": "3.2.1", - "gm": "1.23.0", + "gm": "1.23.1", "gulp": "3.9.1", "gulp-cssnano": "2.1.2", - "gulp-htmlmin": "3.0.0", - "gulp-imagemin": "4.0.0", - "gulp-mocha": "4.3.1", + "gulp-htmlmin": "4.0.0", + "gulp-imagemin": "4.1.0", + "gulp-mocha": "5.0.0", "gulp-pug": "3.3.0", "gulp-rename": "1.2.2", "gulp-replace": "0.6.1", + "gulp-stylus": "2.7.0", "gulp-tslint": "8.1.2", - "gulp-typescript": "3.2.3", + "gulp-typescript": "3.2.4", "gulp-uglify": "3.0.0", "gulp-util": "3.0.8", - "inquirer": "4.0.1", + "highlight.js": "9.12.0", + "inquirer": "5.0.1", "is-root": "1.0.0", "is-url": "1.2.2", "js-yaml": "3.10.0", + "license-checker": "16.0.0", "mecab-async": "0.1.2", - "mocha": "4.0.1", + "mkdirp": "0.5.1", + "mocha": "5.0.0", "moji": "0.5.1", - "mongodb": "2.2.33", + "mongodb": "3.0.2", "monk": "6.0.5", "morgan": "1.9.0", "ms": "2.1.1", "multer": "1.3.0", "nprogress": "0.2.0", "os-utils": "0.0.14", - "page": "1.7.1", - "pictograph": "2.1.2", + "page": "1.8.3", + "pictograph": "2.1.5", "prominence": "0.2.0", "proxy-addr": "2.0.2", "pug": "2.0.0-rc.4", + "qrcode": "1.2.0", "ratelimiter": "3.0.3", "recaptcha-promise": "0.1.3", "reconnecting-websocket": "3.2.2", "redis": "2.8.0", "request": "2.83.0", "rimraf": "2.6.2", - "riot": "3.7.4", - "riot-tag-loader": "1.0.0", + "riot": "3.8.1", + "riot-tag-loader": "2.0.2", "rndstr": "1.0.0", - "s-age": "1.1.0", - "seedrandom": "^2.4.3", + "s-age": "1.1.2", + "seedrandom": "2.4.3", "serve-favicon": "2.4.5", "sortablejs": "1.7.0", + "speakeasy": "2.0.0", "string-replace-webpack-plugin": "0.1.3", - "style-loader": "0.19.0", + "style-loader": "0.20.1", "stylus": "0.54.5", "stylus-loader": "3.0.1", "summaly": "2.0.3", @@ -155,12 +165,12 @@ "tcp-port-used": "0.1.2", "textarea-caret": "3.0.2", "tmp": "0.0.33", - "ts-node": "3.3.0", - "tslint": "5.8.0", - "typescript": "2.6.2", - "uglify-es": "3.2.0", - "uglifyjs-webpack-plugin": "1.1.2", - "uuid": "3.1.0", + "ts-node": "4.1.0", + "tslint": "5.9.1", + "typescript": "2.7.1", + "uglify-es": "3.3.9", + "uglifyjs-webpack-plugin": "1.1.8", + "uuid": "3.2.1", "vhost": "3.0.2", "web-push": "3.2.5", "webpack": "3.10.0", diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts index 2a649788a..1ee455c09 100644 --- a/src/api/common/add-file-to-drive.ts +++ b/src/api/common/add-file-to-drive.ts @@ -5,17 +5,21 @@ import * as stream from 'stream'; import * as mongodb from 'mongodb'; import * as crypto from 'crypto'; -import * as gm from 'gm'; +import * as _gm from 'gm'; import * as debug from 'debug'; import fileType = require('file-type'); import prominence = require('prominence'); import DriveFile, { getGridFSBucket } from '../models/drive-file'; import DriveFolder from '../models/drive-folder'; -import serialize from '../serializers/drive-file'; +import { pack } from '../models/drive-file'; import event, { publishDriveStream } from '../event'; import config from '../../conf'; +const gm = _gm.subClass({ + imageMagick: true +}); + const log = debug('misskey:register-drive-file'); const tmpFile = (): Promise<string> => new Promise((resolve, reject) => { @@ -106,8 +110,32 @@ const addFile = async ( } } - const [properties, folder] = await Promise.all([ - // properties + const [wh, averageColor, folder] = await Promise.all([ + // Width and height (when image) + (async () => { + // 画像かどうか + if (!/^image\/.*$/.test(mime)) { + return null; + } + + const imageType = mime.split('/')[1]; + + // 画像でもPNGかJPEGかGIFでないならスキップ + if (imageType != 'png' && imageType != 'jpeg' && imageType != 'gif') { + return null; + } + + log('calculate image width and height...'); + + // Calculate width and height + const g = gm(fs.createReadStream(path), name); + const size = await prominence(g).size(); + + log(`image width and height is calculated: ${size.width}, ${size.height}`); + + return [size.width, size.height]; + })(), + // average color (when image) (async () => { // 画像かどうか if (!/^image\/.*$/.test(mime)) { @@ -121,17 +149,20 @@ const addFile = async ( return null; } - // If the file is an image, calculate width and height to save in property - const g = gm(fs.createReadStream(path), name); - const size = await prominence(g).size(); - const properties = { - width: size.width, - height: size.height - }; + log('calculate average color...'); - log('image width and height is calculated'); + const buffer = await prominence(gm(fs.createReadStream(path), name) + .setFormat('ppm') + .resize(1, 1)) // 1pxのサイズに縮小して平均色を取得するというハック + .toBuffer(); - return properties; + const r = buffer.readUInt8(buffer.length - 3); + const g = buffer.readUInt8(buffer.length - 2); + const b = buffer.readUInt8(buffer.length - 1); + + log(`average color is calculated: ${r}, ${g}, ${b}`); + + return [r, g, b]; })(), // folder (async () => { @@ -181,6 +212,17 @@ const addFile = async ( const readable = fs.createReadStream(path); + const properties = {}; + + if (wh) { + properties['width'] = wh[0]; + properties['height'] = wh[1]; + } + + if (averageColor) { + properties['average_color'] = averageColor; + } + return addToGridFS(detectedName, readable, mime, { user_id: user._id, folder_id: folder !== null ? folder._id : null, @@ -224,11 +266,11 @@ export default (user: any, file: string | stream.Readable, ...args) => new Promi } rej(new Error('un-compatible file.')); }) - .then(([path, remove]): Promise<any> => new Promise((res, rej) => { + .then(([path, shouldCleanup]): Promise<any> => new Promise((res, rej) => { addFile(user, path, ...args) .then(file => { res(file); - if (remove) { + if (shouldCleanup) { fs.unlink(path, (e) => { if (e) log(e.stack); }); @@ -240,7 +282,7 @@ export default (user: any, file: string | stream.Readable, ...args) => new Promi log(`drive file has been created ${file._id}`); resolve(file); - serialize(file).then(serializedFile => { + pack(file).then(serializedFile => { // Publish drive_file_created event event(user._id, 'drive_file_created', serializedFile); publishDriveStream(user._id, 'file_created', serializedFile); diff --git a/src/api/common/notify.ts b/src/api/common/notify.ts index 4b3e6a5d5..ae5669b84 100644 --- a/src/api/common/notify.ts +++ b/src/api/common/notify.ts @@ -1,7 +1,8 @@ import * as mongo from 'mongodb'; import Notification from '../models/notification'; +import Mute from '../models/mute'; import event from '../event'; -import serialize from '../serializers/notification'; +import { pack } from '../models/notification'; export default ( notifiee: mongo.ObjectID, @@ -26,13 +27,24 @@ export default ( // Publish notification event event(notifiee, 'notification', - await serialize(notification)); + await pack(notification)); // 3秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する setTimeout(async () => { const fresh = await Notification.findOne({ _id: notification._id }, { is_read: true }); if (!fresh.is_read) { - event(notifiee, 'unread_notification', await serialize(notification)); + //#region ただしミュートしているユーザーからの通知なら無視 + const mute = await Mute.find({ + muter_id: notifiee, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id.toString()); + if (mutedUserIds.indexOf(notifier.toString()) != -1) { + return; + } + //#endregion + + event(notifiee, 'unread_notification', await pack(notification)); } }, 3000); }); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 06fb9a64a..e84638157 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -155,6 +155,21 @@ const endpoints: Endpoint[] = [ name: 'i', withCredential: true }, + { + name: 'i/2fa/register', + withCredential: true, + secure: true + }, + { + name: 'i/2fa/unregister', + withCredential: true, + secure: true + }, + { + name: 'i/2fa/done', + withCredential: true, + secure: true + }, { name: 'i/update', withCredential: true, @@ -171,11 +186,13 @@ const endpoints: Endpoint[] = [ }, { name: 'i/change_password', - withCredential: true + withCredential: true, + secure: true }, { name: 'i/regenerate_token', - withCredential: true + withCredential: true, + secure: true }, { name: 'i/pin', @@ -205,6 +222,23 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-read' }, + + { + name: 'mute/create', + withCredential: true, + kind: 'account/write' + }, + { + name: 'mute/delete', + withCredential: true, + kind: 'account/write' + }, + { + name: 'mute/list', + withCredential: true, + kind: 'account/read' + }, + { name: 'notifications/get_unread_count', withCredential: true, diff --git a/src/api/endpoints/aggregation/posts/reactions.ts b/src/api/endpoints/aggregation/posts/reactions.ts index 2cd4588ae..790b523be 100644 --- a/src/api/endpoints/aggregation/posts/reactions.ts +++ b/src/api/endpoints/aggregation/posts/reactions.ts @@ -35,10 +35,13 @@ module.exports = (params) => new Promise(async (res, rej) => { { deleted_at: { $gt: startTime } } ] }, { - _id: false, - post_id: false - }, { - sort: { created_at: -1 } + sort: { + _id: -1 + }, + fields: { + _id: false, + post_id: false + } }); const graph = []; diff --git a/src/api/endpoints/aggregation/users.ts b/src/api/endpoints/aggregation/users.ts index 9eb2d035e..e38ce92ff 100644 --- a/src/api/endpoints/aggregation/users.ts +++ b/src/api/endpoints/aggregation/users.ts @@ -17,11 +17,14 @@ module.exports = params => new Promise(async (res, rej) => { const users = await User .find({}, { - _id: false, - created_at: true, - deleted_at: true - }, { - sort: { created_at: -1 } + sort: { + _id: -1 + }, + fields: { + _id: false, + created_at: true, + deleted_at: true + } }); const graph = []; diff --git a/src/api/endpoints/app/create.ts b/src/api/endpoints/app/create.ts index ca684de02..0f688792a 100644 --- a/src/api/endpoints/app/create.ts +++ b/src/api/endpoints/app/create.ts @@ -3,9 +3,7 @@ */ import rndstr from 'rndstr'; import $ from 'cafy'; -import App from '../../models/app'; -import { isValidNameId } from '../../models/app'; -import serialize from '../../serializers/app'; +import App, { isValidNameId, pack } from '../../models/app'; /** * @swagger @@ -106,5 +104,5 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { }); // Response - res(await serialize(app)); + res(await pack(app)); }); diff --git a/src/api/endpoints/app/show.ts b/src/api/endpoints/app/show.ts index 054aab859..8bc3dda42 100644 --- a/src/api/endpoints/app/show.ts +++ b/src/api/endpoints/app/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import App from '../../models/app'; -import serialize from '../../serializers/app'; +import App, { pack } from '../../models/app'; /** * @swagger @@ -67,7 +66,7 @@ module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) => } // Send response - res(await serialize(app, user, { + res(await pack(app, user, { includeSecret: isSecure && app.user_id.equals(user._id) })); }); diff --git a/src/api/endpoints/auth/session/show.ts b/src/api/endpoints/auth/session/show.ts index ede8a6763..73ac3185f 100644 --- a/src/api/endpoints/auth/session/show.ts +++ b/src/api/endpoints/auth/session/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import AuthSess from '../../../models/auth-session'; -import serialize from '../../../serializers/auth-session'; +import AuthSess, { pack } from '../../../models/auth-session'; /** * @swagger @@ -67,5 +66,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Response - res(await serialize(session, user)); + res(await pack(session, user)); }); diff --git a/src/api/endpoints/auth/session/userkey.ts b/src/api/endpoints/auth/session/userkey.ts index afd3250b0..fc989bf8c 100644 --- a/src/api/endpoints/auth/session/userkey.ts +++ b/src/api/endpoints/auth/session/userkey.ts @@ -5,7 +5,7 @@ import $ from 'cafy'; import App from '../../../models/app'; import AuthSess from '../../../models/auth-session'; import AccessToken from '../../../models/access-token'; -import serialize from '../../../serializers/user'; +import { pack } from '../../../models/user'; /** * @swagger @@ -102,7 +102,7 @@ module.exports = (params) => new Promise(async (res, rej) => { // Response res({ access_token: accessToken.token, - user: await serialize(session.user_id, null, { + user: await pack(session.user_id, null, { detail: true }) }); diff --git a/src/api/endpoints/channels.ts b/src/api/endpoints/channels.ts index e10c94389..b9a7d1b78 100644 --- a/src/api/endpoints/channels.ts +++ b/src/api/endpoints/channels.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Channel from '../models/channel'; -import serialize from '../serializers/channel'; +import Channel, { pack } from '../models/channel'; /** * Get all channels @@ -21,13 +20,13 @@ module.exports = (params, me) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Construct query @@ -40,9 +39,9 @@ module.exports = (params, me) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -55,5 +54,5 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(channels.map(async channel => - await serialize(channel, me)))); + await pack(channel, me)))); }); diff --git a/src/api/endpoints/channels/create.ts b/src/api/endpoints/channels/create.ts index a8d7c29dc..695b4515b 100644 --- a/src/api/endpoints/channels/create.ts +++ b/src/api/endpoints/channels/create.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import Channel from '../../models/channel'; import Watching from '../../models/channel-watching'; -import serialize from '../../serializers/channel'; +import { pack } from '../../models/channel'; /** * Create a channel @@ -28,7 +28,7 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { }); // Response - res(await serialize(channel)); + res(await pack(channel)); // Create Watching await Watching.insert({ diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts index 5c071a124..d722589c2 100644 --- a/src/api/endpoints/channels/posts.ts +++ b/src/api/endpoints/channels/posts.ts @@ -3,8 +3,7 @@ */ import $ from 'cafy'; import { default as Channel, IChannel } from '../../models/channel'; -import Post from '../../models/post'; -import serialize from '../../serializers/post'; +import Post, { pack } from '../../models/post'; /** * Show a posts of a channel @@ -22,13 +21,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Get 'channel_id' parameter @@ -58,9 +57,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } //#endregion Construct query @@ -74,6 +73,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(posts.map(async (post) => - await serialize(post, user) + await pack(post, user) ))); }); diff --git a/src/api/endpoints/channels/show.ts b/src/api/endpoints/channels/show.ts index 8861e5459..332da6467 100644 --- a/src/api/endpoints/channels/show.ts +++ b/src/api/endpoints/channels/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import { default as Channel, IChannel } from '../../models/channel'; -import serialize from '../../serializers/channel'; +import Channel, { IChannel, pack } from '../../models/channel'; /** * Show a channel @@ -27,5 +26,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Serialize - res(await serialize(channel, user)); + res(await pack(channel, user)); }); diff --git a/src/api/endpoints/drive/files.ts b/src/api/endpoints/drive/files.ts index b2e094775..89915331e 100644 --- a/src/api/endpoints/drive/files.ts +++ b/src/api/endpoints/drive/files.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFile from '../../models/drive-file'; -import serialize from '../../serializers/drive-file'; +import DriveFile, { pack } from '../../models/drive-file'; /** * Get drive files @@ -22,13 +21,13 @@ module.exports = async (params, user, app) => { const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; if (sinceIdErr) throw 'invalid since_id param'; - // Get 'max_id' parameter - const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) throw 'invalid max_id param'; + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) throw 'invalid until_id param'; - // Check if both of since_id and max_id is specified - if (sinceId && maxId) { - throw 'cannot set since_id and max_id'; + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + throw 'cannot set since_id and until_id'; } // Get 'folder_id' parameter @@ -52,9 +51,9 @@ module.exports = async (params, user, app) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } if (type) { @@ -69,6 +68,6 @@ module.exports = async (params, user, app) => { }); // Serialize - const _files = await Promise.all(files.map(file => serialize(file))); + const _files = await Promise.all(files.map(file => pack(file))); return _files; }; diff --git a/src/api/endpoints/drive/files/create.ts b/src/api/endpoints/drive/files/create.ts index 7546eca30..96bcace88 100644 --- a/src/api/endpoints/drive/files/create.ts +++ b/src/api/endpoints/drive/files/create.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import { validateFileName } from '../../../models/drive-file'; -import serialize from '../../../serializers/drive-file'; +import { validateFileName, pack } from '../../../models/drive-file'; import create from '../../../common/add-file-to-drive'; /** @@ -38,9 +37,15 @@ module.exports = async (file, params, user): Promise<any> => { const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; if (folderIdErr) throw 'invalid folder_id param'; - // Create file - const driveFile = await create(user, file.path, name, null, folderId); + try { + // Create file + const driveFile = await create(user, file.path, name, null, folderId); - // Serialize - return serialize(driveFile); + // Serialize + return pack(driveFile); + } catch (e) { + console.error(e); + + throw e; + } }; diff --git a/src/api/endpoints/drive/files/find.ts b/src/api/endpoints/drive/files/find.ts index a1cdf1643..e026afe93 100644 --- a/src/api/endpoints/drive/files/find.ts +++ b/src/api/endpoints/drive/files/find.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFile from '../../../models/drive-file'; -import serialize from '../../../serializers/drive-file'; +import DriveFile, { pack } from '../../../models/drive-file'; /** * Find a file(s) @@ -31,5 +30,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(files.map(async file => - await serialize(file)))); + await pack(file)))); }); diff --git a/src/api/endpoints/drive/files/show.ts b/src/api/endpoints/drive/files/show.ts index 3c7cf774f..21664f7ba 100644 --- a/src/api/endpoints/drive/files/show.ts +++ b/src/api/endpoints/drive/files/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFile from '../../../models/drive-file'; -import serialize from '../../../serializers/drive-file'; +import DriveFile, { pack } from '../../../models/drive-file'; /** * Show a file @@ -29,7 +28,7 @@ module.exports = async (params, user) => { } // Serialize - const _file = await serialize(file, { + const _file = await pack(file, { detail: true }); diff --git a/src/api/endpoints/drive/files/update.ts b/src/api/endpoints/drive/files/update.ts index f39a420d6..83da46211 100644 --- a/src/api/endpoints/drive/files/update.ts +++ b/src/api/endpoints/drive/files/update.ts @@ -3,9 +3,7 @@ */ import $ from 'cafy'; import DriveFolder from '../../../models/drive-folder'; -import DriveFile from '../../../models/drive-file'; -import { validateFileName } from '../../../models/drive-file'; -import serialize from '../../../serializers/drive-file'; +import DriveFile, { validateFileName, pack } from '../../../models/drive-file'; import { publishDriveStream } from '../../../event'; /** @@ -67,7 +65,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - const fileObj = await serialize(file); + const fileObj = await pack(file); // Response res(fileObj); diff --git a/src/api/endpoints/drive/files/upload_from_url.ts b/src/api/endpoints/drive/files/upload_from_url.ts index 519e0bdf6..68428747e 100644 --- a/src/api/endpoints/drive/files/upload_from_url.ts +++ b/src/api/endpoints/drive/files/upload_from_url.ts @@ -3,8 +3,7 @@ */ import * as URL from 'url'; import $ from 'cafy'; -import { validateFileName } from '../../../models/drive-file'; -import serialize from '../../../serializers/drive-file'; +import { validateFileName, pack } from '../../../models/drive-file'; import create from '../../../common/add-file-to-drive'; import * as debug from 'debug'; import * as tmp from 'tmp'; @@ -63,5 +62,5 @@ module.exports = async (params, user): Promise<any> => { if (e) log(e.stack); }); - return serialize(driveFile); + return pack(driveFile); }; diff --git a/src/api/endpoints/drive/folders.ts b/src/api/endpoints/drive/folders.ts index d49ef0af0..428bde350 100644 --- a/src/api/endpoints/drive/folders.ts +++ b/src/api/endpoints/drive/folders.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFolder from '../../models/drive-folder'; -import serialize from '../../serializers/drive-folder'; +import DriveFolder, { pack } from '../../models/drive-folder'; /** * Get drive folders @@ -22,13 +21,13 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Get 'folder_id' parameter @@ -48,9 +47,9 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -63,5 +62,5 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(folders.map(async folder => - await serialize(folder)))); + await pack(folder)))); }); diff --git a/src/api/endpoints/drive/folders/create.ts b/src/api/endpoints/drive/folders/create.ts index be847b215..03f396ddc 100644 --- a/src/api/endpoints/drive/folders/create.ts +++ b/src/api/endpoints/drive/folders/create.ts @@ -2,9 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFolder from '../../../models/drive-folder'; -import { isValidFolderName } from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-folder'; +import DriveFolder, { isValidFolderName, pack } from '../../../models/drive-folder'; import { publishDriveStream } from '../../../event'; /** @@ -47,7 +45,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - const folderObj = await serialize(folder); + const folderObj = await pack(folder); // Response res(folderObj); diff --git a/src/api/endpoints/drive/folders/find.ts b/src/api/endpoints/drive/folders/find.ts index a5eb8e015..fc84766bc 100644 --- a/src/api/endpoints/drive/folders/find.ts +++ b/src/api/endpoints/drive/folders/find.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFolder from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-folder'; +import DriveFolder, { pack } from '../../../models/drive-folder'; /** * Find a folder(s) @@ -30,5 +29,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(folders.map(folder => serialize(folder)))); + res(await Promise.all(folders.map(folder => pack(folder)))); }); diff --git a/src/api/endpoints/drive/folders/show.ts b/src/api/endpoints/drive/folders/show.ts index 9b1c04ca3..e07d14d20 100644 --- a/src/api/endpoints/drive/folders/show.ts +++ b/src/api/endpoints/drive/folders/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFolder from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-folder'; +import DriveFolder, { pack } from '../../../models/drive-folder'; /** * Show a folder @@ -29,7 +28,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Serialize - res(await serialize(folder, { + res(await pack(folder, { detail: true })); }); diff --git a/src/api/endpoints/drive/folders/update.ts b/src/api/endpoints/drive/folders/update.ts index ff673402a..d3df8bdae 100644 --- a/src/api/endpoints/drive/folders/update.ts +++ b/src/api/endpoints/drive/folders/update.ts @@ -2,9 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFolder from '../../../models/drive-folder'; -import { isValidFolderName } from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-folder'; +import DriveFolder, { isValidFolderName, pack } from '../../../models/drive-folder'; import { publishDriveStream } from '../../../event'; /** @@ -91,7 +89,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - const folderObj = await serialize(folder); + const folderObj = await pack(folder); // Response res(folderObj); diff --git a/src/api/endpoints/drive/stream.ts b/src/api/endpoints/drive/stream.ts index 7ee255e5d..8352c7dd4 100644 --- a/src/api/endpoints/drive/stream.ts +++ b/src/api/endpoints/drive/stream.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import DriveFile from '../../models/drive-file'; -import serialize from '../../serializers/drive-file'; +import DriveFile, { pack } from '../../models/drive-file'; /** * Get drive stream @@ -21,13 +20,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Get 'type' parameter @@ -46,9 +45,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } if (type) { @@ -64,5 +63,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(files.map(async file => - await serialize(file)))); + await pack(file)))); }); diff --git a/src/api/endpoints/following/create.ts b/src/api/endpoints/following/create.ts index b4a2217b1..8e1aa3471 100644 --- a/src/api/endpoints/following/create.ts +++ b/src/api/endpoints/following/create.ts @@ -2,11 +2,10 @@ * Module dependencies */ import $ from 'cafy'; -import User from '../../models/user'; +import User, { pack as packUser } from '../../models/user'; import Following from '../../models/following'; import notify from '../../common/notify'; import event from '../../event'; -import serializeUser from '../../serializers/user'; /** * Follow a user @@ -77,8 +76,8 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Publish follow event - event(follower._id, 'follow', await serializeUser(followee, follower)); - event(followee._id, 'followed', await serializeUser(follower, followee)); + event(follower._id, 'follow', await packUser(followee, follower)); + event(followee._id, 'followed', await packUser(follower, followee)); // Notify notify(followee._id, follower._id, 'follow'); diff --git a/src/api/endpoints/following/delete.ts b/src/api/endpoints/following/delete.ts index aa1639ef6..b68cec09d 100644 --- a/src/api/endpoints/following/delete.ts +++ b/src/api/endpoints/following/delete.ts @@ -2,10 +2,9 @@ * Module dependencies */ import $ from 'cafy'; -import User from '../../models/user'; +import User, { pack as packUser } from '../../models/user'; import Following from '../../models/following'; import event from '../../event'; -import serializeUser from '../../serializers/user'; /** * Unfollow a user @@ -78,5 +77,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Publish follow event - event(follower._id, 'unfollow', await serializeUser(followee, follower)); + event(follower._id, 'unfollow', await packUser(followee, follower)); }); diff --git a/src/api/endpoints/i.ts b/src/api/endpoints/i.ts index ae75f11d5..7efdbcd7c 100644 --- a/src/api/endpoints/i.ts +++ b/src/api/endpoints/i.ts @@ -1,8 +1,7 @@ /** * Module dependencies */ -import User from '../models/user'; -import serialize from '../serializers/user'; +import User, { pack } from '../models/user'; /** * Show myself @@ -15,7 +14,7 @@ import serialize from '../serializers/user'; */ module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) => { // Serialize - res(await serialize(user, user, { + res(await pack(user, user, { detail: true, includeSecrets: isSecure })); diff --git a/src/api/endpoints/i/2fa/done.ts b/src/api/endpoints/i/2fa/done.ts new file mode 100644 index 000000000..0b36033bb --- /dev/null +++ b/src/api/endpoints/i/2fa/done.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import User from '../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + const _token = token.replace(/\s/g, ''); + + if (user.two_factor_temp_secret == null) { + return rej('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: user.two_factor_temp_secret, + encoding: 'base32', + token: _token + }); + + if (!verified) { + return rej('not verified'); + } + + await User.update(user._id, { + $set: { + two_factor_secret: user.two_factor_temp_secret, + two_factor_enabled: true + } + }); + + res(); +}); diff --git a/src/api/endpoints/i/2fa/register.ts b/src/api/endpoints/i/2fa/register.ts new file mode 100644 index 000000000..c2b5037a2 --- /dev/null +++ b/src/api/endpoints/i/2fa/register.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import User from '../../../models/user'; +import config from '../../../../conf'; + +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 = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await User.update(user._id, { + $set: { + two_factor_temp_secret: secret.base32 + } + }); + + // Get the data URL of the authenticator URL + QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + }), (err, data_url) => { + res({ + qr: data_url, + secret: secret.base32, + label: user.username, + issuer: config.host + }); + }); +}); diff --git a/src/api/endpoints/i/2fa/unregister.ts b/src/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 000000000..6bee6a26f --- /dev/null +++ b/src/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../models/user'; + +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 = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + await User.update(user._id, { + $set: { + two_factor_secret: null, + two_factor_enabled: false + } + }); + + res(); +}); diff --git a/src/api/endpoints/i/authorized_apps.ts b/src/api/endpoints/i/authorized_apps.ts index 807ca5b1e..40ce7a68c 100644 --- a/src/api/endpoints/i/authorized_apps.ts +++ b/src/api/endpoints/i/authorized_apps.ts @@ -3,7 +3,7 @@ */ import $ from 'cafy'; import AccessToken from '../../models/access-token'; -import serialize from '../../serializers/app'; +import { pack } from '../../models/app'; /** * Get authorized apps of my account @@ -39,5 +39,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(tokens.map(async token => - await serialize(token.app_id)))); + await pack(token.app_id)))); }); diff --git a/src/api/endpoints/i/favorites.ts b/src/api/endpoints/i/favorites.ts index a66eaa754..eb464cf0f 100644 --- a/src/api/endpoints/i/favorites.ts +++ b/src/api/endpoints/i/favorites.ts @@ -3,7 +3,7 @@ */ import $ from 'cafy'; import Favorite from '../../models/favorite'; -import serialize from '../../serializers/post'; +import { pack } from '../../models/post'; /** * Get followers of a user @@ -39,6 +39,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(favorites.map(async favorite => - await serialize(favorite.post) + await pack(favorite.post) ))); }); diff --git a/src/api/endpoints/i/notifications.ts b/src/api/endpoints/i/notifications.ts index 607e0768a..688039a0d 100644 --- a/src/api/endpoints/i/notifications.ts +++ b/src/api/endpoints/i/notifications.ts @@ -3,7 +3,8 @@ */ import $ from 'cafy'; import Notification from '../../models/notification'; -import serialize from '../../serializers/notification'; +import Mute from '../../models/mute'; +import { pack } from '../../models/notification'; import getFriends from '../../common/get-friends'; import read from '../../common/read-notification'; @@ -36,17 +37,27 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const query = { - notifiee_id: user._id + notifiee_id: user._id, + $and: [{ + notifier_id: { + $nin: mute.map(m => m.mutee_id) + } + }] } as any; const sort = { @@ -54,12 +65,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }; if (following) { - // ID list of the user $self and other users who the user follows + // ID list of the user itself and other users who the user follows const followingIds = await getFriends(user._id); - query.notifier_id = { - $in: followingIds - }; + query.$and.push({ + notifier_id: { + $in: followingIds + } + }); } if (type) { @@ -73,9 +86,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -88,7 +101,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(notifications.map(async notification => - await serialize(notification)))); + await pack(notification)))); // Mark as read all if (notifications.length > 0 && markAsRead) { diff --git a/src/api/endpoints/i/pin.ts b/src/api/endpoints/i/pin.ts index a94950d22..ff546fc2b 100644 --- a/src/api/endpoints/i/pin.ts +++ b/src/api/endpoints/i/pin.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import User from '../../models/user'; import Post from '../../models/post'; -import serialize from '../../serializers/user'; +import { pack } from '../../models/user'; /** * Pin post @@ -35,7 +35,7 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { }); // Serialize - const iObj = await serialize(user, user, { + const iObj = await pack(user, user, { detail: true }); diff --git a/src/api/endpoints/i/signin_history.ts b/src/api/endpoints/i/signin_history.ts index 1a6e50c7c..859e81653 100644 --- a/src/api/endpoints/i/signin_history.ts +++ b/src/api/endpoints/i/signin_history.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Signin from '../../models/signin'; -import serialize from '../../serializers/signin'; +import Signin, { pack } from '../../models/signin'; /** * Get signin history of my account @@ -21,13 +20,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } const query = { @@ -43,9 +42,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -58,5 +57,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(history.map(async record => - await serialize(record)))); + await pack(record)))); }); diff --git a/src/api/endpoints/i/update.ts b/src/api/endpoints/i/update.ts index c484c51a9..7bbbf9590 100644 --- a/src/api/endpoints/i/update.ts +++ b/src/api/endpoints/i/update.ts @@ -2,9 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import User from '../../models/user'; -import { isValidName, isValidDescription, isValidLocation, isValidBirthday } from '../../models/user'; -import serialize from '../../serializers/user'; +import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../models/user'; import event from '../../event'; import config from '../../../conf'; @@ -65,7 +63,7 @@ module.exports = async (params, user, _, isSecure) => new Promise(async (res, re }); // Serialize - const iObj = await serialize(user, user, { + const iObj = await pack(user, user, { detail: true, includeSecrets: isSecure }); diff --git a/src/api/endpoints/messaging/history.ts b/src/api/endpoints/messaging/history.ts index 5f7c9276d..1683ca7a8 100644 --- a/src/api/endpoints/messaging/history.ts +++ b/src/api/endpoints/messaging/history.ts @@ -3,7 +3,8 @@ */ import $ from 'cafy'; import History from '../../models/messaging-history'; -import serialize from '../../serializers/messaging-message'; +import Mute from '../../models/mute'; +import { pack } from '../../models/messaging-message'; /** * Show messaging history @@ -17,10 +18,18 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; if (limitErr) return rej('invalid limit param'); + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + // Get history const history = await History .find({ - user_id: user._id + user_id: user._id, + partner: { + $nin: mute.map(m => m.mutee_id) + } }, { limit: limit, sort: { @@ -30,5 +39,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(history.map(async h => - await serialize(h.message, user)))); + await pack(h.message, user)))); }); diff --git a/src/api/endpoints/messaging/messages.ts b/src/api/endpoints/messaging/messages.ts index 7b270924e..67ba5e9d6 100644 --- a/src/api/endpoints/messaging/messages.ts +++ b/src/api/endpoints/messaging/messages.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import Message from '../../models/messaging-message'; import User from '../../models/user'; -import serialize from '../../serializers/messaging-message'; +import { pack } from '../../models/messaging-message'; import read from '../../common/read-messaging-message'; /** @@ -44,13 +44,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } const query = { @@ -72,9 +72,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -87,7 +87,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(messages.map(async message => - await serialize(message, user, { + await pack(message, user, { populateRecipient: false })))); diff --git a/src/api/endpoints/messaging/messages/create.ts b/src/api/endpoints/messaging/messages/create.ts index 3c7689f96..1b8a5f59e 100644 --- a/src/api/endpoints/messaging/messages/create.ts +++ b/src/api/endpoints/messaging/messages/create.ts @@ -6,8 +6,9 @@ import Message from '../../../models/messaging-message'; import { isValidText } from '../../../models/messaging-message'; import History from '../../../models/messaging-history'; import User from '../../../models/user'; +import Mute from '../../../models/mute'; import DriveFile from '../../../models/drive-file'; -import serialize from '../../../serializers/messaging-message'; +import { pack } from '../../../models/messaging-message'; import publishUserStream from '../../../event'; import { publishMessagingStream, publishMessagingIndexStream, pushSw } from '../../../event'; import config from '../../../../conf'; @@ -78,7 +79,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - const messageObj = await serialize(message); + const messageObj = await pack(message); // Reponse res(messageObj); @@ -97,6 +98,17 @@ module.exports = (params, user) => new Promise(async (res, rej) => { setTimeout(async () => { const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); if (!freshMessage.is_read) { + //#region ただしミュートされているなら発行しない + const mute = await Mute.find({ + muter_id: recipient._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id.toString()); + if (mutedUserIds.indexOf(user._id.toString()) != -1) { + return; + } + //#endregion + publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); pushSw(message.recipient_id, 'unread_messaging_message', messageObj); } diff --git a/src/api/endpoints/messaging/unread.ts b/src/api/endpoints/messaging/unread.ts index 40bc83fe1..c4326e1d2 100644 --- a/src/api/endpoints/messaging/unread.ts +++ b/src/api/endpoints/messaging/unread.ts @@ -2,6 +2,7 @@ * Module dependencies */ import Message from '../../models/messaging-message'; +import Mute from '../../models/mute'; /** * Get count of unread messages @@ -11,8 +12,17 @@ import Message from '../../models/messaging-message'; * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id); + const count = await Message .count({ + user_id: { + $nin: mutedUserIds + }, recipient_id: user._id, is_read: false }); diff --git a/src/api/endpoints/mute/create.ts b/src/api/endpoints/mute/create.ts new file mode 100644 index 000000000..f44854ab5 --- /dev/null +++ b/src/api/endpoints/mute/create.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Mute from '../../models/mute'; + +/** + * Mute a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check if already muting + const exist = await Mute.findOne({ + muter_id: muter._id, + mutee_id: mutee._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already muting'); + } + + // Create mute + await Mute.insert({ + created_at: new Date(), + muter_id: muter._id, + mutee_id: mutee._id, + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/mute/delete.ts b/src/api/endpoints/mute/delete.ts new file mode 100644 index 000000000..d6bff3353 --- /dev/null +++ b/src/api/endpoints/mute/delete.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Mute from '../../models/mute'; + +/** + * Unmute a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Check if the mutee is yourself + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check not muting + const exist = await Mute.findOne({ + muter_id: muter._id, + mutee_id: mutee._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not muting'); + } + + // Delete mute + await Mute.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/mute/list.ts b/src/api/endpoints/mute/list.ts new file mode 100644 index 000000000..19e3b157e --- /dev/null +++ b/src/api/endpoints/mute/list.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Mute from '../../models/mute'; +import { pack } from '../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get muted users of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 30, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Construct query + const query = { + muter_id: me._id, + deleted_at: { $exists: false } + } as any; + + if (iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.mutee_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get mutes + const mutes = await Mute + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = mutes.length === limit + 1; + if (inStock) { + mutes.pop(); + } + + // Serialize + const users = await Promise.all(mutes.map(async m => + await pack(m.mutee_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? mutes[mutes.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/my/apps.ts b/src/api/endpoints/my/apps.ts index eb9c75876..b23619050 100644 --- a/src/api/endpoints/my/apps.ts +++ b/src/api/endpoints/my/apps.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import App from '../../models/app'; -import serialize from '../../serializers/app'; +import App, { pack } from '../../models/app'; /** * Get my apps @@ -37,5 +36,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Reply res(await Promise.all(apps.map(async app => - await serialize(app)))); + await pack(app)))); }); diff --git a/src/api/endpoints/notifications/get_unread_count.ts b/src/api/endpoints/notifications/get_unread_count.ts index 9514e7871..845d6b29c 100644 --- a/src/api/endpoints/notifications/get_unread_count.ts +++ b/src/api/endpoints/notifications/get_unread_count.ts @@ -2,6 +2,7 @@ * Module dependencies */ import Notification from '../../models/notification'; +import Mute from '../../models/mute'; /** * Get count of unread notifications @@ -11,9 +12,18 @@ import Notification from '../../models/notification'; * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id); + const count = await Notification .count({ notifiee_id: user._id, + notifier_id: { + $nin: mutedUserIds + }, is_read: false }); diff --git a/src/api/endpoints/posts.ts b/src/api/endpoints/posts.ts index f6efcc108..3b2942592 100644 --- a/src/api/endpoints/posts.ts +++ b/src/api/endpoints/posts.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Post from '../models/post'; -import serialize from '../serializers/post'; +import Post, { pack } from '../models/post'; /** * Lists all posts @@ -36,13 +35,13 @@ module.exports = (params) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Construct query @@ -55,9 +54,9 @@ module.exports = (params) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -85,5 +84,5 @@ module.exports = (params) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(posts.map(async post => await serialize(post)))); + res(await Promise.all(posts.map(async post => await pack(post)))); }); diff --git a/src/api/endpoints/posts/context.ts b/src/api/endpoints/posts/context.ts index bad59a6be..5ba375897 100644 --- a/src/api/endpoints/posts/context.ts +++ b/src/api/endpoints/posts/context.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Post from '../../models/post'; -import serialize from '../../serializers/post'; +import Post, { pack } from '../../models/post'; /** * Show a context of a post @@ -60,5 +59,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(context.map(async post => - await serialize(post, user)))); + await pack(post, user)))); }); diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts index ae4959dae..0fa52221f 100644 --- a/src/api/endpoints/posts/create.ts +++ b/src/api/endpoints/posts/create.ts @@ -8,10 +8,11 @@ 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 Mute from '../../models/mute'; import DriveFile from '../../models/drive-file'; import Watching from '../../models/post-watching'; import ChannelWatching from '../../models/channel-watching'; -import serialize from '../../serializers/post'; +import { pack } from '../../models/post'; import notify from '../../common/notify'; import watch from '../../common/watch-post'; import event, { pushSw, publishChannelStream } from '../../event'; @@ -215,14 +216,20 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { poll: poll, text: text, user_id: user._id, - app_id: app ? app._id : null + app_id: app ? app._id : null, + + // 以下非正規化データ + _reply: reply ? { user_id: reply.user_id } : undefined, + _repost: repost ? { user_id: repost.user_id } : undefined, }); // Serialize - const postObj = await serialize(post); + const postObj = await pack(post); // Reponse - res(postObj); + res({ + created_post: postObj + }); //#region Post processes @@ -234,7 +241,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { const mentions = []; - function addMention(mentionee, reason) { + async function addMention(mentionee, reason) { // Reject if already added if (mentions.some(x => x.equals(mentionee))) return; @@ -243,8 +250,15 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // Publish event if (!user._id.equals(mentionee)) { - event(mentionee, reason, postObj); - pushSw(mentionee, reason, postObj); + const mentioneeMutes = await Mute.find({ + muter_id: mentionee, + deleted_at: { $exists: false } + }); + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.mutee_id.toString()); + if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) { + event(mentionee, reason, postObj); + pushSw(mentionee, reason, postObj); + } } } diff --git a/src/api/endpoints/posts/mentions.ts b/src/api/endpoints/posts/mentions.ts index 0ebe8be50..7127db0ad 100644 --- a/src/api/endpoints/posts/mentions.ts +++ b/src/api/endpoints/posts/mentions.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import Post from '../../models/post'; import getFriends from '../../common/get-friends'; -import serialize from '../../serializers/post'; +import { pack } from '../../models/post'; /** * Get mentions of myself @@ -27,13 +27,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Construct query @@ -58,9 +58,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -73,6 +73,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(mentions.map(async mention => - await serialize(mention, user) + await pack(mention, user) ))); }); diff --git a/src/api/endpoints/posts/polls/recommendation.ts b/src/api/endpoints/posts/polls/recommendation.ts index 9c92d6cac..4a3fa3f55 100644 --- a/src/api/endpoints/posts/polls/recommendation.ts +++ b/src/api/endpoints/posts/polls/recommendation.ts @@ -3,8 +3,7 @@ */ import $ from 'cafy'; import Vote from '../../../models/poll-vote'; -import Post from '../../../models/post'; -import serialize from '../../../serializers/post'; +import Post, { pack } from '../../../models/post'; /** * Get recommended polls @@ -56,5 +55,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(posts.map(async post => - await serialize(post, user, { detail: true })))); + await pack(post, user, { detail: true })))); }); diff --git a/src/api/endpoints/posts/reactions.ts b/src/api/endpoints/posts/reactions.ts index eab5d9b25..feb140ab4 100644 --- a/src/api/endpoints/posts/reactions.ts +++ b/src/api/endpoints/posts/reactions.ts @@ -3,8 +3,7 @@ */ import $ from 'cafy'; import Post from '../../models/post'; -import Reaction from '../../models/post-reaction'; -import serialize from '../../serializers/post-reaction'; +import Reaction, { pack } from '../../models/post-reaction'; /** * Show reactions of a post @@ -54,5 +53,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(reactions.map(async reaction => - await serialize(reaction, user)))); + await pack(reaction, user)))); }); diff --git a/src/api/endpoints/posts/reactions/create.ts b/src/api/endpoints/posts/reactions/create.ts index d537463df..0b0e0e294 100644 --- a/src/api/endpoints/posts/reactions/create.ts +++ b/src/api/endpoints/posts/reactions/create.ts @@ -3,13 +3,12 @@ */ import $ from 'cafy'; import Reaction from '../../../models/post-reaction'; -import Post from '../../../models/post'; +import Post, { pack as packPost } from '../../../models/post'; +import { pack as packUser } from '../../../models/user'; import Watching from '../../../models/post-watching'; import notify from '../../../common/notify'; import watch from '../../../common/watch-post'; import { publishPostStream, pushSw } from '../../../event'; -import serializePost from '../../../serializers/post'; -import serializeUser from '../../../serializers/user'; /** * React to a post @@ -90,8 +89,8 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); pushSw(post.user_id, 'reaction', { - user: await serializeUser(user, post.user_id), - post: await serializePost(post, post.user_id), + user: await packUser(user, post.user_id), + post: await packPost(post, post.user_id), reaction: reaction }); diff --git a/src/api/endpoints/posts/replies.ts b/src/api/endpoints/posts/replies.ts index 3fd6a4676..613c4fa24 100644 --- a/src/api/endpoints/posts/replies.ts +++ b/src/api/endpoints/posts/replies.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Post from '../../models/post'; -import serialize from '../../serializers/post'; +import Post, { pack } from '../../models/post'; /** * Show a replies of a post @@ -50,5 +49,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(replies.map(async post => - await serialize(post, user)))); + await pack(post, user)))); }); diff --git a/src/api/endpoints/posts/reposts.ts b/src/api/endpoints/posts/reposts.ts index b701ff757..89ab0e3d5 100644 --- a/src/api/endpoints/posts/reposts.ts +++ b/src/api/endpoints/posts/reposts.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Post from '../../models/post'; -import serialize from '../../serializers/post'; +import Post, { pack } from '../../models/post'; /** * Show a reposts of a post @@ -25,13 +24,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Lookup post @@ -55,9 +54,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -70,5 +69,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(reposts.map(async post => - await serialize(post, user)))); + await pack(post, user)))); }); diff --git a/src/api/endpoints/posts/search.ts b/src/api/endpoints/posts/search.ts index b434f6434..6e26f5539 100644 --- a/src/api/endpoints/posts/search.ts +++ b/src/api/endpoints/posts/search.ts @@ -1,12 +1,13 @@ /** * Module dependencies */ -import * as mongo from 'mongodb'; import $ from 'cafy'; const escapeRegexp = require('escape-regexp'); import Post from '../../models/post'; -import serialize from '../../serializers/post'; -import config from '../../../conf'; +import User from '../../models/user'; +import Mute from '../../models/mute'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../models/post'; /** * Search a post @@ -16,33 +17,331 @@ import config from '../../../conf'; * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { - // Get 'query' parameter - const [query, queryError] = $(params.query).string().pipe(x => x != '').$; - if (queryError) return rej('invalid query param'); + // Get 'text' parameter + const [text, textError] = $(params.text).optional.string().$; + if (textError) return rej('invalid text param'); + + // Get 'include_user_ids' parameter + const [includeUserIds = [], includeUserIdsErr] = $(params.include_user_ids).optional.array('id').$; + if (includeUserIdsErr) return rej('invalid include_user_ids param'); + + // Get 'exclude_user_ids' parameter + const [excludeUserIds = [], excludeUserIdsErr] = $(params.exclude_user_ids).optional.array('id').$; + if (excludeUserIdsErr) return rej('invalid exclude_user_ids param'); + + // Get 'include_user_usernames' parameter + const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.include_user_usernames).optional.array('string').$; + if (includeUserUsernamesErr) return rej('invalid include_user_usernames param'); + + // Get 'exclude_user_usernames' parameter + const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.exclude_user_usernames).optional.array('string').$; + if (excludeUserUsernamesErr) return rej('invalid exclude_user_usernames param'); + + // Get 'following' parameter + const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$; + if (followingErr) return rej('invalid following param'); + + // Get 'mute' parameter + const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$; + if (muteErr) return rej('invalid mute param'); + + // Get 'reply' parameter + const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'repost' parameter + const [repost = null, repostErr] = $(params.repost).optional.nullable.boolean().$; + if (repostErr) return rej('invalid repost param'); + + // Get 'media' parameter + const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$; + if (pollErr) return rej('invalid poll param'); + + // Get 'since_date' parameter + const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; + if (sinceDateErr) throw 'invalid since_date param'; + + // Get 'until_date' parameter + const [untilDate, untilDateErr] = $(params.until_date).optional.number().$; + if (untilDateErr) throw 'invalid until_date param'; // Get 'offset' parameter const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; if (offsetErr) return rej('invalid offset param'); - // Get 'max' parameter - const [max = 10, maxErr] = $(params.max).optional.number().range(1, 30).$; - if (maxErr) return rej('invalid max param'); + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$; + if (limitErr) return rej('invalid limit param'); - // If Elasticsearch is available, search by $ - // If not, search by MongoDB - (config.elasticsearch.enable ? byElasticsearch : byNative) - (res, rej, me, query, offset, max); + let includeUsers = includeUserIds; + if (includeUserUsernames != null) { + const ids = (await Promise.all(includeUserUsernames.map(async (username) => { + const _user = await User.findOne({ + username_lower: username.toLowerCase() + }); + return _user ? _user._id : null; + }))).filter(id => id != null); + includeUsers = includeUsers.concat(ids); + } + + let excludeUsers = excludeUserIds; + if (excludeUserUsernames != null) { + const ids = (await Promise.all(excludeUserUsernames.map(async (username) => { + const _user = await User.findOne({ + username_lower: username.toLowerCase() + }); + return _user ? _user._id : null; + }))).filter(id => id != null); + excludeUsers = excludeUsers.concat(ids); + } + + search(res, rej, me, text, includeUsers, excludeUsers, following, + mute, reply, repost, media, poll, sinceDate, untilDate, offset, limit); }); -// Search by MongoDB -async function byNative(res, rej, me, query, offset, max) { - const escapedQuery = escapeRegexp(query); +async function search( + res, rej, me, text, includeUserIds, excludeUserIds, following, + mute, reply, repost, media, poll, sinceDate, untilDate, offset, max) { + + let q: any = { + $and: [] + }; + + const push = x => q.$and.push(x); + + if (text) { + // 完全一致検索 + if (/"""(.+?)"""/.test(text)) { + const x = text.match(/"""(.+?)"""/)[1]; + push({ + text: x + }); + } else { + push({ + $and: text.split(' ').map(x => ({ + // キーワードが-で始まる場合そのキーワードを除外する + text: x[0] == '-' ? { + $not: new RegExp(escapeRegexp(x.substr(1))) + } : new RegExp(escapeRegexp(x)) + })) + }); + } + } + + if (includeUserIds && includeUserIds.length != 0) { + push({ + user_id: { + $in: includeUserIds + } + }); + } else if (excludeUserIds && excludeUserIds.length != 0) { + push({ + user_id: { + $nin: excludeUserIds + } + }); + } + + if (following != null && me != null) { + const ids = await getFriends(me._id, false); + push({ + user_id: following ? { + $in: ids + } : { + $nin: ids.concat(me._id) + } + }); + } + + if (me != null) { + const mutes = await Mute.find({ + muter_id: me._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mutes.map(m => m.mutee_id); + + switch (mute) { + case 'mute_all': + push({ + user_id: { + $nin: mutedUserIds + }, + '_reply.user_id': { + $nin: mutedUserIds + }, + '_repost.user_id': { + $nin: mutedUserIds + } + }); + break; + case 'mute_related': + push({ + '_reply.user_id': { + $nin: mutedUserIds + }, + '_repost.user_id': { + $nin: mutedUserIds + } + }); + break; + case 'mute_direct': + push({ + user_id: { + $nin: mutedUserIds + } + }); + break; + case 'direct_only': + push({ + user_id: { + $in: mutedUserIds + } + }); + break; + case 'related_only': + push({ + $or: [{ + '_reply.user_id': { + $in: mutedUserIds + } + }, { + '_repost.user_id': { + $in: mutedUserIds + } + }] + }); + break; + case 'all_only': + push({ + $or: [{ + user_id: { + $in: mutedUserIds + } + }, { + '_reply.user_id': { + $in: mutedUserIds + } + }, { + '_repost.user_id': { + $in: mutedUserIds + } + }] + }); + break; + } + } + + if (reply != null) { + if (reply) { + push({ + reply_id: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + reply_id: { + $exists: false + } + }, { + reply_id: null + }] + }); + } + } + + if (repost != null) { + if (repost) { + push({ + repost_id: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + repost_id: { + $exists: false + } + }, { + repost_id: null + }] + }); + } + } + + if (media != null) { + if (media) { + push({ + media_ids: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + media_ids: { + $exists: false + } + }, { + media_ids: null + }] + }); + } + } + + if (poll != null) { + if (poll) { + push({ + poll: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + poll: { + $exists: false + } + }, { + poll: null + }] + }); + } + } + + if (sinceDate) { + push({ + created_at: { + $gt: new Date(sinceDate) + } + }); + } + + if (untilDate) { + push({ + created_at: { + $lt: new Date(untilDate) + } + }); + } + + if (q.$and.length == 0) { + q = {}; + } // Search posts const posts = await Post - .find({ - text: new RegExp(escapedQuery) - }, { + .find(q, { sort: { _id: -1 }, @@ -52,68 +351,5 @@ async function byNative(res, rej, me, query, offset, max) { // Serialize res(await Promise.all(posts.map(async post => - await serialize(post, me)))); -} - -// Search by Elasticsearch -async function byElasticsearch(res, rej, me, query, offset, max) { - const es = require('../../db/elasticsearch'); - - es.search({ - index: 'misskey', - type: 'post', - body: { - size: max, - from: offset, - query: { - simple_query_string: { - fields: ['text'], - query: query, - default_operator: 'and' - } - }, - sort: [ - { _doc: 'desc' } - ], - highlight: { - pre_tags: ['<mark>'], - post_tags: ['</mark>'], - encoder: 'html', - fields: { - text: {} - } - } - } - }, async (error, response) => { - if (error) { - console.error(error); - return res(500); - } - - if (response.hits.total === 0) { - return res([]); - } - - const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); - - // Fetch found posts - const posts = await Post - .find({ - _id: { - $in: hits - } - }, { - sort: { - _id: -1 - } - }); - - posts.map(post => { - post._highlight = response.hits.hits.filter(hit => post._id.equals(hit._id))[0].highlight.text[0]; - }); - - // Serialize - res(await Promise.all(posts.map(async post => - await serialize(post, me)))); - }); + await pack(post, me)))); } diff --git a/src/api/endpoints/posts/show.ts b/src/api/endpoints/posts/show.ts index 5bfe4f660..383949059 100644 --- a/src/api/endpoints/posts/show.ts +++ b/src/api/endpoints/posts/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import Post from '../../models/post'; -import serialize from '../../serializers/post'; +import Post, { pack } from '../../models/post'; /** * Show a post @@ -27,7 +26,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Serialize - res(await serialize(post, user, { + res(await pack(post, user, { detail: true })); }); diff --git a/src/api/endpoints/posts/timeline.ts b/src/api/endpoints/posts/timeline.ts index 0d08b9546..c41cfdb8b 100644 --- a/src/api/endpoints/posts/timeline.ts +++ b/src/api/endpoints/posts/timeline.ts @@ -4,9 +4,10 @@ import $ from 'cafy'; import rap from '@prezzemolo/rap'; import Post from '../../models/post'; +import Mute from '../../models/mute'; import ChannelWatching from '../../models/channel-watching'; import getFriends from '../../common/get-friends'; -import serialize from '../../serializers/post'; +import { pack } from '../../models/post'; /** * Get timeline of myself @@ -25,32 +26,40 @@ module.exports = async (params, user, app) => { const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; if (sinceIdErr) throw 'invalid since_id param'; - // Get 'max_id' parameter - const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) throw 'invalid max_id param'; + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) throw 'invalid until_id param'; // Get 'since_date' parameter const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; if (sinceDateErr) throw 'invalid since_date param'; - // Get 'max_date' parameter - const [maxDate, maxDateErr] = $(params.max_date).optional.number().$; - if (maxDateErr) throw 'invalid max_date param'; + // Get 'until_date' parameter + const [untilDate, untilDateErr] = $(params.until_date).optional.number().$; + if (untilDateErr) throw 'invalid until_date param'; - // Check if only one of since_id, max_id, since_date, max_date specified - if ([sinceId, maxId, sinceDate, maxDate].filter(x => x != null).length > 1) { - throw 'only one of since_id, max_id, since_date, max_date can be specified'; + // Check if only one of since_id, until_id, since_date, until_date specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of since_id, until_id, since_date, until_date can be specified'; } - const { followingIds, watchingChannelIds } = await rap({ + const { followingIds, watchingChannelIds, mutedUserIds } = await rap({ // ID list of the user itself and other users who the user follows followingIds: getFriends(user._id), + // Watchしているチャンネルを取得 watchingChannelIds: ChannelWatching.find({ user_id: user._id, // 削除されたドキュメントは除く deleted_at: { $exists: false } - }).then(watches => watches.map(w => w.channel_id)) + }).then(watches => watches.map(w => w.channel_id)), + + // ミュートしているユーザーを取得 + mutedUserIds: Mute.find({ + muter_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(ms => ms.map(m => m.mutee_id)) }); //#region Construct query @@ -77,7 +86,17 @@ module.exports = async (params, user, app) => { channel_id: { $in: watchingChannelIds } - }] + }], + // mute + user_id: { + $nin: mutedUserIds + }, + '_reply.user_id': { + $nin: mutedUserIds + }, + '_repost.user_id': { + $nin: mutedUserIds + }, } as any; if (sinceId) { @@ -85,18 +104,18 @@ module.exports = async (params, user, app) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } else if (sinceDate) { sort._id = 1; query.created_at = { $gt: new Date(sinceDate) }; - } else if (maxDate) { + } else if (untilDate) { query.created_at = { - $lt: new Date(maxDate) + $lt: new Date(untilDate) }; } //#endregion @@ -109,5 +128,5 @@ module.exports = async (params, user, app) => { }); // Serialize - return await Promise.all(timeline.map(post => serialize(post, user))); + return await Promise.all(timeline.map(post => pack(post, user))); }; diff --git a/src/api/endpoints/posts/trend.ts b/src/api/endpoints/posts/trend.ts index 64a195dff..caded92bf 100644 --- a/src/api/endpoints/posts/trend.ts +++ b/src/api/endpoints/posts/trend.ts @@ -3,8 +3,7 @@ */ const ms = require('ms'); import $ from 'cafy'; -import Post from '../../models/post'; -import serialize from '../../serializers/post'; +import Post, { pack } from '../../models/post'; /** * Get trend posts @@ -76,5 +75,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(posts.map(async post => - await serialize(post, user, { detail: true })))); + await pack(post, user, { detail: true })))); }); diff --git a/src/api/endpoints/users.ts b/src/api/endpoints/users.ts index 134f262fb..095b9fe40 100644 --- a/src/api/endpoints/users.ts +++ b/src/api/endpoints/users.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import User from '../models/user'; -import serialize from '../serializers/user'; +import User, { pack } from '../models/user'; /** * Lists all users @@ -21,13 +20,13 @@ module.exports = (params, me) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_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'); + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); } // Construct query @@ -40,9 +39,9 @@ module.exports = (params, me) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } @@ -55,5 +54,5 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(users.map(async user => - await serialize(user, me)))); + await pack(user, me)))); }); diff --git a/src/api/endpoints/users/followers.ts b/src/api/endpoints/users/followers.ts index 4905323ba..b0fb83c68 100644 --- a/src/api/endpoints/users/followers.ts +++ b/src/api/endpoints/users/followers.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import User from '../../models/user'; import Following from '../../models/following'; -import serialize from '../../serializers/user'; +import { pack } from '../../models/user'; import getFriends from '../../common/get-friends'; /** @@ -82,7 +82,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize const users = await Promise.all(following.map(async f => - await serialize(f.follower_id, me, { detail: true }))); + await pack(f.follower_id, me, { detail: true }))); // Response res({ diff --git a/src/api/endpoints/users/following.ts b/src/api/endpoints/users/following.ts index dc2ff49bb..8e88431e9 100644 --- a/src/api/endpoints/users/following.ts +++ b/src/api/endpoints/users/following.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import User from '../../models/user'; import Following from '../../models/following'; -import serialize from '../../serializers/user'; +import { pack } from '../../models/user'; import getFriends from '../../common/get-friends'; /** @@ -82,7 +82,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize const users = await Promise.all(following.map(async f => - await serialize(f.followee_id, me, { detail: true }))); + await pack(f.followee_id, me, { detail: true }))); // Response res({ diff --git a/src/api/endpoints/users/get_frequently_replied_users.ts b/src/api/endpoints/users/get_frequently_replied_users.ts index a8add623d..87f4f77a5 100644 --- a/src/api/endpoints/users/get_frequently_replied_users.ts +++ b/src/api/endpoints/users/get_frequently_replied_users.ts @@ -3,8 +3,7 @@ */ import $ from 'cafy'; import Post from '../../models/post'; -import User from '../../models/user'; -import serialize from '../../serializers/user'; +import User, { pack } from '../../models/user'; module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'user_id' parameter @@ -91,7 +90,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Make replies object (includes weights) const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ - user: await serialize(user, me, { detail: true }), + user: await pack(user, me, { detail: true }), weight: repliedUsers[user] / peak }))); diff --git a/src/api/endpoints/users/posts.ts b/src/api/endpoints/users/posts.ts index fe821cf17..0c8bceee3 100644 --- a/src/api/endpoints/users/posts.ts +++ b/src/api/endpoints/users/posts.ts @@ -2,9 +2,8 @@ * Module dependencies */ import $ from 'cafy'; -import Post from '../../models/post'; +import Post, { pack } from '../../models/post'; import User from '../../models/user'; -import serialize from '../../serializers/post'; /** * Get posts of a user @@ -42,21 +41,21 @@ module.exports = (params, me) => new Promise(async (res, rej) => { 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'); + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); // Get 'since_date' parameter const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; if (sinceDateErr) throw 'invalid since_date param'; - // Get 'max_date' parameter - const [maxDate, maxDateErr] = $(params.max_date).optional.number().$; - if (maxDateErr) throw 'invalid max_date param'; + // Get 'until_date' parameter + const [untilDate, untilDateErr] = $(params.until_date).optional.number().$; + if (untilDateErr) throw 'invalid until_date param'; - // Check if only one of since_id, max_id, since_date, max_date specified - if ([sinceId, maxId, sinceDate, maxDate].filter(x => x != null).length > 1) { - throw 'only one of since_id, max_id, since_date, max_date can be specified'; + // Check if only one of since_id, until_id, since_date, until_date specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of since_id, until_id, since_date, until_date can be specified'; } const q = userId !== undefined @@ -88,18 +87,18 @@ module.exports = (params, me) => new Promise(async (res, rej) => { query._id = { $gt: sinceId }; - } else if (maxId) { + } else if (untilId) { query._id = { - $lt: maxId + $lt: untilId }; } else if (sinceDate) { sort._id = 1; query.created_at = { $gt: new Date(sinceDate) }; - } else if (maxDate) { + } else if (untilDate) { query.created_at = { - $lt: new Date(maxDate) + $lt: new Date(untilDate) }; } @@ -124,6 +123,6 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(posts.map(async (post) => - await serialize(post, me) + await pack(post, me) ))); }); diff --git a/src/api/endpoints/users/recommendation.ts b/src/api/endpoints/users/recommendation.ts index 731d68a7b..736233b34 100644 --- a/src/api/endpoints/users/recommendation.ts +++ b/src/api/endpoints/users/recommendation.ts @@ -3,8 +3,7 @@ */ const ms = require('ms'); import $ from 'cafy'; -import User from '../../models/user'; -import serialize from '../../serializers/user'; +import User, { pack } from '../../models/user'; import getFriends from '../../common/get-friends'; /** @@ -44,5 +43,5 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(users.map(async user => - await serialize(user, me, { detail: true })))); + await pack(user, me, { detail: true })))); }); diff --git a/src/api/endpoints/users/search.ts b/src/api/endpoints/users/search.ts index 73a5db47e..39e2ff989 100644 --- a/src/api/endpoints/users/search.ts +++ b/src/api/endpoints/users/search.ts @@ -3,8 +3,7 @@ */ import * as mongo from 'mongodb'; import $ from 'cafy'; -import User from '../../models/user'; -import serialize from '../../serializers/user'; +import User, { pack } from '../../models/user'; import config from '../../../conf'; const escapeRegexp = require('escape-regexp'); @@ -52,7 +51,7 @@ async function byNative(res, rej, me, query, offset, max) { // Serialize res(await Promise.all(users.map(async user => - await serialize(user, me, { detail: true })))); + await pack(user, me, { detail: true })))); } // Search by Elasticsearch @@ -94,6 +93,6 @@ async function byElasticsearch(res, rej, me, query, offset, max) { // Serialize res(await Promise.all(users.map(async user => - await serialize(user, me, { detail: true })))); + await pack(user, me, { detail: true })))); }); } diff --git a/src/api/endpoints/users/search_by_username.ts b/src/api/endpoints/users/search_by_username.ts index 7f2f42f0a..9c5e1905a 100644 --- a/src/api/endpoints/users/search_by_username.ts +++ b/src/api/endpoints/users/search_by_username.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import User from '../../models/user'; -import serialize from '../../serializers/user'; +import User, { pack } from '../../models/user'; /** * Search a user by username @@ -35,5 +34,5 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Serialize res(await Promise.all(users.map(async user => - await serialize(user, me, { detail: true })))); + await pack(user, me, { detail: true })))); }); diff --git a/src/api/endpoints/users/show.ts b/src/api/endpoints/users/show.ts index 8e74b0fe3..7aea59296 100644 --- a/src/api/endpoints/users/show.ts +++ b/src/api/endpoints/users/show.ts @@ -2,8 +2,7 @@ * Module dependencies */ import $ from 'cafy'; -import User from '../../models/user'; -import serialize from '../../serializers/user'; +import User, { pack } from '../../models/user'; /** * Show a user @@ -41,7 +40,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { } // Send response - res(await serialize(user, me, { + res(await pack(user, me, { detail: true })); }); diff --git a/src/api/models/app.ts b/src/api/models/app.ts index 68f2f448b..34e9867db 100644 --- a/src/api/models/app.ts +++ b/src/api/models/app.ts @@ -1,13 +1,97 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import AccessToken from './access-token'; import db from '../../db/mongodb'; +import config from '../../conf'; -const collection = db.get('apps'); +const App = db.get<IApp>('apps'); +App.createIndex('name_id'); +App.createIndex('name_id_lower'); +App.createIndex('secret'); +export default App; -(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 +export type IApp = { + _id: mongo.ObjectID; + created_at: Date; + user_id: mongo.ObjectID; + secret: string; +}; export function isValidNameId(nameId: string): boolean { return typeof nameId == 'string' && /^[a-zA-Z0-9\-]{3,30}$/.test(nameId); } + +/** + * Pack an app for API response + * + * @param {any} app + * @param {any} me? + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + app: any, + me?: any, + options?: { + includeSecret?: boolean, + includeProfileImageIds?: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = options || { + includeSecret: false, + includeProfileImageIds: false + }; + + let _app: any; + + // Populate the app if 'app' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(app)) { + _app = await App.findOne({ + _id: app + }); + } else if (typeof app === 'string') { + _app = await App.findOne({ + _id: new mongo.ObjectID(app) + }); + } else { + _app = deepcopy(app); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _app.id = _app._id; + delete _app._id; + + delete _app.name_id_lower; + + // Visible by only owner + if (!opts.includeSecret) { + delete _app.secret; + } + + _app.icon_url = _app.icon != null + ? `${config.drive_url}/${_app.icon}` + : `${config.drive_url}/app-default.jpg`; + + if (me) { + // 既に連携しているか + const exist = await AccessToken.count({ + app_id: _app.id, + user_id: me, + }, { + limit: 1 + }); + + _app.is_authorized = exist === 1; + } + + resolve(_app); +}); diff --git a/src/api/models/auth-session.ts b/src/api/models/auth-session.ts index b264a133e..997ec61c2 100644 --- a/src/api/models/auth-session.ts +++ b/src/api/models/auth-session.ts @@ -1,3 +1,45 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); import db from '../../db/mongodb'; +import { pack as packApp } from './app'; -export default db.get('auth_sessions') as any; // fuck type definition +const AuthSession = db.get('auth_sessions'); +export default AuthSession; + +export interface IAuthSession { + _id: mongo.ObjectID; +} + +/** + * Pack an auth session for API response + * + * @param {any} session + * @param {any} me? + * @return {Promise<any>} + */ +export const pack = ( + session: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _session: any; + + // TODO: Populate session if it ID + + _session = deepcopy(session); + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + delete _session._id; + + // Populate app + _session.app = await packApp(_session.app_id, me); + + resolve(_session); +}); diff --git a/src/api/models/channel.ts b/src/api/models/channel.ts index c80e84dbc..815d53593 100644 --- a/src/api/models/channel.ts +++ b/src/api/models/channel.ts @@ -1,9 +1,11 @@ import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { IUser } from './user'; +import Watching from './channel-watching'; import db from '../../db/mongodb'; -const collection = db.get('channels'); - -export default collection as any; // fuck type definition +const Channel = db.get<IChannel>('channels'); +export default Channel; export type IChannel = { _id: mongo.ObjectID; @@ -12,3 +14,61 @@ export type IChannel = { user_id: mongo.ObjectID; index: number; }; + +/** + * Pack a channel for API response + * + * @param channel target + * @param me? serializee + * @return response + */ +export const pack = ( + channel: string | mongo.ObjectID | IChannel, + me?: string | mongo.ObjectID | IUser +) => new Promise<any>(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/models/drive-file.ts b/src/api/models/drive-file.ts index 802ee5a5f..2a46d8dc4 100644 --- a/src/api/models/drive-file.ts +++ b/src/api/models/drive-file.ts @@ -1,9 +1,12 @@ import * as mongodb from 'mongodb'; +import deepcopy = require('deepcopy'); +import { pack as packFolder } from './drive-folder'; +import config from '../../conf'; import monkDb, { nativeDbConn } from '../../db/mongodb'; -const collection = monkDb.get('drive_files.files'); +const DriveFile = monkDb.get<IDriveFile>('drive_files.files'); -export default collection as any; // fuck type definition +export default DriveFile; const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => { const db = await nativeDbConn(); @@ -15,6 +18,19 @@ const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => { export { getGridFSBucket }; +export type IDriveFile = { + _id: mongodb.ObjectID; + uploadDate: Date; + md5: string; + filename: string; + contentType: string; + metadata: { + properties: any; + user_id: mongodb.ObjectID; + folder_id: mongodb.ObjectID; + } +}; + export function validateFileName(name: string): boolean { return ( (name.trim().length > 0) && @@ -24,3 +40,74 @@ export function validateFileName(name: string): boolean { (name.indexOf('..') === -1) ); } + +/** + * Pack a drive file for API response + * + * @param {any} file + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + file: any, + options?: { + detail: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = Object.assign({ + detail: false + }, options); + + let _file: any; + + // Populate the file if 'file' is ID + if (mongodb.ObjectID.prototype.isPrototypeOf(file)) { + _file = await DriveFile.findOne({ + _id: file + }); + } else if (typeof file === 'string') { + _file = await DriveFile.findOne({ + _id: new mongodb.ObjectID(file) + }); + } else { + _file = deepcopy(file); + } + + if (!_file) return reject('invalid file arg.'); + + // rendered target + let _target: any = {}; + + _target.id = _file._id; + _target.created_at = _file.uploadDate; + _target.name = _file.filename; + _target.type = _file.contentType; + _target.datasize = _file.length; + _target.md5 = _file.md5; + + _target = Object.assign(_target, _file.metadata); + + _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; + + if (_target.properties == null) _target.properties = {}; + + if (opts.detail) { + if (_target.folder_id) { + // Populate folder + _target.folder = await packFolder(_target.folder_id, { + detail: true + }); + } + + /* + if (_target.tags) { + // Populate tags + _target.tags = await _target.tags.map(async (tag: any) => + await serializeDriveTag(tag) + ); + } + */ + } + + resolve(_target); +}); diff --git a/src/api/models/drive-folder.ts b/src/api/models/drive-folder.ts index f81ffe855..54b45049b 100644 --- a/src/api/models/drive-folder.ts +++ b/src/api/models/drive-folder.ts @@ -1,6 +1,18 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); import db from '../../db/mongodb'; +import DriveFile from './drive-file'; -export default db.get('drive_folders') as any; // fuck type definition +const DriveFolder = db.get<IDriveFolder>('drive_folders'); +export default DriveFolder; + +export type IDriveFolder = { + _id: mongo.ObjectID; + created_at: Date; + name: string; + user_id: mongo.ObjectID; + parent_id: mongo.ObjectID; +}; export function isValidFolderName(name: string): boolean { return ( @@ -8,3 +20,58 @@ export function isValidFolderName(name: string): boolean { (name.length <= 200) ); } + +/** + * Pack a drive folder for API response + * + * @param {any} folder + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + folder: any, + options?: { + detail: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = Object.assign({ + detail: false + }, options); + + let _folder: any; + + // Populate the folder if 'folder' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { + _folder = await DriveFolder.findOne({ _id: folder }); + } else if (typeof folder === 'string') { + _folder = await DriveFolder.findOne({ _id: new mongo.ObjectID(folder) }); + } else { + _folder = deepcopy(folder); + } + + // Rename _id to id + _folder.id = _folder._id; + delete _folder._id; + + if (opts.detail) { + const childFoldersCount = await DriveFolder.count({ + parent_id: _folder.id + }); + + const childFilesCount = await DriveFile.count({ + 'metadata.folder_id': _folder.id + }); + + _folder.folders_count = childFoldersCount; + _folder.files_count = childFilesCount; + } + + if (opts.detail && _folder.parent_id) { + // Populate parent folder + _folder.parent = await pack(_folder.parent_id, { + detail: true + }); + } + + resolve(_folder); +}); diff --git a/src/api/models/messaging-message.ts b/src/api/models/messaging-message.ts index 18afa57e4..90cf1cd71 100644 --- a/src/api/models/messaging-message.ts +++ b/src/api/models/messaging-message.ts @@ -1,12 +1,81 @@ import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { pack as packUser } from './user'; +import { pack as packFile } from './drive-file'; import db from '../../db/mongodb'; +import parse from '../common/text'; -export default db.get('messaging_messages') as any; // fuck type definition +const MessagingMessage = db.get<IMessagingMessage>('messaging_messages'); +export default MessagingMessage; export interface IMessagingMessage { _id: mongo.ObjectID; + created_at: Date; + text: string; + user_id: mongo.ObjectID; + recipient_id: mongo.ObjectID; + is_read: boolean; } export function isValidText(text: string): boolean { return text.length <= 1000 && text.trim() != ''; } + +/** + * Pack a messaging message for API response + * + * @param {any} message + * @param {any} me? + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + message: any, + me?: any, + options?: { + populateRecipient: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = options || { + populateRecipient: true + }; + + let _message: any; + + // Populate the message if 'message' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(message)) { + _message = await MessagingMessage.findOne({ + _id: message + }); + } else if (typeof message === 'string') { + _message = await MessagingMessage.findOne({ + _id: new mongo.ObjectID(message) + }); + } else { + _message = deepcopy(message); + } + + // Rename _id to id + _message.id = _message._id; + delete _message._id; + + // Parse text + if (_message.text) { + _message.ast = parse(_message.text); + } + + // Populate user + _message.user = await packUser(_message.user_id, me); + + if (_message.file) { + // Populate file + _message.file = await packFile(_message.file_id); + } + + if (opts.populateRecipient) { + // Populate recipient + _message.recipient = await packUser(_message.recipient_id, me); + } + + resolve(_message); +}); diff --git a/src/api/models/mute.ts b/src/api/models/mute.ts new file mode 100644 index 000000000..16018b82f --- /dev/null +++ b/src/api/models/mute.ts @@ -0,0 +1,3 @@ +import db from '../../db/mongodb'; + +export default db.get('mute') as any; // fuck type definition diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts index e3dc6c70a..fa7049d31 100644 --- a/src/api/models/notification.ts +++ b/src/api/models/notification.ts @@ -1,8 +1,11 @@ import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); import db from '../../db/mongodb'; -import { IUser } from './user'; +import { IUser, pack as packUser } from './user'; +import { pack as packPost } from './post'; -export default db.get('notifications') as any; // fuck type definition +const Notification = db.get<INotification>('notifications'); +export default Notification; export interface INotification { _id: mongo.ObjectID; @@ -45,3 +48,60 @@ export interface INotification { */ is_read: Boolean; } + +/** + * Pack a notification for API response + * + * @param {any} notification + * @return {Promise<any>} + */ +export const pack = (notification: any) => new Promise<any>(async (resolve, reject) => { + let _notification: any; + + // Populate the notification if 'notification' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + _notification = await Notification.findOne({ + _id: notification + }); + } else if (typeof notification === 'string') { + _notification = await Notification.findOne({ + _id: new mongo.ObjectID(notification) + }); + } else { + _notification = deepcopy(notification); + } + + // Rename _id to id + _notification.id = _notification._id; + delete _notification._id; + + // Rename notifier_id to user_id + _notification.user_id = _notification.notifier_id; + delete _notification.notifier_id; + + const me = _notification.notifiee_id; + delete _notification.notifiee_id; + + // Populate notifier + _notification.user = await packUser(_notification.user_id, me); + + switch (_notification.type) { + case 'follow': + // nope + break; + case 'mention': + case 'reply': + case 'repost': + case 'quote': + case 'reaction': + case 'poll_vote': + // Populate post + _notification.post = await packPost(_notification.post_id, me); + break; + default: + console.error(`Unknown type: ${_notification.type}`); + break; + } + + resolve(_notification); +}); diff --git a/src/api/models/post-reaction.ts b/src/api/models/post-reaction.ts index 282ae5bd2..639a70e00 100644 --- a/src/api/models/post-reaction.ts +++ b/src/api/models/post-reaction.ts @@ -1,3 +1,51 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); import db from '../../db/mongodb'; +import Reaction from './post-reaction'; +import { pack as packUser } from './user'; -export default db.get('post_reactions') as any; // fuck type definition +const PostReaction = db.get<IPostReaction>('post_reactions'); +export default PostReaction; + +export interface IPostReaction { + _id: mongo.ObjectID; + created_at: Date; + deleted_at: Date; + reaction: string; +} + +/** + * Pack a reaction for API response + * + * @param {any} reaction + * @param {any} me? + * @return {Promise<any>} + */ +export const pack = ( + reaction: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _reaction: any; + + // Populate the reaction if 'reaction' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(reaction)) { + _reaction = await Reaction.findOne({ + _id: reaction + }); + } else if (typeof reaction === 'string') { + _reaction = await Reaction.findOne({ + _id: new mongo.ObjectID(reaction) + }); + } else { + _reaction = deepcopy(reaction); + } + + // Rename _id to id + _reaction.id = _reaction._id; + delete _reaction._id; + + // Populate user + _reaction.user = await packUser(_reaction.user_id, me); + + resolve(_reaction); +}); diff --git a/src/api/models/post.ts b/src/api/models/post.ts index 7584ce182..0bbacebf6 100644 --- a/src/api/models/post.ts +++ b/src/api/models/post.ts @@ -1,8 +1,18 @@ import * as mongo from 'mongodb'; - +import deepcopy = require('deepcopy'); +import rap from '@prezzemolo/rap'; import db from '../../db/mongodb'; +import { IUser, pack as packUser } from './user'; +import { pack as packApp } from './app'; +import { pack as packChannel } from './channel'; +import Vote from './poll-vote'; +import Reaction from './post-reaction'; +import { pack as packFile } from './drive-file'; +import parse from '../common/text'; -export default db.get('posts') as any; // fuck type definition +const Post = db.get<IPost>('posts'); + +export default Post; export function isValidText(text: string): boolean { return text.length <= 1000 && text.trim() != ''; @@ -15,8 +25,185 @@ export type IPost = { media_ids: mongo.ObjectID[]; reply_id: mongo.ObjectID; repost_id: mongo.ObjectID; - poll: {}; // todo + poll: any; // todo text: string; user_id: mongo.ObjectID; app_id: mongo.ObjectID; + category: string; + is_category_verified: boolean; +}; + +/** + * Pack a post for API response + * + * @param post target + * @param me? serializee + * @param options? serialize options + * @return response + */ +export const pack = async ( + post: string | mongo.ObjectID | IPost, + me?: string | mongo.ObjectID | IUser, + options?: { + detail: boolean + } +) => { + 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 + if (mongo.ObjectID.prototype.isPrototypeOf(post)) { + _post = await Post.findOne({ + _id: post + }); + } else if (typeof post === 'string') { + _post = await Post.findOne({ + _id: new mongo.ObjectID(post) + }); + } else { + _post = deepcopy(post); + } + + if (!_post) throw 'invalid post arg.'; + + const id = _post._id; + + // Rename _id to id + _post.id = _post._id; + delete _post._id; + + delete _post.mentions; + + // Parse text + if (_post.text) { + _post.ast = parse(_post.text); + } + + // Populate user + _post.user = packUser(_post.user_id, meId); + + // Populate app + if (_post.app_id) { + _post.app = packApp(_post.app_id); + } + + // Populate channel + if (_post.channel_id) { + _post.channel = packChannel(_post.channel_id); + } + + // Populate media + if (_post.media_ids) { + _post.media = Promise.all(_post.media_ids.map(fileId => + packFile(fileId) + )); + } + + // 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; + })(); + + // 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 = pack(_post.reply_id, meId, { + detail: false + }); + } + + if (_post.repost_id) { + // Populate repost + _post.repost = pack(_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; + })(); + } + } + + // resolve promises in _post object + _post = await rap(_post); + + return _post; }; diff --git a/src/api/models/signin.ts b/src/api/models/signin.ts index 385a348f2..262c8707e 100644 --- a/src/api/models/signin.ts +++ b/src/api/models/signin.ts @@ -1,3 +1,29 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); import db from '../../db/mongodb'; -export default db.get('signin') as any; // fuck type definition +const Signin = db.get<ISignin>('signin'); +export default Signin; + +export interface ISignin { + _id: mongo.ObjectID; +} + +/** + * Pack a signin record for API response + * + * @param {any} record + * @return {Promise<any>} + */ +export const pack = ( + record: any +) => new Promise<any>(async (resolve, reject) => { + + const _record = deepcopy(record); + + // Rename _id to id + _record.id = _record._id; + delete _record._id; + + resolve(_record); +}); diff --git a/src/api/models/user.ts b/src/api/models/user.ts index b2f3af09f..e92f244dd 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -1,14 +1,19 @@ import * as mongo from 'mongodb'; - +import deepcopy = require('deepcopy'); +import rap from '@prezzemolo/rap'; import db from '../../db/mongodb'; -import { IPost } from './post'; +import { IPost, pack as packPost } from './post'; +import Following from './following'; +import Mute from './mute'; +import getFriends from '../common/get-friends'; +import config from '../../conf'; -const collection = db.get('users'); +const User = db.get<IUser>('users'); -(collection as any).createIndex('username'); // fuck type definition -(collection as any).createIndex('token'); // fuck type definition +User.createIndex('username'); +User.createIndex('token'); -export default collection as any; // fuck type definition +export default User; export function validateUsername(username: string): boolean { return typeof username == 'string' && /^[a-zA-Z0-9\-]{3,20}$/.test(username); @@ -37,6 +42,7 @@ export function isValidBirthday(birthday: string): boolean { export type IUser = { _id: mongo.ObjectID; created_at: Date; + deleted_at: Date; email: string; followers_count: number; following_count: number; @@ -72,6 +78,8 @@ export type IUser = { is_pro: boolean; is_suspended: boolean; keywords: string[]; + two_factor_secret: string; + two_factor_enabled: boolean; }; export function init(user): IUser { @@ -81,3 +89,182 @@ export function init(user): IUser { user.pinned_post_id = new mongo.ObjectID(user.pinned_post_id); return user; } + +/** + * Pack a user for API response + * + * @param user target + * @param me? serializee + * @param options? serialize options + * @return Packed user + */ +export const pack = ( + user: string | mongo.ObjectID | IUser, + me?: string | mongo.ObjectID | IUser, + options?: { + detail?: boolean, + includeSecrets?: boolean + } +) => new Promise<any>(async (resolve, reject) => { + + const opts = Object.assign({ + detail: false, + includeSecrets: false + }, options); + + let _user: any; + + const fields = opts.detail ? { + settings: false + } : { + settings: false, + client_settings: false, + profile: false, + keywords: false, + domains: false + }; + + // Populate the user if 'user' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + _user = await User.findOne({ + _id: user + }, { fields }); + } else if (typeof user === 'string') { + _user = await User.findOne({ + _id: new mongo.ObjectID(user) + }, { fields }); + } else { + _user = deepcopy(user); + } + + if (!_user) return reject('invalid user arg.'); + + // 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; + + // Rename _id to id + _user.id = _user._id; + delete _user._id; + + // Remove needless properties + delete _user.latest_post; + + // Remove private properties + delete _user.password; + delete _user.token; + delete _user.two_factor_temp_secret; + delete _user.two_factor_secret; + delete _user.username_lower; + if (_user.twitter) { + delete _user.twitter.access_token; + delete _user.twitter.access_token_secret; + } + delete _user.line; + + // Visible via only the official client + if (!opts.includeSecrets) { + delete _user.email; + delete _user.client_settings; + } + + if (!opts.detail) { + delete _user.two_factor_enabled; + } + + _user.avatar_url = _user.avatar_id != null + ? `${config.drive_url}/${_user.avatar_id}` + : `${config.drive_url}/default-avatar.jpg`; + + _user.banner_url = _user.banner_id != null + ? `${config.drive_url}/${_user.banner_id}` + : null; + + if (!meId || !meId.equals(_user.id) || !opts.detail) { + delete _user.avatar_id; + delete _user.banner_id; + + delete _user.drive_capacity; + } + + if (meId && !meId.equals(_user.id)) { + // Whether the user is following + _user.is_following = (async () => { + const follow = await Following.findOne({ + follower_id: meId, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + return follow !== null; + })(); + + // Whether the user is followed + _user.is_followed = (async () => { + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: meId, + deleted_at: { $exists: false } + }); + return follow2 !== null; + })(); + + // Whether the user is muted + _user.is_muted = (async () => { + const mute = await Mute.findOne({ + muter_id: meId, + mutee_id: _user.id, + deleted_at: { $exists: false } + }); + return mute !== null; + })(); + } + + if (opts.detail) { + if (_user.pinned_post_id) { + // Populate pinned post + _user.pinned_post = packPost(_user.pinned_post_id, meId, { + detail: true + }); + } + + if (meId && !meId.equals(_user.id)) { + const myFollowingIds = await getFriends(meId); + + // 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); +}); + +/* +function img(url) { + return { + thumbnail: { + large: `${url}`, + medium: '', + small: '' + } + }; +} +*/ diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts index 0ebf8d6aa..b49d25d99 100644 --- a/src/api/private/signin.ts +++ b/src/api/private/signin.ts @@ -1,16 +1,19 @@ import * as express from 'express'; import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; import { default as User, IUser } from '../models/user'; -import Signin from '../models/signin'; -import serialize from '../serializers/signin'; +import Signin, { pack } from '../models/signin'; import event from '../event'; import signin from '../common/signin'; +import config from '../../conf'; export default async (req: express.Request, res: express.Response) => { + res.header('Access-Control-Allow-Origin', config.url); res.header('Access-Control-Allow-Credentials', 'true'); const username = req.body['username']; const password = req.body['password']; + const token = req.body['token']; if (typeof username != 'string') { res.sendStatus(400); @@ -22,6 +25,11 @@ export default async (req: express.Request, res: express.Response) => { return; } + if (token != null && typeof token != 'string') { + res.sendStatus(400); + return; + } + // Fetch user const user: IUser = await User.findOne({ username_lower: username.toLowerCase() @@ -43,7 +51,23 @@ export default async (req: express.Request, res: express.Response) => { const same = await bcrypt.compare(password, user.password); if (same) { - signin(res, user, false); + if (user.two_factor_enabled) { + const verified = (speakeasy as any).totp.verify({ + secret: user.two_factor_secret, + encoding: 'base32', + token: token + }); + + if (verified) { + signin(res, user, false); + } else { + res.status(400).send({ + error: 'invalid token' + }); + } + } else { + signin(res, user, false); + } } else { res.status(400).send({ error: 'incorrect password' @@ -60,5 +84,5 @@ export default async (req: express.Request, res: express.Response) => { }); // Publish signin event - event(user._id, 'signin', await serialize(record)); + event(user._id, 'signin', await pack(record)); }; diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts index 466c6a489..8efdb6db4 100644 --- a/src/api/private/signup.ts +++ b/src/api/private/signup.ts @@ -2,9 +2,7 @@ import * as uuid from 'uuid'; import * as express from 'express'; import * as bcrypt from 'bcryptjs'; import recaptcha = require('recaptcha-promise'); -import { default as User, IUser } from '../models/user'; -import { validateUsername, validatePassword } from '../models/user'; -import serialize from '../serializers/user'; +import User, { IUser, validateUsername, validatePassword, pack } from '../models/user'; import generateUserToken from '../common/generate-native-user-token'; import config from '../../conf'; @@ -142,7 +140,7 @@ export default async (req: express.Request, res: express.Response) => { }); // Response - res.send(await serialize(account)); + res.send(await pack(account)); // Create search index if (config.elasticsearch.enable) { diff --git a/src/api/serializers/app.ts b/src/api/serializers/app.ts deleted file mode 100644 index 9d1c46dca..000000000 --- a/src/api/serializers/app.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import deepcopy = require('deepcopy'); -import App from '../models/app'; -import AccessToken from '../models/access-token'; -import config from '../../conf'; - -/** - * Serialize an app - * - * @param {any} app - * @param {any} me? - * @param {any} options? - * @return {Promise<any>} - */ -export default ( - app: any, - me?: any, - options?: { - includeSecret?: boolean, - includeProfileImageIds?: boolean - } -) => new Promise<any>(async (resolve, reject) => { - const opts = options || { - includeSecret: false, - includeProfileImageIds: false - }; - - let _app: any; - - // Populate the app if 'app' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(app)) { - _app = await App.findOne({ - _id: app - }); - } else if (typeof app === 'string') { - _app = await App.findOne({ - _id: new mongo.ObjectID(app) - }); - } else { - _app = deepcopy(app); - } - - // Me - if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { - if (typeof me === 'string') { - me = new mongo.ObjectID(me); - } else { - me = me._id; - } - } - - // Rename _id to id - _app.id = _app._id; - delete _app._id; - - delete _app.name_id_lower; - - // Visible by only owner - if (!opts.includeSecret) { - delete _app.secret; - } - - _app.icon_url = _app.icon != null - ? `${config.drive_url}/${_app.icon}` - : `${config.drive_url}/app-default.jpg`; - - if (me) { - // 既に連携しているか - const exist = await AccessToken.count({ - app_id: _app.id, - user_id: me, - }, { - limit: 1 - }); - - _app.is_authorized = exist === 1; - } - - resolve(_app); -}); diff --git a/src/api/serializers/auth-session.ts b/src/api/serializers/auth-session.ts deleted file mode 100644 index a9acf1243..000000000 --- a/src/api/serializers/auth-session.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import deepcopy = require('deepcopy'); -import serializeApp from './app'; - -/** - * Serialize an auth session - * - * @param {any} session - * @param {any} me? - * @return {Promise<any>} - */ -export default ( - session: any, - me?: any -) => new Promise<any>(async (resolve, reject) => { - let _session: any; - - // TODO: Populate session if it ID - - _session = deepcopy(session); - - // Me - if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { - if (typeof me === 'string') { - me = new mongo.ObjectID(me); - } else { - me = me._id; - } - } - - delete _session._id; - - // Populate app - _session.app = await serializeApp(_session.app_id, me); - - resolve(_session); -}); diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts deleted file mode 100644 index 3cba39aa1..000000000 --- a/src/api/serializers/channel.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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<any>(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 deleted file mode 100644 index dcdaa01fa..000000000 --- a/src/api/serializers/drive-file.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import DriveFile from '../models/drive-file'; -import serializeDriveFolder from './drive-folder'; -import serializeDriveTag from './drive-tag'; -import deepcopy = require('deepcopy'); -import config from '../../conf'; - -/** - * Serialize a drive file - * - * @param {any} file - * @param {any} options? - * @return {Promise<any>} - */ -export default ( - file: any, - options?: { - detail: boolean - } -) => new Promise<any>(async (resolve, reject) => { - const opts = Object.assign({ - detail: false - }, options); - - let _file: any; - - // Populate the file if 'file' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(file)) { - _file = await DriveFile.findOne({ - _id: file - }); - } else if (typeof file === 'string') { - _file = await DriveFile.findOne({ - _id: new mongo.ObjectID(file) - }); - } else { - _file = deepcopy(file); - } - - if (!_file) return reject('invalid file arg.'); - - // rendered target - let _target: any = {}; - - _target.id = _file._id; - _target.created_at = _file.uploadDate; - _target.name = _file.filename; - _target.type = _file.contentType; - _target.datasize = _file.length; - _target.md5 = _file.md5; - - _target = Object.assign(_target, _file.metadata); - - _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; - - if (opts.detail && _target.folder_id) { - // Populate folder - _target.folder = await serializeDriveFolder(_target.folder_id, { - detail: true - }); - } - - if (opts.detail && _target.tags) { - // Populate tags - _target.tags = await _target.tags.map(async (tag: any) => - await serializeDriveTag(tag) - ); - } - - resolve(_target); -}); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts deleted file mode 100644 index 6ebf454a2..000000000 --- a/src/api/serializers/drive-folder.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import DriveFolder from '../models/drive-folder'; -import DriveFile from '../models/drive-file'; -import deepcopy = require('deepcopy'); - -/** - * Serialize a drive folder - * - * @param {any} folder - * @param {any} options? - * @return {Promise<any>} - */ -const self = ( - folder: any, - options?: { - detail: boolean - } -) => new Promise<any>(async (resolve, reject) => { - const opts = Object.assign({ - detail: false - }, options); - - let _folder: any; - - // Populate the folder if 'folder' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { - _folder = await DriveFolder.findOne({ _id: folder }); - } else if (typeof folder === 'string') { - _folder = await DriveFolder.findOne({ _id: new mongo.ObjectID(folder) }); - } else { - _folder = deepcopy(folder); - } - - // Rename _id to id - _folder.id = _folder._id; - delete _folder._id; - - if (opts.detail) { - const childFoldersCount = await DriveFolder.count({ - parent_id: _folder.id - }); - - const childFilesCount = await DriveFile.count({ - 'metadata.folder_id': _folder.id - }); - - _folder.folders_count = childFoldersCount; - _folder.files_count = childFilesCount; - } - - if (opts.detail && _folder.parent_id) { - // Populate parent folder - _folder.parent = await self(_folder.parent_id, { - detail: true - }); - } - - resolve(_folder); -}); - -export default self; diff --git a/src/api/serializers/drive-tag.ts b/src/api/serializers/drive-tag.ts deleted file mode 100644 index 2f152381b..000000000 --- a/src/api/serializers/drive-tag.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import DriveTag from '../models/drive-tag'; -import deepcopy = require('deepcopy'); - -/** - * Serialize a drive tag - * - * @param {any} tag - * @return {Promise<any>} - */ -const self = ( - tag: any -) => new Promise<any>(async (resolve, reject) => { - let _tag: any; - - // Populate the tag if 'tag' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(tag)) { - _tag = await DriveTag.findOne({ _id: tag }); - } else if (typeof tag === 'string') { - _tag = await DriveTag.findOne({ _id: new mongo.ObjectID(tag) }); - } else { - _tag = deepcopy(tag); - } - - // Rename _id to id - _tag.id = _tag._id; - delete _tag._id; - - resolve(_tag); -}); - -export default self; diff --git a/src/api/serializers/messaging-message.ts b/src/api/serializers/messaging-message.ts deleted file mode 100644 index 4ab95e42a..000000000 --- a/src/api/serializers/messaging-message.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import deepcopy = require('deepcopy'); -import Message from '../models/messaging-message'; -import serializeUser from './user'; -import serializeDriveFile from './drive-file'; -import parse from '../common/text'; - -/** - * Serialize a message - * - * @param {any} message - * @param {any} me? - * @param {any} options? - * @return {Promise<any>} - */ -export default ( - message: any, - me?: any, - options?: { - populateRecipient: boolean - } -) => new Promise<any>(async (resolve, reject) => { - const opts = options || { - populateRecipient: true - }; - - let _message: any; - - // Populate the message if 'message' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(message)) { - _message = await Message.findOne({ - _id: message - }); - } else if (typeof message === 'string') { - _message = await Message.findOne({ - _id: new mongo.ObjectID(message) - }); - } else { - _message = deepcopy(message); - } - - // Rename _id to id - _message.id = _message._id; - delete _message._id; - - // Parse text - if (_message.text) { - _message.ast = parse(_message.text); - } - - // Populate user - _message.user = await serializeUser(_message.user_id, me); - - if (_message.file) { - // Populate file - _message.file = await serializeDriveFile(_message.file_id); - } - - if (opts.populateRecipient) { - // Populate recipient - _message.recipient = await serializeUser(_message.recipient_id, me); - } - - resolve(_message); -}); diff --git a/src/api/serializers/notification.ts b/src/api/serializers/notification.ts deleted file mode 100644 index ac919dc8b..000000000 --- a/src/api/serializers/notification.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import Notification from '../models/notification'; -import serializeUser from './user'; -import serializePost from './post'; -import deepcopy = require('deepcopy'); - -/** - * Serialize a notification - * - * @param {any} notification - * @return {Promise<any>} - */ -export default (notification: any) => new Promise<any>(async (resolve, reject) => { - let _notification: any; - - // Populate the notification if 'notification' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { - _notification = await Notification.findOne({ - _id: notification - }); - } else if (typeof notification === 'string') { - _notification = await Notification.findOne({ - _id: new mongo.ObjectID(notification) - }); - } else { - _notification = deepcopy(notification); - } - - // Rename _id to id - _notification.id = _notification._id; - delete _notification._id; - - // Rename notifier_id to user_id - _notification.user_id = _notification.notifier_id; - delete _notification.notifier_id; - - const me = _notification.notifiee_id; - delete _notification.notifiee_id; - - // Populate notifier - _notification.user = await serializeUser(_notification.user_id, me); - - switch (_notification.type) { - case 'follow': - // nope - break; - case 'mention': - case 'reply': - case 'repost': - case 'quote': - case 'reaction': - case 'poll_vote': - // Populate post - _notification.post = await serializePost(_notification.post_id, me); - break; - default: - console.error(`Unknown type: ${_notification.type}`); - break; - } - - resolve(_notification); -}); diff --git a/src/api/serializers/post-reaction.ts b/src/api/serializers/post-reaction.ts deleted file mode 100644 index b8807a741..000000000 --- a/src/api/serializers/post-reaction.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import deepcopy = require('deepcopy'); -import Reaction from '../models/post-reaction'; -import serializeUser from './user'; - -/** - * Serialize a reaction - * - * @param {any} reaction - * @param {any} me? - * @return {Promise<any>} - */ -export default ( - reaction: any, - me?: any -) => new Promise<any>(async (resolve, reject) => { - let _reaction: any; - - // Populate the reaction if 'reaction' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(reaction)) { - _reaction = await Reaction.findOne({ - _id: reaction - }); - } else if (typeof reaction === 'string') { - _reaction = await Reaction.findOne({ - _id: new mongo.ObjectID(reaction) - }); - } else { - _reaction = deepcopy(reaction); - } - - // Rename _id to id - _reaction.id = _reaction._id; - delete _reaction._id; - - // Populate user - _reaction.user = await serializeUser(_reaction.user_id, me); - - resolve(_reaction); -}); diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts deleted file mode 100644 index 03fd12077..000000000 --- a/src/api/serializers/post.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import deepcopy = require('deepcopy'); -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 post target - * @param me? serializee - * @param options? serialize options - * @return response - */ -const self = async ( - post: string | mongo.ObjectID | IPost, - me?: string | mongo.ObjectID | IUser, - options?: { - detail: boolean - } -) => { - 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 - if (mongo.ObjectID.prototype.isPrototypeOf(post)) { - _post = await Post.findOne({ - _id: post - }); - } else if (typeof post === 'string') { - _post = await Post.findOne({ - _id: new mongo.ObjectID(post) - }); - } else { - _post = deepcopy(post); - } - - if (!_post) throw 'invalid post arg.'; - - const id = _post._id; - - // Rename _id to id - _post.id = _post._id; - delete _post._id; - - delete _post.mentions; - - // Parse text - if (_post.text) { - _post.ast = parse(_post.text); - } - - // Populate user - _post.user = serializeUser(_post.user_id, meId); - - // Populate app - if (_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) { - _post.media = Promise.all(_post.media_ids.map(fileId => - serializeDriveFile(fileId) - )); - } - - // 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; - })(); - - // 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; - })(); - } - } - - // resolve promises in _post object - _post = await rap(_post); - - return _post; -}; - -export default self; diff --git a/src/api/serializers/signin.ts b/src/api/serializers/signin.ts deleted file mode 100644 index 406806767..000000000 --- a/src/api/serializers/signin.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Module dependencies - */ -import deepcopy = require('deepcopy'); - -/** - * Serialize a signin record - * - * @param {any} record - * @return {Promise<any>} - */ -export default ( - record: any -) => new Promise<any>(async (resolve, reject) => { - - const _record = deepcopy(record); - - // Rename _id to id - _record.id = _record._id; - delete _record._id; - - resolve(_record); -}); diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts deleted file mode 100644 index 3d8415660..000000000 --- a/src/api/serializers/user.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Module dependencies - */ -import * as mongo from 'mongodb'; -import deepcopy = require('deepcopy'); -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 user target - * @param me? serializee - * @param options? serialize options - * @return response - */ -export default ( - user: string | mongo.ObjectID | IUser, - me?: string | mongo.ObjectID | IUser, - options?: { - detail?: boolean, - includeSecrets?: boolean - } -) => new Promise<any>(async (resolve, reject) => { - - const opts = Object.assign({ - detail: false, - includeSecrets: false - }, options); - - let _user: any; - - const fields = opts.detail ? { - settings: false - } : { - settings: false, - client_settings: false, - profile: false, - keywords: false, - domains: false - }; - - // Populate the user if 'user' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(user)) { - _user = await User.findOne({ - _id: user - }, { fields }); - } else if (typeof user === 'string') { - _user = await User.findOne({ - _id: new mongo.ObjectID(user) - }, { fields }); - } else { - _user = deepcopy(user); - } - - if (!_user) return reject('invalid user arg.'); - - // 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; - - // Rename _id to id - _user.id = _user._id; - delete _user._id; - - // Remove needless properties - delete _user.latest_post; - - // Remove private properties - delete _user.password; - delete _user.token; - delete _user.username_lower; - if (_user.twitter) { - delete _user.twitter.access_token; - delete _user.twitter.access_token_secret; - } - delete _user.line; - - // Visible via only the official client - if (!opts.includeSecrets) { - delete _user.email; - delete _user.client_settings; - } - - _user.avatar_url = _user.avatar_id != null - ? `${config.drive_url}/${_user.avatar_id}` - : `${config.drive_url}/default-avatar.jpg`; - - _user.banner_url = _user.banner_id != null - ? `${config.drive_url}/${_user.banner_id}` - : null; - - if (!meId || !meId.equals(_user.id) || !opts.detail) { - delete _user.avatar_id; - delete _user.banner_id; - - delete _user.drive_capacity; - } - - if (meId && !meId.equals(_user.id)) { - // If the user is following - _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 - _user.is_followed = (async () => { - const follow2 = await Following.findOne({ - follower_id: _user.id, - followee_id: meId, - deleted_at: { $exists: false } - }); - return follow2 !== null; - })(); - } - - if (opts.detail) { - if (_user.pinned_post_id) { - // Populate pinned post - _user.pinned_post = serializePost(_user.pinned_post_id, meId, { - detail: true - }); - } - - if (meId && !meId.equals(_user.id)) { - const myFollowingIds = await getFriends(meId); - - // 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); -}); -/* -function img(url) { - return { - thumbnail: { - large: `${url}`, - medium: '', - small: '' - } - }; -} -*/ diff --git a/src/api/server.ts b/src/api/server.ts index 026357b46..e89d19609 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -26,9 +26,7 @@ app.use(bodyParser.json({ } } })); -app.use(cors({ - origin: true -})); +app.use(cors()); app.get('/', (req, res) => { res.send('YEE HAW'); @@ -49,13 +47,6 @@ endpoints.forEach(endpoint => app.post('/signup', require('./private/signup').default); app.post('/signin', require('./private/signin').default); -app.use((req, res, next) => { - // req.headers['cookie'] は常に string ですが、型定義の都合上 - // string | string[] になっているので string を明示しています - res.locals.user = ((req.headers['cookie'] as string || '').match(/i=(!\w+)/) || [null, null])[1]; - next(); -}); - require('./service/github')(app); require('./service/twitter')(app); diff --git a/src/api/service/twitter.ts b/src/api/service/twitter.ts index f164cdc45..adcd5ac49 100644 --- a/src/api/service/twitter.ts +++ b/src/api/service/twitter.ts @@ -5,27 +5,51 @@ import * as uuid from 'uuid'; // const Twitter = require('twitter'); import autwh from 'autwh'; import redis from '../../db/redis'; -import User from '../models/user'; -import serialize from '../serializers/user'; +import User, { pack } from '../models/user'; import event from '../event'; import config from '../../conf'; import signin from '../common/signin'; module.exports = (app: express.Application) => { + function getUserToken(req: express.Request) { + // req.headers['cookie'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + return ((req.headers['cookie'] as string || '').match(/i=(!\w+)/) || [null, null])[1]; + } + + function compareOrigin(req: express.Request) { + function normalizeUrl(url: string) { + return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url; + } + + // req.headers['referer'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const referer = req.headers['referer'] as string; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); + } + app.get('/disconnect/twitter', async (req, res): Promise<any> => { - if (res.locals.user == null) return res.send('plz signin'); + if (!compareOrigin(req)) { + res.status(400).send('invalid origin'); + return; + } + + const userToken = getUserToken(req); + if (userToken == null) return res.send('plz signin'); + const user = await User.findOneAndUpdate({ - token: res.locals.user + token: userToken }, { - $set: { - twitter: null - } - }); + $set: { + twitter: null + } + }); res.send(`Twitterの連携を解除しました :v:`); // Publish i updated event - event(user._id, 'i_updated', await serialize(user, user, { + event(user._id, 'i_updated', await pack(user, user, { detail: true, includeSecrets: true })); @@ -50,9 +74,16 @@ module.exports = (app: express.Application) => { }); app.get('/connect/twitter', async (req, res): Promise<any> => { - if (res.locals.user == null) return res.send('plz signin'); + if (!compareOrigin(req)) { + res.status(400).send('invalid origin'); + return; + } + + const userToken = getUserToken(req); + if (userToken == null) return res.send('plz signin'); + const ctx = await twAuth.begin(); - redis.set(res.locals.user, JSON.stringify(ctx)); + redis.set(userToken, JSON.stringify(ctx)); res.redirect(ctx.url); }); @@ -77,7 +108,9 @@ module.exports = (app: express.Application) => { }); app.get('/tw/cb', (req, res): any => { - if (res.locals.user == null) { + const userToken = getUserToken(req); + + if (userToken == null) { // req.headers['cookie'] は常に string ですが、型定義の都合上 // string | string[] になっているので string を明示しています const cookies = cookie.parse((req.headers['cookie'] as string || '')); @@ -86,6 +119,7 @@ module.exports = (app: express.Application) => { if (sessid == undefined) { res.status(400).send('invalid session'); + return; } redis.get(sessid, async (_, ctx) => { @@ -97,16 +131,24 @@ module.exports = (app: express.Application) => { if (user == null) { res.status(404).send(`@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; } signin(res, user, true); }); } else { - redis.get(res.locals.user, async (_, ctx) => { - const result = await twAuth.done(JSON.parse(ctx), req.query.oauth_verifier); + const verifier = req.query.oauth_verifier; + + if (verifier == null) { + res.status(400).send('invalid session'); + return; + } + + redis.get(userToken, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), verifier); const user = await User.findOneAndUpdate({ - token: res.locals.user + token: userToken }, { $set: { twitter: { @@ -121,7 +163,7 @@ module.exports = (app: express.Application) => { res.send(`Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`); // Publish i updated event - event(user._id, 'i_updated', await serialize(user, user, { + event(user._id, 'i_updated', await pack(user, user, { detail: true, includeSecrets: true })); diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts index 7c8f3bfec..10078337c 100644 --- a/src/api/stream/home.ts +++ b/src/api/stream/home.ts @@ -3,24 +3,53 @@ import * as redis from 'redis'; import * as debug from 'debug'; import User from '../models/user'; -import serializePost from '../serializers/post'; +import Mute from '../models/mute'; +import { pack as packPost } from '../models/post'; import readNotification from '../common/read-notification'; const log = debug('misskey'); -export default function homeStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { +export default async function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any) { // Subscribe Home stream channel subscriber.subscribe(`misskey:user-stream:${user._id}`); + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id.toString()); + subscriber.on('message', async (channel, data) => { switch (channel.split(':')[1]) { case 'user-stream': - connection.send(data); + try { + const x = JSON.parse(data); + + if (x.type == 'post') { + if (mutedUserIds.indexOf(x.body.user_id) != -1) { + return; + } + if (x.body.reply != null && mutedUserIds.indexOf(x.body.reply.user_id) != -1) { + return; + } + if (x.body.repost != null && mutedUserIds.indexOf(x.body.repost.user_id) != -1) { + return; + } + } else if (x.type == 'notification') { + if (mutedUserIds.indexOf(x.body.user_id) != -1) { + return; + } + } + + connection.send(data); + } catch (e) { + connection.send(data); + } break; case 'post-stream': const postId = channel.split(':')[2]; log(`RECEIVED: ${postId} ${data} by @${user.username}`); - const post = await serializePost(postId, user, { + const post = await packPost(postId, user, { detail: true }); connection.send(JSON.stringify({ diff --git a/src/common/build/fa.ts b/src/common/build/fa.ts new file mode 100644 index 000000000..0c21be950 --- /dev/null +++ b/src/common/build/fa.ts @@ -0,0 +1,57 @@ +/** + * Replace fontawesome symbols + */ + +import * as fontawesome from '@fortawesome/fontawesome'; +import * as regular from '@fortawesome/fontawesome-free-regular'; +import * as solid from '@fortawesome/fontawesome-free-solid'; +import * as brands from '@fortawesome/fontawesome-free-brands'; + +// Add icons +fontawesome.library.add(regular); +fontawesome.library.add(solid); +fontawesome.library.add(brands); + +export const pattern = /%fa:(.+?)%/g; + +export const replacement = (_, key) => { + const args = key.split(' '); + let prefix = 'fas'; + const classes = []; + let transform = ''; + let name; + + args.forEach(arg => { + if (arg == 'R' || arg == 'S' || arg == 'B') { + prefix = + arg == 'R' ? 'far' : + arg == 'S' ? 'fas' : + arg == 'B' ? 'fab' : + ''; + } else if (arg[0] == '.') { + classes.push('fa-' + arg.substr(1)); + } else if (arg[0] == '-') { + transform = arg.substr(1).split('|').join(' '); + } else { + name = arg; + } + }); + + const icon = fontawesome.icon({ prefix, iconName: name }, { + classes: classes + }); + + if (icon) { + icon.transform = fontawesome.parse.transform(transform); + return `<i data-fa class="${name}">${icon.html[0]}</i>`; + } else { + console.warn(`'${name}' not found in fa`); + return ''; + } +}; + +export default (src: string) => { + return src.replace(pattern, replacement); +}; + +export const fa = fontawesome; diff --git a/src/common/build/i18n.ts b/src/common/build/i18n.ts new file mode 100644 index 000000000..1ae22147c --- /dev/null +++ b/src/common/build/i18n.ts @@ -0,0 +1,50 @@ +/** + * Replace i18n texts + */ + +import locale from '../../../locales'; + +export default class Replacer { + private lang: string; + + public pattern = /"%i18n:(.+?)%"|'%i18n:(.+?)%'|%i18n:(.+?)%/g; + + constructor(lang: string) { + this.lang = lang; + + this.get = this.get.bind(this); + this.replacement = this.replacement.bind(this); + } + + private get(key: string) { + let text = locale[this.lang]; + + // Check the key existance + const error = key.split('.').some(k => { + if (text.hasOwnProperty(k)) { + text = text[k]; + return false; + } else { + return true; + } + }); + + if (error) { + console.warn(`key '${key}' not found in '${this.lang}'`); + return key; // Fallback + } else { + return text; + } + } + + public replacement(match, a, b, c) { + const key = a || b || c; + if (match[0] == '"') { + return '"' + this.get(key).replace(/"/g, '\\"') + '"'; + } else if (match[0] == "'") { + return '\'' + this.get(key).replace(/'/g, '\\\'') + '\''; + } else { + return this.get(key); + } + } +} diff --git a/src/common/build/license.ts b/src/common/build/license.ts new file mode 100644 index 000000000..e5c264df8 --- /dev/null +++ b/src/common/build/license.ts @@ -0,0 +1,13 @@ +import * as fs from 'fs'; + +const license = fs.readFileSync(__dirname + '/../../../LICENSE', 'utf-8'); + +const licenseHtml = license + .replace(/\r\n/g, '\n') + .replace(/(.)\n(.)/g, '$1 $2') + .replace(/(^|\n)(.*?)($|\n)/g, '<p>$2</p>'); + +export { + license, + licenseHtml +}; diff --git a/src/config.ts b/src/config.ts index 3ff800758..3ffefe278 100644 --- a/src/config.ts +++ b/src/config.ts @@ -101,7 +101,7 @@ type Mixin = { secondary_scheme: string; api_url: string; auth_url: string; - about_url: string; + docs_url: string; ch_url: string; stats_url: string; status_url: string; @@ -131,7 +131,7 @@ export default function load() { 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.docs_url = `${mixin.scheme}://docs.${mixin.host}`; mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`; mixin.status_url = `${mixin.scheme}://status.${mixin.host}`; mixin.drive_url = `${mixin.secondary_scheme}://file.${mixin.secondary_host}`; diff --git a/src/const.json b/src/const.json index 924b4dd8b..d8fe4fe6c 100644 --- a/src/const.json +++ b/src/const.json @@ -1,4 +1,5 @@ { + "copyright": "Copyright (c) 2014-2018 syuilo", "themeColor": "#ff4e45", "themeColorForeground": "#fff" } diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts index c978e6460..233f2f3d7 100644 --- a/src/db/mongodb.ts +++ b/src/db/mongodb.ts @@ -1,13 +1,16 @@ 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}`; +const u = config.mongodb.user ? encodeURIComponent(config.mongodb.user) : null; +const p = config.mongodb.pass ? encodeURIComponent(config.mongodb.pass) : null; + +const uri = u && p + ? `mongodb://${u}:${p}@${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'; +import mongo from 'monk'; const db = mongo(uri); @@ -24,9 +27,9 @@ const nativeDbConn = async (): Promise<mongodb.Db> => { if (mdb) return mdb; const db = await ((): Promise<mongodb.Db> => new Promise((resolve, reject) => { - mongodb.MongoClient.connect(uri, (e, db) => { + (mongodb as any).MongoClient.connect(uri, (e, client) => { if (e) return reject(e); - resolve(db); + resolve(client.db(config.mongodb.db)); }); }))(); diff --git a/src/docs/api/entities/post.pug b/src/docs/api/entities/post.pug deleted file mode 100644 index 954f17271..000000000 --- a/src/docs/api/entities/post.pug +++ /dev/null @@ -1,149 +0,0 @@ -extend ../../BASE - -block title - | Entity: Post - -block content - h1 Post - p 投稿を表します。 - - section - h2 Properties - table.entity - thead: tr - td Name - td Type - td Description - tbody - tr.nullable.optional - td app - td: a(href='./app', target='_blank') App - td 投稿したアプリ - tr.nullable - td app_id - td ID - td 投稿したアプリのID - tr - td created_at - td Date - td 投稿日時 - tr - td id - td ID - td 投稿ID - tr.optional - td is_liked - td Boolean - td いいね したかどうか - tr - td likes_count - td Number - td いいね数 - tr.nullable.optional - td media_ids - td ID[] - td 添付されたメディアのIDの配列 - tr.nullable.optional - td media - td: a(href='./drive-file', target='_blank') DriveFile[] - td 添付されたメディアの配列 - tr - td replies_count - td Number - td 返信数 - tr.optional - td reply - td: a(href='./post', target='_blank') Post - td 返信先の投稿 - tr.nullable - td reply_id - td ID - td 返信先の投稿のID - tr.optional - td repost - td: a(href='./post', target='_blank') Post - td Repostした投稿 - tr - td repost_count - td Number - td Repostされた数 - tr.nullable - td repost_id - td ID - td Repostした投稿のID - tr.nullable - td text - td String - td 本文 - tr.optional - td user - td: a(href='./user', target='_blank') User - td 投稿者 - tr - td user_id - td ID - td 投稿者のID - - section - h2 Example - pre: code. - { - "created_at": "2016-12-10T00:28:50.114Z", - "media_ids": null, - "reply_id": "584a16b15860fc52320137e3", - "repost_id": null, - "text": "小日向美穂だぞ!", - "user_id": "5848bf7764e572683f4402f8", - "app_id": null, - "likes_count": 1, - "replies_count": 1, - "id": "584b4c42d8e5186f8f755d0c", - "user": { - "birthday": null, - "created_at": "2016-12-08T02:03:35.332Z", - "bio": "女が嫌いです、女性は好きです", - "followers_count": 11, - "following_count": 11, - "links": null, - "location": "", - "name": "女が嫌い", - "posts_count": 26, - "likes_count": 2, - "liked_count": 20, - "username": "onnnagakirai", - "id": "5848bf7764e572683f4402f8", - "avatar_url": "https://file.himasaku.net/5848c0ec64e572683f4402fc", - "banner_url": "https://file.himasaku.net/5848c12864e572683f4402fd", - "is_following": true, - "is_followed": true - }, - "reply": { - "created_at": "2016-12-09T02:28:01.563Z", - "media_ids": null, - "reply_id": "5849d35e547e4249be329884", - "repost_id": null, - "text": "アイコン小日向美穂?", - "user_id": "57d01a501fdf2d07be417afe", - "app_id": null, - "replies_count": 1, - "id": "584a16b15860fc52320137e3", - "user": { - "birthday": null, - "created_at": "2016-09-07T13:46:56.605Z", - "bio": "どうすれば君だけのために生きていけるの", - "followers_count": 51, - "following_count": 97, - "links": null, - "location": "川崎", - "name": "きな子", - "posts_count": 4813, - "username": "syuilo", - "likes_count": 3141, - "liked_count": 750, - "id": "57d01a501fdf2d07be417afe", - "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", - "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5" - } - }, - "is_liked": true - } diff --git a/src/docs/api/entities/user.pug b/src/docs/api/entities/user.pug deleted file mode 100644 index a37886bb1..000000000 --- a/src/docs/api/entities/user.pug +++ /dev/null @@ -1,122 +0,0 @@ -extend ../../BASE - -block title - | Entity: User - -block content - h1 User - p ユーザーを表します。 - - section - h2 Properties - table.entity - thead: tr - td Name - td Type - td Description - tbody - tr.nullable.optional - td avatar_id - td ID - td アバターに設定しているドライブのファイルのID - tr.nullable - td avatar_url - td String - td アバターURL - tr.nullable.optional - td banner_id - td ID - td バナーに設定しているドライブのファイルのID - tr.nullable - td banner_url - td String - td バナーURL - tr.nullable - td bio - td String - td プロフィール - tr.nullable - td birthday - td String - td 誕生日(YYYY-MM-DD) - tr - td created_at - td Date - td アカウント作成日時 - tr.optional - td drive_capacity - td Number - td ドライブの最大容量(byte単位) - tr - td followers_count - td Number - td フォロワー数 - tr - td following_count - td Number - td フォロー数 - tr - td id - td ID - td ユーザーID - tr.optional - td is_bot - td Boolean - td botかどうか - tr.optional - td is_followed - td Boolean - td フォローされているか - tr.optional - td is_following - td Boolean - td フォローしているか - tr - td liked_count - td Number - td 投稿にいいねされた数 - tr - td likes_count - td Number - td 投稿にいいねした数 - tr.nullable - td location - td String - td 住処 - tr - td name - td String - td ニックネーム - tr - td posts_count - td Number - td 投稿数 - tr - td username - td String - td ユーザー名 - - section - h2 Example - pre: code. - { - "avatar_id": "583ddc6e64df272771f74c1a", - "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", - "banner_id": "584bfc82d8e5186f8f755ec5", - "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5", - "bio": "どうすれば君だけのために生きていけるの", - "birthday": "1997-12-06", - "created_at": "2016-09-07T13:46:56.605Z", - "drive_capacity": 1073741824, - "email": null, - "followers_count": 51, - "following_count": 97, - "id": "57d01a501fdf2d07be417afe", - "liked_count": 750, - "likes_count": 3130, - "links": null, - "location": "川崎", - "name": "きな子", - "posts_count": 4811, - "username": "syuilo" - } diff --git a/src/docs/api/getting-started.md b/src/docs/api/getting-started.md deleted file mode 100644 index e13659914..000000000 --- a/src/docs/api/getting-started.md +++ /dev/null @@ -1,73 +0,0 @@ -Getting Started -================================================================ -MisskeyはREST APIやStreaming APIを提供しており、プログラムからMisskeyの全ての機能を利用することができます。 -それらのAPIを利用するには、まずAPIを利用したいアカウントのアクセストークンを取得する必要があります: - -自分のアクセストークンを取得したい場合 ----------------------------------------------------------------- -自分自身のアクセストークンは、設定 > API で確認できます。 -<p class="tip"> - アカウントを乗っ取られてしまう可能性があるため、トークンは第三者に教えないでください(アプリなどにも入力しないでください)。<br> - 万が一トークンが漏れたりその可能性がある場合は トークンを再生成できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) -</p> - -他人のアクセストークンを取得する ----------------------------------------------------------------- -不特定多数のユーザーからAPIを利用したい場合、アプリケーションを作成します。 -アプリケーションを作成すると、ユーザーが連携を許可した時に、そのユーザーのアクセストークンを取得することができます。 - -アプリケーションを作成してアクセストークンを取得するまでの流れを説明します。 - -### アプリケーションを作成する -まずはあなたのアプリケーションを作成しましょう。 - | <a href=#{dev_url} target="_blank">デベロッパーセンター</a>にアクセスし、アプリ > アプリ作成 に進みます。 - br - | 次に、フォームに必要事項を記入します: - dl - dt アプリケーション名 - dd あなたのアプリケーションの名前。 - dt Named ID - dd アプリを識別する/a-z-/で構成されたID。 - dt アプリの概要 - dd アプリの簡単な説明を入力してください。 - dt コールバックURL - dd あなたのアプリケーションがWebアプリケーションである場合、ユーザーが後述するフォームで認証を終えた際にリダイレクトするURLを設定できます。 - dt 権限 - dd アプリケーションが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 - p.tip - | 権限はアプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーはすべて無効になります。 - p - | アプリケーションを作成すると、作ったアプリの管理ページに進みます。 - br - | アプリのシークレットキー(App Secret)が表示されていますので、メモしておいてください。 - p.tip - | アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。 - - section - h3 ユーザーに認証させる - p あなたのアプリを使ってもらうには、ユーザーにアカウントへアクセスすることを許可してもらい、Misskeyにそのユーザーのアクセストークンを発行してもらう必要があります。 - p 認証セッションを開始するには、<code>#{api_url}/auth/session/generate</code>へパラメータに<code>app_secret</code>としてApp Secretを含めたリクエストを送信します。 - p - | そうすると、レスポンスとして認証セッションのトークンや認証フォームのURLが取得できます。 - br - | この認証フォームのURLをブラウザで表示し、ユーザーにフォームを表示してください。 - section - h4 あなたのアプリがコールバックURLを設定している場合 - p ユーザーがアプリの連携を許可すると設定しているコールバックURLに<code>token</code>という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 - section - h4 あなたのアプリがコールバックURLを設定していない場合 - p ユーザーがアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 - p - | 次に、<code>#{api_url}/auth/session/userkey</code>へ<code>app_secret</code>としてApp Secretを、<code>token</code>としてセッションのトークンをパラメータとして付与したリクエストを送信してください。 - br - | 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! - p - | 以降アクセストークンは、<strong>ユーザーのアクセストークン+アプリのシークレットキーをsha256したもの</strong>として扱います。 - - p アクセストークンを取得できたら、あとは簡単です。REST APIなら、リクエストにアクセストークンを<code>i</code>としてパラメータに含めるだけです。 - - section - h2 リクエスト形式 - p <code>application/json</code>を受け付けます。 - p.tip - | 現在<code>application/x-www-form-urlencoded</code>も受け付けていますが、将来的にこのサポートはされなくなる予定です。 diff --git a/src/docs/api/library.md b/src/docs/api/library.md deleted file mode 100644 index 71ddbe345..000000000 --- a/src/docs/api/library.md +++ /dev/null @@ -1,8 +0,0 @@ -ライブラリ -================================================================ - -Misskey APIを便利に利用するためのライブラリ一覧です。 - -.NET ----------------------------------------------------------------- -* **[Misq (公式)](https://github.com/syuilo/Misq)** diff --git a/src/docs/index.md b/src/docs/index.md deleted file mode 100644 index 0846cf27e..000000000 --- a/src/docs/index.md +++ /dev/null @@ -1,4 +0,0 @@ -Misskeyについて -================================================================ - -誰か書いて diff --git a/src/docs/link-to-twitter.md b/src/docs/link-to-twitter.md deleted file mode 100644 index 77fb74457..000000000 --- a/src/docs/link-to-twitter.md +++ /dev/null @@ -1,9 +0,0 @@ -Twitterと連携する -================================================================ - -設定 -> Twitter から、お使いのMisskeyアカウントとお使いのTwitterアカウントを関連付けることができます。 -アカウントの関連付けを行うと、プロフィールにTwitterアカウントへのリンクが表示されたりなどします。 - -MisskeyがあなたのTwitterアカウントでツイートしたり誰かをフォローしたりといったことは、 -一切行いませんのでご安心ください。(Misskeyはそのような権限を取得しないので、行おうと思っても行えません) -Twitterのアプリケーション認証フォームでこの権限の詳細を確認することができます。また、いつでも連携を取り消すことができます。 diff --git a/src/docs/tou.md b/src/docs/tou.md deleted file mode 100644 index fbf87867b..000000000 --- a/src/docs/tou.md +++ /dev/null @@ -1,4 +0,0 @@ -利用規約 -================================================================ - -公序良俗に反する行為はおやめください。 diff --git a/src/file/server.ts b/src/file/server.ts index 1f8d21b80..3bda5b14f 100644 --- a/src/file/server.ts +++ b/src/file/server.ts @@ -7,11 +7,15 @@ import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as cors from 'cors'; import * as mongodb from 'mongodb'; -import * as gm from 'gm'; +import * as _gm from 'gm'; import * as stream from 'stream'; import DriveFile, { getGridFSBucket } from '../api/models/drive-file'; +const gm = _gm.subClass({ + imageMagick: true +}); + /** * Init app */ @@ -78,6 +82,8 @@ function thumbnail(data: stream.Readable, type: string, resize: number): ISend { const stream = g .compress('jpeg') .quality(80) + .interlace('line') + .noProfile() // Remove EXIF .stream(); return { @@ -130,6 +136,8 @@ async function sendFileById(req: express.Request, res: express.Response): Promis } const fileId = new mongodb.ObjectID(req.params.id); + + // Fetch (drive) file const file = await DriveFile.findOne({ _id: fileId }); // validate name diff --git a/src/utils/dependencyInfo.ts b/src/utils/dependencyInfo.ts index 818fa3136..89af0d20f 100644 --- a/src/utils/dependencyInfo.ts +++ b/src/utils/dependencyInfo.ts @@ -11,7 +11,7 @@ export default class { public showAll(): void { this.show('MongoDB', 'mongo --version', x => x.match(/^MongoDB shell version:? (.*)\r?\n/)); this.show('Redis', 'redis-server --version', x => x.match(/v=([0-9\.]*)/)); - this.show('GraphicsMagick', 'gm -version', x => x.match(/^GraphicsMagick ([0-9\.]*) .*/)); + this.show('ImageMagick', 'magick -version', x => x.match(/^Version: ImageMagick (.+?)\r?\n/)); } public show(serviceName: string, command: string, transform: (x: string) => RegExpMatchArray): void { diff --git a/src/web/app/app.styl b/src/web/app/app.styl index de66df74d..22043b883 100644 --- a/src/web/app/app.styl +++ b/src/web/app/app.styl @@ -1,29 +1,4 @@ -json('../../const.json') - -@charset 'utf-8' - -$theme-color = themeColor -$theme-color-foreground = themeColorForeground - -/* - ::selection - background $theme-color - color #fff -*/ - -* - 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) - -html, body - margin 0 - padding 0 - scroll-behavior smooth - text-size-adjust 100% - font-family sans-serif +@import "../style" html &.progress @@ -96,17 +71,6 @@ body 100% transform rotate(360deg) -a - text-decoration none - color $theme-color - cursor pointer - - &:hover - text-decoration underline - - * - cursor pointer - code font-family Consolas, 'Courier New', Courier, Monaco, monospace diff --git a/src/web/app/base.pug b/src/web/app/base.pug index 140286a76..d7c7f0aed 100644 --- a/src/web/app/base.pug +++ b/src/web/app/base.pug @@ -24,6 +24,9 @@ html //- FontAwesome style style #{facss} + //- highlight.js style + style #{hljscss} + body noscript: p | JavaScriptを有効にしてください diff --git a/src/web/app/common/scripts/api.ts b/src/web/app/common/scripts/api.ts index 2008e6f5a..bba838f56 100644 --- a/src/web/app/common/scripts/api.ts +++ b/src/web/app/common/scripts/api.ts @@ -40,7 +40,7 @@ export default (i, endpoint, data = {}): Promise<{ [x: string]: any }> => { } else { res.json().then(err => { reject(err.error); - }); + }, reject); } }).catch(reject); }); diff --git a/src/web/app/common/scripts/parse-search-query.ts b/src/web/app/common/scripts/parse-search-query.ts new file mode 100644 index 000000000..512791ecb --- /dev/null +++ b/src/web/app/common/scripts/parse-search-query.ts @@ -0,0 +1,53 @@ +export default function(qs: string) { + const q = { + text: '' + }; + + qs.split(' ').forEach(x => { + if (/^([a-z_]+?):(.+?)$/.test(x)) { + const [key, value] = x.split(':'); + switch (key) { + case 'user': + q['include_user_usernames'] = value.split(','); + break; + case 'exclude_user': + q['exclude_user_usernames'] = value.split(','); + break; + case 'follow': + q['following'] = value == 'null' ? null : value == 'true'; + break; + case 'reply': + q['reply'] = value == 'null' ? null : value == 'true'; + break; + case 'repost': + q['repost'] = value == 'null' ? null : value == 'true'; + break; + case 'media': + q['media'] = value == 'null' ? null : value == 'true'; + break; + case 'poll': + q['poll'] = value == 'null' ? null : value == 'true'; + break; + case 'until': + case 'since': + // YYYY-MM-DD + if (/^[0-9]+\-[0-9]+\-[0-9]+$/) { + const [yyyy, mm, dd] = value.split('-'); + q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime(); + } + break; + default: + q[key] = value; + break; + } + } else { + q.text += x + ' '; + } + }); + + if (q.text) { + q.text = q.text.trim(); + } + + return q; +} diff --git a/src/web/app/common/tags/authorized-apps.tag b/src/web/app/common/tags/authorized-apps.tag index 0078a1863..0594032de 100644 --- a/src/web/app/common/tags/authorized-apps.tag +++ b/src/web/app/common/tags/authorized-apps.tag @@ -1,5 +1,7 @@ <mk-authorized-apps> - <p class="none" if={ !fetching && apps.length == 0 }>%i18n:common.tags.mk-authorized-apps.no-apps%</p> + <div class="none ui info" if={ !fetching && apps.length == 0 }> + <p>%fa:info-circle%%i18n:common.tags.mk-authorized-apps.no-apps%</p> + </div> <div class="apps" if={ apps.length != 0 }> <div each={ app in apps }> <p><b>{ app.name }</b></p> diff --git a/src/web/app/common/tags/copyright.tag b/src/web/app/common/tags/copyright.tag deleted file mode 100644 index 9c3f1f648..000000000 --- a/src/web/app/common/tags/copyright.tag +++ /dev/null @@ -1,7 +0,0 @@ -<mk-copyright> - <span>(c) syuilo 2014-2017</span> - <style> - :scope - display block - </style> -</mk-copyright> diff --git a/src/web/app/common/tags/index.ts b/src/web/app/common/tags/index.ts index 2f4e1181d..df99d93cc 100644 --- a/src/web/app/common/tags/index.ts +++ b/src/web/app/common/tags/index.ts @@ -12,7 +12,6 @@ require('./signin.tag'); require('./signup.tag'); require('./forkit.tag'); require('./introduction.tag'); -require('./copyright.tag'); require('./signin-history.tag'); require('./twitter-setting.tag'); require('./authorized-apps.tag'); diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag index 3256688d1..28afc6fa4 100644 --- a/src/web/app/common/tags/introduction.tag +++ b/src/web/app/common/tags/introduction.tag @@ -3,7 +3,7 @@ <h1>Misskeyとは?</h1> <p><ruby>Misskey<rt>みすきー</rt></ruby>は、<a href="http://syuilo.com" target="_blank">syuilo</a>が2014年くらいから<a href="https://github.com/syuilo/misskey" target="_blank">オープンソースで</a>開発・運営を行っている、ミニブログベースのSNSです。</p> <p>無料で誰でも利用でき、広告も掲載していません。</p> - <p><a href={ _ABOUT_URL_ } target="_blank">もっと知りたい方はこちら</a></p> + <p><a href={ _DOCS_URL_ } target="_blank">もっと知りたい方はこちら</a></p> </article> <style> :scope diff --git a/src/web/app/common/tags/messaging/room.tag b/src/web/app/common/tags/messaging/room.tag index a149e1de2..7b4d1be56 100644 --- a/src/web/app/common/tags/messaging/room.tag +++ b/src/web/app/common/tags/messaging/room.tag @@ -254,7 +254,7 @@ this.api('messaging/messages', { user_id: this.user.id, limit: max + 1, - max_id: this.moreMessagesIsInStock ? this.messages[0].id : undefined + until_id: this.moreMessagesIsInStock ? this.messages[0].id : undefined }).then(messages => { if (messages.length == max + 1) { this.moreMessagesIsInStock = true; diff --git a/src/web/app/common/tags/nav-links.tag b/src/web/app/common/tags/nav-links.tag index 71f0453db..ea122575a 100644 --- a/src/web/app/common/tags/nav-links.tag +++ b/src/web/app/common/tags/nav-links.tag @@ -1,7 +1,10 @@ <mk-nav-links> - <a href={ _ABOUT_URL_ }>%i18n:common.tags.mk-nav-links.about%</a><i>・</i><a href={ _STATS_URL_ }>%i18n:common.tags.mk-nav-links.stats%</a><i>・</i><a href={ _STATUS_URL_ }>%i18n:common.tags.mk-nav-links.status%</a><i>・</i><a href="http://zawazawa.jp/misskey/">%i18n:common.tags.mk-nav-links.wiki%</a><i>・</i><a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:common.tags.mk-nav-links.donors%</a><i>・</i><a href="https://github.com/syuilo/misskey">%i18n:common.tags.mk-nav-links.repository%</a><i>・</i><a href={ _DEV_URL_ }>%i18n:common.tags.mk-nav-links.develop%</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a> + <a href={ aboutUrl }>%i18n:common.tags.mk-nav-links.about%</a><i>・</i><a href={ _STATS_URL_ }>%i18n:common.tags.mk-nav-links.stats%</a><i>・</i><a href={ _STATUS_URL_ }>%i18n:common.tags.mk-nav-links.status%</a><i>・</i><a href="http://zawazawa.jp/misskey/">%i18n:common.tags.mk-nav-links.wiki%</a><i>・</i><a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:common.tags.mk-nav-links.donors%</a><i>・</i><a href="https://github.com/syuilo/misskey">%i18n:common.tags.mk-nav-links.repository%</a><i>・</i><a href={ _DEV_URL_ }>%i18n:common.tags.mk-nav-links.develop%</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a> <style> :scope display inline </style> + <script> + this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/about`; + </script> </mk-nav-links> diff --git a/src/web/app/common/tags/signin-history.tag b/src/web/app/common/tags/signin-history.tag index 03afd7232..cdd58c4c6 100644 --- a/src/web/app/common/tags/signin-history.tag +++ b/src/web/app/common/tags/signin-history.tag @@ -1,55 +1,11 @@ <mk-signin-history> <div class="records" if={ history.length != 0 }> - <div each={ history }> - <mk-time time={ created_at }/> - <header> - <virtual if={ success }>%fa:check%</virtual> - <virtual if={ !success }>%fa:times%</virtual> - <span class="ip">{ ip }</span> - </header> - <pre><code>{ JSON.stringify(headers, null, ' ') }</code></pre> - </div> + <mk-signin-record each={ rec in history } rec={ rec }/> </div> <style> :scope display block - > .records - > div - padding 16px 0 0 0 - border-bottom solid 1px #eee - - > header - - > [data-fa] - margin-right 8px - - &.check - color #0fda82 - - &.times - color #ff3100 - - > .ip - display inline-block - color #444 - background #f8f8f8 - - > mk-time - position absolute - top 16px - right 0 - color #777 - - > pre - overflow auto - max-height 100px - - > code - white-space pre-wrap - word-break break-all - color #4a535a - </style> <script> this.mixin('i'); @@ -84,3 +40,77 @@ }; </script> </mk-signin-history> + +<mk-signin-record> + <header onclick={ toggle }> + <virtual if={ rec.success }>%fa:check%</virtual> + <virtual if={ !rec.success }>%fa:times%</virtual> + <span class="ip">{ rec.ip }</span> + <mk-time time={ rec.created_at }/> + </header> + <pre ref="headers" class="json" show={ show }>{ JSON.stringify(rec.headers, null, 2) }</pre> + + <style> + :scope + display block + border-bottom solid 1px #eee + + > header + display flex + padding 8px 0 + line-height 32px + cursor pointer + + > [data-fa] + margin-right 8px + text-align left + + &.check + color #0fda82 + + &.times + color #ff3100 + + > .ip + display inline-block + text-align left + padding 8px + line-height 16px + font-family monospace + font-size 14px + color #444 + background #f8f8f8 + border-radius 4px + + > mk-time + margin-left auto + text-align right + color #777 + + > pre + overflow auto + margin 0 0 16px 0 + max-height 100px + white-space pre-wrap + word-break break-all + color #4a535a + + </style> + + <script> + import hljs from 'highlight.js'; + + this.rec = this.opts.rec; + this.show = false; + + this.on('mount', () => { + hljs.highlightBlock(this.refs.headers); + }); + + this.toggle = () => { + this.update({ + show: !this.show + }); + }; + </script> +</mk-signin-record> diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag index f25d99974..f5a2be94e 100644 --- a/src/web/app/common/tags/signin.tag +++ b/src/web/app/common/tags/signin.tag @@ -6,6 +6,9 @@ <label class="password"> <input ref="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required="required"/>%fa:lock% </label> + <label class="token" if={ user && user.two_factor_enabled }> + <input ref="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required="required"/>%fa:lock% + </label> <button type="submit" disabled={ signing }>{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }</button> </form> <style> @@ -39,6 +42,7 @@ input[type=text] input[type=password] + input[type=number] user-select text display inline-block cursor auto @@ -123,6 +127,10 @@ this.refs.password.focus(); return false; } + if (this.user && this.user.two_factor_enabled && this.refs.token.value == '') { + this.refs.token.focus(); + return false; + } this.update({ signing: true @@ -130,7 +138,8 @@ this.api('signin', { username: this.refs.username.value, - password: this.refs.password.value + password: this.refs.password.value, + token: this.user && this.user.two_factor_enabled ? this.refs.token.value : undefined }).then(() => { location.reload(); }).catch(() => { diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag index 4816fe66d..b488efb92 100644 --- a/src/web/app/common/tags/signup.tag +++ b/src/web/app/common/tags/signup.tag @@ -34,7 +34,7 @@ </label> <label class="agree-tou"> <input name="agree-tou" type="checkbox" autocomplete="off" required="required"/> - <p><a href="https://github.com/syuilo/misskey/blob/master/src/docs/tou.md" target="_blank">利用規約</a>に同意する</p> + <p><a href={ touUrl } target="_blank">利用規約</a>に同意する</p> </label> <button onclick={ onsubmit }>%i18n:common.tags.mk-signup.create%</button> </form> @@ -182,6 +182,8 @@ this.passwordRetypeState = null; this.recaptchaed = false; + this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/tou`; + window.onRecaptchaed = () => { this.recaptchaed = true; this.update(); diff --git a/src/web/app/common/tags/twitter-setting.tag b/src/web/app/common/tags/twitter-setting.tag index 3b70505ba..4d57cfa55 100644 --- a/src/web/app/common/tags/twitter-setting.tag +++ b/src/web/app/common/tags/twitter-setting.tag @@ -1,5 +1,5 @@ <mk-twitter-setting> - <p>%i18n:common.tags.mk-twitter-setting.description%<a href={ _ABOUT_URL_ + '/link-to-twitter' } target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> + <p>%i18n:common.tags.mk-twitter-setting.description%<a href={ _DOCS_URL_ + '/link-to-twitter' } target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> <p class="account" if={ I.twitter } title={ 'Twitter ID: ' + I.twitter.user_id }>%i18n:common.tags.mk-twitter-setting.connected-to%: <a href={ 'https://twitter.com/' + I.twitter.screen_name } target="_blank">@{ I.twitter.screen_name }</a></p> <p> <a href={ _API_URL_ + '/connect/twitter' } target="_blank" onclick={ connect }>{ I.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }</a> diff --git a/src/web/app/desktop/router.ts b/src/web/app/desktop/router.ts index 27b63ab2e..ce68c4f2d 100644 --- a/src/web/app/desktop/router.ts +++ b/src/web/app/desktop/router.ts @@ -16,7 +16,7 @@ export default (mios: MiOS) => { route('/i/messaging/:user', messaging); route('/i/mentions', mentions); route('/post::post', post); - route('/search::query', search); + route('/search', search); route('/:user', user.bind(null, 'home')); route('/:user/graphs', user.bind(null, 'graphs')); route('/:user/:post', post); @@ -47,7 +47,7 @@ export default (mios: MiOS) => { function search(ctx) { const el = document.createElement('mk-search-page'); - el.setAttribute('query', ctx.params.query); + el.setAttribute('query', ctx.querystring.substr(2)); mount(el); } diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl index d99e5df2b..c893e2ed6 100644 --- a/src/web/app/desktop/style.styl +++ b/src/web/app/desktop/style.styl @@ -2,6 +2,8 @@ @import "../reset" @import "../../../../node_modules/cropperjs/dist/cropper.css" +@import "./ui" + *::input-placeholder color #D8CBC5 @@ -47,66 +49,3 @@ html #wait right auto left 15px - -button - font-family sans-serif - - * - pointer-events none - - &.style-normal - &.style-primary - display block - cursor pointer - padding 0 16px - margin 0 - min-width 100px - height 40px - font-size 1em - outline none - border-radius 4px - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid rgba($theme-color, 0.3) - border-radius 8px - - &:disabled - opacity 0.7 - cursor default - - &.style-normal - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 - - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc - - &:active - background #ececec - border-color #dcdcdc - - &.style-primary - color $theme-color-foreground - background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) - border solid 1px lighten($theme-color, 15%) - - &:not(:disabled) - font-weight bold - - &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color - - &:active:not(:disabled) - background $theme-color - border-color $theme-color diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag index 901daabfd..a60a46b79 100644 --- a/src/web/app/desktop/tags/drive/browser.tag +++ b/src/web/app/desktop/tags/drive/browser.tag @@ -18,14 +18,16 @@ <virtual each={ folder in folders }> <mk-drive-browser-folder class="folder" folder={ folder }/> </virtual> - <div class="padding" each={ folders }></div> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" each={ Array(10).fill(16) }></div> <button if={ moreFolders }>%i18n:desktop.tags.mk-drive-browser.load-more%</button> </div> <div class="files" ref="filesContainer" if={ files.length > 0 }> <virtual each={ file in files }> <mk-drive-browser-file class="file" file={ file }/> </virtual> - <div class="padding" each={ files }></div> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" each={ Array(10).fill(16) }></div> <button if={ moreFiles } onclick={ fetchMoreFiles }>%i18n:desktop.tags.mk-drive-browser.load-more%</button> </div> <div class="empty" if={ files.length == 0 && folders.length == 0 && !fetching }> diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag index 0f019d95b..8b3d36b3f 100644 --- a/src/web/app/desktop/tags/drive/file.tag +++ b/src/web/app/desktop/tags/drive/file.tag @@ -5,7 +5,9 @@ <div class="label" if={ I.banner_id == file.id }><img src="/assets/label.svg"/> <p>%i18n:desktop.tags.mk-drive-browser-file.banner%</p> </div> - <div class="thumbnail"><img src={ file.url + '?thumbnail&size=128' } alt=""/></div> + <div class="thumbnail" ref="thumbnail" style="background-color:{ file.properties.average_color ? 'rgb(' + file.properties.average_color.join(',') + ')' : 'transparent' }"> + <img src={ file.url + '?thumbnail&size=128' } alt="" onload={ onload }/> + </div> <p class="name"><span>{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }</span><span class="ext" if={ file.name.lastIndexOf('.') != -1 }>{ file.name.substr(file.name.lastIndexOf('.')) }</span></p> <style> :scope @@ -139,6 +141,7 @@ </style> <script> + import anime from 'animejs'; import bytesToSize from '../../../common/scripts/bytes-to-size'; this.mixin('i'); @@ -199,5 +202,16 @@ this.isDragging = false; this.browser.isDragSource = false; }; + + this.onload = () => { + if (this.file.properties.average_color) { + anime({ + targets: this.refs.thumbnail, + backgroundColor: `rgba(${this.file.properties.average_color.join(',')}, 0)`, + duration: 100, + easing: 'linear' + }); + } + }; </script> </mk-drive-browser-file> diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag index a48c7239a..268728307 100644 --- a/src/web/app/desktop/tags/home-widgets/mentions.tag +++ b/src/web/app/desktop/tags/home-widgets/mentions.tag @@ -101,7 +101,7 @@ }); this.api('posts/mentions', { following: this.mode == 'following', - max_id: this.refs.timeline.tail().id + until_id: this.refs.timeline.tail().id }).then(posts => { this.update({ moreLoading: false diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag index 4c58aa4aa..9571b09f3 100644 --- a/src/web/app/desktop/tags/home-widgets/timeline.tag +++ b/src/web/app/desktop/tags/home-widgets/timeline.tag @@ -86,7 +86,7 @@ }); this.api('posts/timeline', { - max_date: this.date ? this.date.getTime() : undefined + until_date: this.date ? this.date.getTime() : undefined }).then(posts => { this.update({ isLoading: false, @@ -103,7 +103,7 @@ moreLoading: true }); this.api('posts/timeline', { - max_id: this.refs.timeline.tail().id + until_id: this.refs.timeline.tail().id }).then(posts => { this.update({ moreLoading: false diff --git a/src/web/app/desktop/tags/image-dialog.tag b/src/web/app/desktop/tags/image-dialog.tag deleted file mode 100644 index 39d16ca13..000000000 --- a/src/web/app/desktop/tags/image-dialog.tag +++ /dev/null @@ -1,61 +0,0 @@ -<mk-image-dialog> - <div class="bg" ref="bg" onclick={ close }></div><img ref="img" src={ image.url } alt={ image.name } title={ image.name } onclick={ close }/> - <style> - :scope - display block - position fixed - z-index 2048 - top 0 - left 0 - width 100% - height 100% - opacity 0 - - > .bg - display block - position fixed - z-index 1 - top 0 - left 0 - width 100% - height 100% - background rgba(0, 0, 0, 0.7) - - > img - position fixed - z-index 2 - top 0 - right 0 - bottom 0 - left 0 - max-width 100% - max-height 100% - margin auto - cursor zoom-out - - </style> - <script> - import anime from 'animejs'; - - this.image = this.opts.image; - - this.on('mount', () => { - anime({ - targets: this.root, - opacity: 1, - duration: 100, - easing: 'linear' - }); - }); - - this.close = () => { - anime({ - targets: this.root, - opacity: 0, - duration: 100, - easing: 'linear', - complete: () => this.unmount() - }); - }; - </script> -</mk-image-dialog> diff --git a/src/web/app/desktop/tags/images-viewer.tag b/src/web/app/desktop/tags/images-viewer.tag deleted file mode 100644 index 44a61cb74..000000000 --- a/src/web/app/desktop/tags/images-viewer.tag +++ /dev/null @@ -1,45 +0,0 @@ -<mk-images-viewer> - <div class="image" ref="view" onmousemove={ mousemove } style={ 'background-image: url(' + image.url + '?thumbnail' } onclick={ click }><img src={ image.url + '?thumbnail&size=512' } alt={ image.name } title={ image.name }/></div> - <style> - :scope - display block - overflow hidden - border-radius 4px - - > .image - cursor zoom-in - - > img - display block - max-height 256px - max-width 100% - margin 0 auto - - &:hover - > img - visibility hidden - - &:not(:hover) - background-image none !important - - </style> - <script> - this.images = this.opts.images; - this.image = this.images[0]; - - this.mousemove = e => { - const rect = this.refs.view.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - const xp = mouseX / this.refs.view.offsetWidth * 100; - const yp = mouseY / this.refs.view.offsetHeight * 100; - this.refs.view.style.backgroundPosition = xp + '% ' + yp + '%'; - }; - - this.click = () => { - riot.mount(document.body.appendChild(document.createElement('mk-image-dialog')), { - image: this.image - }); - }; - </script> -</mk-images-viewer> diff --git a/src/web/app/desktop/tags/images.tag b/src/web/app/desktop/tags/images.tag new file mode 100644 index 000000000..0cd408576 --- /dev/null +++ b/src/web/app/desktop/tags/images.tag @@ -0,0 +1,172 @@ +<mk-images> + <virtual each={ image in images }> + <mk-images-image image={ image }/> + </virtual> + <style> + :scope + display grid + grid-gap 4px + height 256px + </style> + <script> + this.images = this.opts.images; + + this.on('mount', () => { + if (this.images.length == 1) { + this.root.style.gridTemplateRows = '1fr'; + + this.tags['mk-images-image'].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'].root.style.gridRow = '1 / 2'; + } else if (this.images.length == 2) { + this.root.style.gridTemplateColumns = '1fr 1fr'; + this.root.style.gridTemplateRows = '1fr'; + + this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; + } else if (this.images.length == 3) { + this.root.style.gridTemplateColumns = '1fr 0.5fr'; + this.root.style.gridTemplateRows = '1fr 1fr'; + + this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][0].root.style.gridRow = '1 / 3'; + this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][2].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; + } else if (this.images.length == 4) { + this.root.style.gridTemplateColumns = '1fr 1fr'; + this.root.style.gridTemplateRows = '1fr 1fr'; + + this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][2].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; + this.tags['mk-images-image'][3].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][3].root.style.gridRow = '2 / 3'; + } + }); + </script> +</mk-images> + +<mk-images-image> + <a ref="view" + href={ image.url } + onmousemove={ mousemove } + onmouseleave={ mouseleave } + style={ styles } + onclick={ click } + title={ image.name }></a> + <style> + :scope + display block + overflow hidden + border-radius 4px + + > a + display block + cursor zoom-in + overflow hidden + width 100% + height 100% + background-position center + + &:not(:hover) + background-size cover + + </style> + <script> + this.image = this.opts.image; + this.styles = { + 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + + this.mousemove = e => { + const rect = this.refs.view.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const xp = mouseX / this.refs.view.offsetWidth * 100; + const yp = mouseY / this.refs.view.offsetHeight * 100; + this.refs.view.style.backgroundPosition = xp + '% ' + yp + '%'; + this.refs.view.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")'; + }; + + this.mouseleave = () => { + this.refs.view.style.backgroundPosition = ''; + }; + + this.click = ev => { + ev.preventDefault(); + riot.mount(document.body.appendChild(document.createElement('mk-image-dialog')), { + image: this.image + }); + return false; + }; + </script> +</mk-images-image> + +<mk-image-dialog> + <div class="bg" ref="bg" onclick={ close }></div><img ref="img" src={ image.url } alt={ image.name } title={ image.name } onclick={ close }/> + <style> + :scope + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > img + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 100% + max-height 100% + margin auto + cursor zoom-out + + </style> + <script> + import anime from 'animejs'; + + this.image = this.opts.image; + + this.on('mount', () => { + anime({ + targets: this.root, + opacity: 1, + duration: 100, + easing: 'linear' + }); + }); + + this.close = () => { + anime({ + targets: this.root, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.unmount() + }); + }; + </script> +</mk-image-dialog> diff --git a/src/web/app/desktop/tags/index.ts b/src/web/app/desktop/tags/index.ts index 3ec1d108a..4edda8353 100644 --- a/src/web/app/desktop/tags/index.ts +++ b/src/web/app/desktop/tags/index.ts @@ -76,8 +76,7 @@ require('./set-avatar-suggestion.tag'); require('./set-banner-suggestion.tag'); require('./repost-form.tag'); require('./sub-post-content.tag'); -require('./images-viewer.tag'); -require('./image-dialog.tag'); +require('./images.tag'); require('./donation.tag'); require('./users-list.tag'); require('./user-following.tag'); diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag index 3218c00f6..39862487e 100644 --- a/src/web/app/desktop/tags/notifications.tag +++ b/src/web/app/desktop/tags/notifications.tag @@ -283,7 +283,7 @@ this.api('i/notifications', { limit: max + 1, - max_id: this.notifications[this.notifications.length - 1].id + until_id: this.notifications[this.notifications.length - 1].id }).then(notifications => { if (notifications.length == max + 1) { this.moreNotifications = true; diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag index 44548e418..974f49a4f 100644 --- a/src/web/app/desktop/tags/pages/entrance.tag +++ b/src/web/app/desktop/tags/pages/entrance.tag @@ -18,7 +18,7 @@ <footer> <div> <mk-nav-links/> - <mk-copyright/> + <p class="c">{ _COPYRIGHT_ }</p> </div> </footer> <!-- ↓ https://github.com/riot/riot/issues/2134 (将来的)--> @@ -101,7 +101,7 @@ text-align center border-top solid 1px #fff - > mk-copyright + > .c margin 0 line-height 64px font-size 10px @@ -150,7 +150,7 @@ </mk-entrance> <mk-entrance-signin> - <a class="help" href={ _ABOUT_URL_ + '/help' } title="お困りですか?">%fa:question%</a> + <a class="help" href={ _DOCS_URL_ + '/help' } title="お困りですか?">%fa:question%</a> <div class="form"> <h1><img if={ user } src={ user.avatar_url + '?thumbnail&size=32' }/> <p>{ user ? user.name : 'アカウント' }</p> diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag index e22386df9..cccd85c47 100644 --- a/src/web/app/desktop/tags/post-detail-sub.tag +++ b/src/web/app/desktop/tags/post-detail-sub.tag @@ -9,7 +9,7 @@ <span class="username">@{ post.user.username }</span> </div> <div class="right"> - <a class="time" href={ '/' + this.post.user.username + '/' + this.post.id }> + <a class="time" href={ '/' + post.user.username + '/' + post.id }> <mk-time time={ post.created_at }/> </a> </div> @@ -17,9 +17,7 @@ <div class="body"> <div class="text" ref="text"></div> <div class="media" if={ post.media }> - <virtual each={ file in post.media }> - <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/> - </virtual> + <mk-images images={ post.media }/> </div> </div> </div> @@ -107,11 +105,6 @@ > mk-url-preview margin-top 8px - > .media - > img - display block - max-width 100% - </style> <script> import compile from '../../common/scripts/text-compiler'; diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag index 37f90a6ff..47c71a6c1 100644 --- a/src/web/app/desktop/tags/post-detail.tag +++ b/src/web/app/desktop/tags/post-detail.tag @@ -37,7 +37,7 @@ <div class="body"> <div class="text" ref="text"></div> <div class="media" if={ p.media }> - <virtual each={ file in p.media }><img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/></virtual> + <mk-images images={ p.media }/> </div> <mk-poll if={ p.poll } post={ p }/> </div> @@ -208,11 +208,6 @@ > mk-url-preview margin-top 8px - > .media - > img - display block - max-width 100% - > footer font-size 1.2em diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag index 8e5171c83..0b4c07906 100644 --- a/src/web/app/desktop/tags/post-form.tag +++ b/src/web/app/desktop/tags/post-form.tag @@ -1,13 +1,12 @@ <mk-post-form ondragover={ ondragover } ondragenter={ ondragenter } ondragleave={ ondragleave } ondrop={ ondrop }> <div class="content"> <textarea class={ with: (files.length != 0 || poll) } ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder={ placeholder }></textarea> - <div class="medias { with: poll }" if={ files.length != 0 }> - <ul> - <li each={ files }> + <div class="medias { with: poll }" show={ files.length != 0 }> + <ul ref="media"> + <li each={ files } data-id={ id }> <div class="img" style="background-image: url({ url + '?thumbnail&size=64' })" title={ name }></div> <img class="remove" onclick={ removeFile } src="/assets/desktop/remove.png" title="%i18n:desktop.tags.mk-post-form.attach-cancel%" alt=""/> </li> - <li class="add" if={ files.length < 4 } title="%i18n:desktop.tags.mk-post-form.attach-media-from-local%" onclick={ selectFile }>%fa:plus%</li> </ul> <p class="remain">{ 4 - files.length }/4</p> </div> @@ -118,8 +117,9 @@ > li display block float left - margin 4px + margin 0 padding 0 + border solid 4px transparent cursor move &:hover > .remove @@ -140,29 +140,6 @@ height 16px cursor pointer - > .add - display block - float left - margin 4px - padding 0 - border dashed 2px rgba($theme-color, 0.2) - cursor pointer - - &:hover - border-color rgba($theme-color, 0.3) - - > i - color rgba($theme-color, 0.4) - - > i - display block - width 60px - height 60px - line-height 60px - text-align center - font-size 1.2em - color rgba($theme-color, 0.2) - > mk-poll-editor background lighten($theme-color, 98%) border solid 1px rgba($theme-color, 0.1) @@ -306,6 +283,7 @@ </style> <script> + import Sortable from 'sortablejs'; import getKao from '../../common/scripts/get-kao'; import notify from '../scripts/notify'; import Autocomplete from '../scripts/autocomplete'; @@ -365,6 +343,10 @@ this.trigger('change-files', this.files); this.update(); } + + new Sortable(this.refs.media, { + animation: 150 + }); }); this.on('unmount', () => { @@ -413,14 +395,17 @@ const data = e.dataTransfer.getData('text'); if (data == null) return false; - // パース - // TODO: Validate JSON - const obj = JSON.parse(data); + try { + // パース + const obj = JSON.parse(data); + + // (ドライブの)ファイルだったら + if (obj.type == 'file') { + this.files.push(obj.file); + this.update(); + } + } catch (e) { - // (ドライブの)ファイルだったら - if (obj.type == 'file') { - this.files.push(obj.file); - this.update(); } }; @@ -483,13 +468,19 @@ this.post = e => { this.wait = true; - const files = this.files && this.files.length > 0 - ? this.files.map(f => f.id) - : undefined; + const files = []; + + if (this.files.length > 0) { + Array.from(this.refs.media.children).forEach(el => { + const id = el.getAttribute('data-id'); + const file = this.files.find(f => f.id == id); + files.push(file); + }); + } this.api('posts/create', { text: this.refs.text.value == '' ? undefined : this.refs.text.value, - media_ids: files, + media_ids: this.files.length > 0 ? files.map(f => f.id) : undefined, reply_id: this.inReplyToPost ? this.inReplyToPost.id : undefined, repost_id: this.repost ? this.repost.id : undefined, poll: this.poll ? this.refs.poll.get() : undefined diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag index 52f765d1a..f7ec85a4f 100644 --- a/src/web/app/desktop/tags/search-posts.tag +++ b/src/web/app/desktop/tags/search-posts.tag @@ -33,21 +33,22 @@ </style> <script> + import parse from '../../common/scripts/parse-search-query'; + this.mixin('api'); this.query = this.opts.query; this.isLoading = true; this.isEmpty = false; this.moreLoading = false; - this.page = 0; + this.limit = 30; + this.offset = 0; this.on('mount', () => { document.addEventListener('keydown', this.onDocumentKeydown); window.addEventListener('scroll', this.onScroll); - this.api('posts/search', { - query: this.query - }).then(posts => { + this.api('posts/search', parse(this.query)).then(posts => { this.update({ isLoading: false, isEmpty: posts.length == 0 @@ -72,16 +73,16 @@ this.more = () => { if (this.moreLoading || this.isLoading || this.timeline.posts.length == 0) return; + this.offset += this.limit; this.update({ moreLoading: true }); - this.api('posts/search', { - query: this.query, - page: this.page + 1 - }).then(posts => { + return this.api('posts/search', Object.assign({}, parse(this.query), { + limit: this.limit, + offset: this.offset + })).then(posts => { this.update({ - moreLoading: false, - page: page + 1 + moreLoading: false }); this.refs.timeline.prependPosts(posts); }); diff --git a/src/web/app/desktop/tags/select-file-from-drive-window.tag b/src/web/app/desktop/tags/select-file-from-drive-window.tag index 17cc60798..c660a2fe9 100644 --- a/src/web/app/desktop/tags/select-file-from-drive-window.tag +++ b/src/web/app/desktop/tags/select-file-from-drive-window.tag @@ -33,7 +33,7 @@ height 72px background lighten($theme-color, 95%) - .upload + > .upload display inline-block position absolute top 8px @@ -72,8 +72,8 @@ border 2px solid rgba($theme-color, 0.3) border-radius 8px - .ok - .cancel + > .ok + > .cancel display block position absolute bottom 16px @@ -102,7 +102,7 @@ opacity 0.7 cursor default - .ok + > .ok right 16px color $theme-color-foreground background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) @@ -119,7 +119,7 @@ background $theme-color border-color $theme-color - .cancel + > .cancel right 148px color #888 background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag index 46cd40552..457b7e227 100644 --- a/src/web/app/desktop/tags/settings.tag +++ b/src/web/app/desktop/tags/settings.tag @@ -1,47 +1,35 @@ <mk-settings> <div class="nav"> - <p class={ active: page == 'account' } onmousedown={ setPage.bind(null, 'account') }>%fa:user .fw%アカウント</p> + <p class={ active: page == 'profile' } onmousedown={ setPage.bind(null, 'profile') }>%fa:user .fw%%i18n:desktop.tags.mk-settings.profile%</p> <p class={ active: page == 'web' } onmousedown={ setPage.bind(null, 'web') }>%fa:desktop .fw%Web</p> <p class={ active: page == 'notification' } onmousedown={ setPage.bind(null, 'notification') }>%fa:R bell .fw%通知</p> - <p class={ active: page == 'drive' } onmousedown={ setPage.bind(null, 'drive') }>%fa:cloud .fw%ドライブ</p> + <p class={ active: page == 'drive' } onmousedown={ setPage.bind(null, 'drive') }>%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p> + <p class={ active: page == 'mute' } onmousedown={ setPage.bind(null, 'mute') }>%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p> <p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }>%fa:puzzle-piece .fw%アプリ</p> <p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }>%fa:B twitter .fw%Twitter</p> - <p class={ active: page == 'signin' } onmousedown={ setPage.bind(null, 'signin') }>%fa:sign-in-alt .fw%ログイン履歴</p> - <p class={ active: page == 'password' } onmousedown={ setPage.bind(null, 'password') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.password%</p> + <p class={ active: page == 'security' } onmousedown={ setPage.bind(null, 'security') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p> <p class={ active: page == 'api' } onmousedown={ setPage.bind(null, 'api') }>%fa:key .fw%API</p> + <p class={ active: page == 'other' } onmousedown={ setPage.bind(null, 'other') }>%fa:cogs .fw%%i18n:desktop.tags.mk-settings.other%</p> </div> <div class="pages"> - <section class="account" show={ page == 'account' }> - <h1>アカウント</h1> - <label class="avatar"> - <p>アバター</p><img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/> - <button class="style-normal" onclick={ avatar }>画像を選択</button> - </label> - <label> - <p>名前</p> - <input ref="accountName" type="text" value={ I.name }/> - </label> - <label> - <p>場所</p> - <input ref="accountLocation" type="text" value={ I.profile.location }/> - </label> - <label> - <p>自己紹介</p> - <textarea ref="accountDescription">{ I.description }</textarea> - </label> - <label> - <p>誕生日</p> - <input ref="accountBirthday" type="date" value={ I.profile.birthday }/> - </label> - <button class="style-primary" onclick={ updateAccount }>保存</button> + <section class="profile" show={ page == 'profile' }> + <h1>%i18n:desktop.tags.mk-settings.profile%</h1> + <mk-profile-setting/> </section> <section class="web" show={ page == 'web' }> <h1>デザイン</h1> - <a href="/i/customize-home">ホームをカスタマイズ</a> + <a href="/i/customize-home" class="ui button">ホームをカスタマイズ</a> </section> - <section class="web" show={ page == 'web' }> + <section class="drive" show={ page == 'drive' }> + <h1>%i18n:desktop.tags.mk-settings.drive%</h1> + <mk-drive-setting/> + </section> + + <section class="mute" show={ page == 'mute' }> + <h1>%i18n:desktop.tags.mk-settings.mute%</h1> + <mk-mute-setting/> </section> <section class="apps" show={ page == 'apps' }> @@ -54,20 +42,30 @@ <mk-twitter-setting/> </section> - <section class="signin" show={ page == 'signin' }> - <h1>ログイン履歴</h1> - <mk-signin-history/> - </section> - - <section class="password" show={ page == 'password' }> + <section class="password" show={ page == 'security' }> <h1>%i18n:desktop.tags.mk-settings.password%</h1> <mk-password-setting/> </section> + <section class="2fa" show={ page == 'security' }> + <h1>%i18n:desktop.tags.mk-settings.2fa%</h1> + <mk-2fa-setting/> + </section> + + <section class="signin" show={ page == 'security' }> + <h1>サインイン履歴</h1> + <mk-signin-history/> + </section> + <section class="api" show={ page == 'api' }> <h1>API</h1> <mk-api-info/> </section> + + <section class="other" show={ page == 'other' }> + <h1>%i18n:desktop.tags.mk-settings.license%</h1> + %license% + </section> </div> <style> :scope @@ -75,25 +73,6 @@ width 100% height 100% - input:not([type]) - input[type='text'] - input[type='password'] - input[type='email'] - input[type='date'] - textarea - padding 8px - width 100% - font-size 16px - color #55595c - border solid 1px #dadada - border-radius 4px - - &:hover - border-color #aeaeae - - &:focus - border-color #aeaeae - > .nav flex 0 0 200px width 100% @@ -128,64 +107,63 @@ overflow auto > section - padding 32px + margin 32px + color #4a535a - // & + section - // margin-top 16px - - h1 + > h1 display block - margin 0 + margin 0 0 1em 0 padding 0 0 8px 0 font-size 1em color #555 border-bottom solid 1px #eee - label - display block - margin 16px 0 + </style> + <script> + this.page = 'profile'; - &:after - content "" - display block - clear both + this.setPage = page => { + this.page = page; + }; + </script> +</mk-settings> - > p - margin 0 0 8px 0 - font-weight bold - color #373a3c +<mk-profile-setting> + <label class="avatar ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.avatar%</p><img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/> + <button class="ui" onclick={ avatar }>%i18n:desktop.tags.mk-profile-setting.choice-avatar%</button> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.name%</p> + <input ref="accountName" type="text" value={ I.name } class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.location%</p> + <input ref="accountLocation" type="text" value={ I.profile.location } class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.description%</p> + <textarea ref="accountDescription" class="ui">{ I.description }</textarea> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.birthday%</p> + <input ref="accountBirthday" type="date" value={ I.profile.birthday } class="ui"/> + </label> + <button class="ui primary" onclick={ updateAccount }>%i18n:desktop.tags.mk-profile-setting.save%</button> + <style> + :scope + display block - &.checkbox - > input - position absolute - top 0 - left 0 + > .avatar + > img + display inline-block + vertical-align top + width 64px + height 64px + border-radius 4px - &:checked + p - color $theme-color - - > p - width calc(100% - 32px) - margin 0 0 0 32px - font-weight bold - - &:last-child - font-weight normal - color #999 - - &.account - > .general - > .avatar - > img - display block - float left - width 64px - height 64px - border-radius 4px - - > button - float left - margin-left 8px + > button + margin-left 8px </style> <script> @@ -195,12 +173,6 @@ this.mixin('i'); this.mixin('api'); - this.page = 'account'; - - this.setPage = page => { - this.page = page; - }; - this.avatar = () => { updateAvatar(this.I); }; @@ -216,21 +188,25 @@ }); }; </script> -</mk-settings> +</mk-profile-setting> <mk-api-info> - <p>Token:<code>{ I.token }</code></p> - <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p> - <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p> - <p>万が一このトークンが漏れたりその可能性がある場合は<a class="regenerate" onclick={ regenerateToken }>トークンを再生成</a>できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します)</p> + <p>Token: <code>{ I.token }</code></p> + <p>%i18n:desktop.tags.mk-api-info.intro%</p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-api-info.caution%</p></div> + <p>%i18n:desktop.tags.mk-api-info.regeneration-of-token%</p> + <button class="ui" onclick={ regenerateToken }>%i18n:desktop.tags.mk-api-info.regenerate-token%</button> <style> :scope display block color #4a535a code - padding 4px + display inline-block + padding 4px 6px + color #555 background #eee + border-radius 2px </style> <script> import passwordDialog from '../scripts/password-dialog'; @@ -239,7 +215,7 @@ this.mixin('api'); this.regenerateToken = () => { - passwordDialog('%i18n:desktop.tags.mk-api-info.regenerate-token%', password => { + passwordDialog('%i18n:desktop.tags.mk-api-info.enter-password%', password => { this.api('i/regenerate_token', { password: password }); @@ -249,7 +225,7 @@ </mk-api-info> <mk-password-setting> - <button onclick={ reset }>%i18n:desktop.tags.mk-password-setting.reset%</button> + <button onclick={ reset } class="ui primary">%i18n:desktop.tags.mk-password-setting.reset%</button> <style> :scope display block @@ -285,3 +261,166 @@ }; </script> </mk-password-setting> + +<mk-2fa-setting> + <p>%i18n:desktop.tags.mk-2fa-setting.intro%<a href="%i18n:desktop.tags.mk-2fa-setting.url%" target="_blank">%i18n:desktop.tags.mk-2fa-setting.detail%</a></p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-2fa-setting.caution%</p></div> + <p if={ !data && !I.two_factor_enabled }><button onclick={ register } class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p> + <virtual if={ I.two_factor_enabled }> + <p>%i18n:desktop.tags.mk-2fa-setting.already-registered%</p> + <button onclick={ unregister } class="ui">%i18n:desktop.tags.mk-2fa-setting.unregister%</button> + </virtual> + <div if={ data }> + <ol> + <li>%i18n:desktop.tags.mk-2fa-setting.authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:desktop.tags.mk-2fa-setting.howtoinstall%</a></li> + <li>%i18n:desktop.tags.mk-2fa-setting.scan%<br><img src={ data.qr }></li> + <li>%i18n:desktop.tags.mk-2fa-setting.done%<br> + <input type="number" ref="token" class="ui"> + <button onclick={ submit } class="ui primary">%i18n:desktop.tags.mk-2fa-setting.submit%</button> + </li> + </ol> + <div class="ui info"><p>%fa:info-circle%%i18n:desktop.tags.mk-2fa-setting.info%</p></div> + </div> + <style> + :scope + display block + color #4a535a + + </style> + <script> + import passwordDialog from '../scripts/password-dialog'; + import notify from '../scripts/notify'; + + this.mixin('i'); + this.mixin('api'); + + this.register = () => { + passwordDialog('%i18n:desktop.tags.mk-2fa-setting.enter-password%', password => { + this.api('i/2fa/register', { + password: password + }).then(data => { + this.update({ + data: data + }); + }); + }); + }; + + this.unregister = () => { + passwordDialog('%i18n:desktop.tags.mk-2fa-setting.enter-password%', password => { + this.api('i/2fa/unregister', { + password: password + }).then(data => { + notify('%i18n:desktop.tags.mk-2fa-setting.unregistered%'); + this.I.two_factor_enabled = false; + this.I.update(); + }); + }); + }; + + this.submit = () => { + this.api('i/2fa/done', { + token: this.refs.token.value + }).then(() => { + notify('%i18n:desktop.tags.mk-2fa-setting.success%'); + this.I.two_factor_enabled = true; + this.I.update(); + }).catch(() => { + notify('%i18n:desktop.tags.mk-2fa-setting.failed%'); + }); + }; + </script> +</mk-2fa-setting> + +<mk-drive-setting> + <svg viewBox="0 0 1 1" preserveAspectRatio="none"> + <circle + riot-r={ r } + cx="50%" cy="50%" + fill="none" + stroke-width="0.1" + stroke="rgba(0, 0, 0, 0.05)"/> + <circle + riot-r={ r } + cx="50%" cy="50%" + riot-stroke-dasharray={ Math.PI * (r * 2) } + riot-stroke-dashoffset={ strokeDashoffset } + fill="none" + stroke-width="0.1" + riot-stroke={ color }/> + <text x="50%" y="50%" dy="0.05" text-anchor="middle">{ (usageP * 100).toFixed(0) }%</text> + </svg> + + <style> + :scope + display block + color #4a535a + + > svg + display block + height 128px + + > circle + transform-origin center + transform rotate(-90deg) + transition stroke-dashoffset 0.5s ease + + > text + font-size 0.15px + fill rgba(0, 0, 0, 0.6) + + </style> + <script> + this.mixin('api'); + + this.r = 0.4; + + this.on('mount', () => { + this.api('drive').then(info => { + const usageP = info.usage / info.capacity; + const color = `hsl(${180 - (usageP * 180)}, 80%, 70%)`; + const strokeDashoffset = (1 - usageP) * (Math.PI * (this.r * 2)); + + this.update({ + color, + strokeDashoffset, + usageP, + usage: info.usage, + capacity: info.capacity + }); + }); + }); + </script> +</mk-drive-setting> + +<mk-mute-setting> + <div class="none ui info" if={ !fetching && users.length == 0 }> + <p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p> + </div> + <div class="users" if={ users.length != 0 }> + <div each={ user in users }> + <p><b>{ user.name }</b> @{ user.username }</p> + </div> + </div> + + <style> + :scope + display block + + </style> + <script> + this.mixin('api'); + + this.apps = []; + this.fetching = true; + + this.on('mount', () => { + this.api('mute/list').then(x => { + this.update({ + fetching: false, + users: x.users + }); + }); + }); + </script> +</mk-mute-setting> diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag index 8989ff1c5..1a81b545b 100644 --- a/src/web/app/desktop/tags/sub-post-content.tag +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -8,7 +8,7 @@ </div> <details if={ post.media }> <summary>({ post.media.length }つのメディア)</summary> - <mk-images-viewer images={ post.media }/> + <mk-images images={ post.media }/> </details> <details if={ post.poll }> <summary>投票</summary> diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag index 08e658a3c..ed77a9e60 100644 --- a/src/web/app/desktop/tags/timeline.tag +++ b/src/web/app/desktop/tags/timeline.tag @@ -120,7 +120,7 @@ <a class="quote" if={ p.repost != null }>RP:</a> </div> <div class="media" if={ p.media }> - <mk-images-viewer images={ p.media }/> + <mk-images images={ p.media }/> </div> <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> <div class="repost" if={ p.repost }>%fa:quote-right -flip-h% @@ -357,11 +357,6 @@ background $theme-color border-radius 4px - > .media - > img - display block - max-width 100% - > mk-poll font-size 80% diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag index 052568062..3dfdeec01 100644 --- a/src/web/app/desktop/tags/ui.tag +++ b/src/web/app/desktop/tags/ui.tag @@ -146,6 +146,9 @@ color #9eaba8 pointer-events none + > * + vertical-align middle + > input user-select text cursor auto @@ -162,7 +165,7 @@ transition color 0.5s ease, border 0.5s ease font-family FontAwesome, sans-serif - &:placeholder-shown + &::placeholder color #9eaba8 &:hover @@ -177,7 +180,7 @@ this.onsubmit = e => { e.preventDefault(); - this.page('/search:' + this.refs.q.value); + this.page('/search?q=' + encodeURIComponent(this.refs.q.value)); }; </script> </mk-ui-header-search> diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag index 2b05f6b5c..134aeee28 100644 --- a/src/web/app/desktop/tags/user-timeline.tag +++ b/src/web/app/desktop/tags/user-timeline.tag @@ -96,7 +96,7 @@ this.fetch = cb => { this.api('users/posts', { user_id: this.user.id, - max_date: this.date ? this.date.getTime() : undefined, + until_date: this.date ? this.date.getTime() : undefined, with_replies: this.mode == 'with-replies' }).then(posts => { this.update({ @@ -116,7 +116,7 @@ this.api('users/posts', { user_id: this.user.id, with_replies: this.mode == 'with-replies', - max_id: this.refs.timeline.tail().id + until_id: this.refs.timeline.tail().id }).then(posts => { this.update({ moreLoading: false diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag index b4db47f9d..b29d1eaeb 100644 --- a/src/web/app/desktop/tags/user.tag +++ b/src/web/app/desktop/tags/user.tag @@ -226,7 +226,9 @@ <mk-user-profile> <div class="friend-form" if={ SIGNIN && I.id != user.id }> <mk-big-follow-button user={ user }/> - <p class="followed" if={ user.is_followed }>フォローされています</p> + <p class="followed" if={ user.is_followed }>%i18n:desktop.tags.mk-user.follows-you%</p> + <p if={ user.is_muted }>%i18n:desktop.tags.mk-user.muted% <a onclick={ unmute }>%i18n:desktop.tags.mk-user.unmute%</a></p> + <p if={ !user.is_muted }><a onclick={ mute }>%i18n:desktop.tags.mk-user.mute%</a></p> </div> <div class="description" if={ user.description }>{ user.description }</div> <div class="birthday" if={ user.profile.birthday }> @@ -311,6 +313,7 @@ this.age = require('s-age'); this.mixin('i'); + this.mixin('api'); this.user = this.opts.user; @@ -325,6 +328,28 @@ user: this.user }); }; + + this.mute = () => { + this.api('mute/create', { + user_id: this.user.id + }).then(() => { + this.user.is_muted = true; + this.update(); + }, e => { + alert('error'); + }); + }; + + this.unmute = () => { + this.api('mute/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_muted = false; + this.update(); + }, e => { + alert('error'); + }); + }; </script> </mk-user-profile> diff --git a/src/web/app/desktop/ui.styl b/src/web/app/desktop/ui.styl new file mode 100644 index 000000000..058271876 --- /dev/null +++ b/src/web/app/desktop/ui.styl @@ -0,0 +1,122 @@ +@import "../../const" + +button + font-family sans-serif + + * + pointer-events none + +button.ui +.button.ui + display inline-block + cursor pointer + padding 0 14px + margin 0 + min-width 100px + line-height 38px + font-size 14px + color #888 + text-decoration none + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + border-radius 4px + outline none + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +input:not([type]).ui +input[type='text'].ui +input[type='password'].ui +input[type='email'].ui +input[type='date'].ui +input[type='number'].ui +textarea.ui + display block + padding 10px + width 100% + height 40px + font-family sans-serif + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #b0b0b0 + + &:focus + border-color $theme-color + +textarea.ui + min-width 100% + max-width 100% + min-height 64px + +.ui.info + display block + margin 1em 0 + padding 0 1em + font-size 90% + color rgba(#000, 0.87) + background #f8f8f9 + border solid 1px rgba(34, 36, 38, 0.22) + border-radius 4px + + > p + opacity 0.8 + + > [data-fa]:first-child + margin-right 0.25em + + &.warn + color #573a08 + background #FFFAF3 + border-color #C9BA9B + +.ui.from.group + display block + margin 16px 0 + + > p:first-child + margin 0 0 6px 0 + font-size 90% + font-weight bold + color rgba(#373a3c, 0.9) diff --git a/src/web/app/mobile/router.ts b/src/web/app/mobile/router.ts index 0358d10e9..afb9aa620 100644 --- a/src/web/app/mobile/router.ts +++ b/src/web/app/mobile/router.ts @@ -19,12 +19,11 @@ export default (mios: MiOS) => { route('/i/settings', settings); route('/i/settings/profile', settingsProfile); route('/i/settings/signin-history', settingsSignin); - route('/i/settings/api', settingsApi); route('/i/settings/twitter', settingsTwitter); route('/i/settings/authorized-apps', settingsAuthorizedApps); route('/post/new', newPost); route('/post::post', post); - route('/search::query', search); + route('/search', search); route('/:user', user.bind(null, 'overview')); route('/:user/graphs', user.bind(null, 'graphs')); route('/:user/followers', userFollowers); @@ -74,10 +73,6 @@ export default (mios: MiOS) => { mount(document.createElement('mk-signin-history-page')); } - function settingsApi() { - mount(document.createElement('mk-api-info-page')); - } - function settingsTwitter() { mount(document.createElement('mk-twitter-setting-page')); } @@ -88,7 +83,7 @@ export default (mios: MiOS) => { function search(ctx) { const el = document.createElement('mk-search-page'); - el.setAttribute('query', ctx.params.query); + el.setAttribute('query', ctx.querystring.substr(2)); mount(el); } diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag index 8350ce07e..2a3ff23bf 100644 --- a/src/web/app/mobile/tags/drive.tag +++ b/src/web/app/mobile/tags/drive.tag @@ -1,9 +1,9 @@ <mk-drive> <nav ref="nav"> - <p onclick={ goRoot }>%fa:cloud%%i18n:mobile.tags.mk-drive.drive%</p> + <a onclick={ goRoot } href="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-drive.drive%</a> <virtual each={ folder in hierarchyFolders }> <span>%fa:angle-right%</span> - <p onclick={ move }>{ folder.name }</p> + <a onclick={ move } href="/i/drive/folder/{ folder.id }">{ folder.name }</a> </virtual> <virtual if={ folder != null }> <span>%fa:angle-right%</span> @@ -74,9 +74,12 @@ border-bottom solid 1px rgba(0, 0, 0, 0.13) > p + > a display inline margin 0 padding 0 + text-decoration none !important + color inherit &:last-child font-weight bold @@ -245,7 +248,9 @@ }; this.move = ev => { + ev.preventDefault(); this.cd(ev.item.folder); + return false; }; this.cd = (target, silent = false) => { @@ -329,7 +334,9 @@ this.prependFile = file => this.addFile(file, true); this.prependFolder = file => this.addFolder(file, true); - this.goRoot = () => { + this.goRoot = ev => { + ev.preventDefault(); + if (this.folder || this.file) { this.update({ file: null, @@ -339,6 +346,8 @@ this.trigger('move-root'); this.fetch(); } + + return false; }; this.fetch = () => { @@ -421,7 +430,7 @@ this.api('drive/files', { folder_id: this.folder ? this.folder.id : null, limit: max + 1, - max_id: this.files[this.files.length - 1].id + until_id: this.files[this.files.length - 1].id }).then(files => { if (files.length == max + 1) { this.moreFiles = true; diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag index da895359d..259873d95 100644 --- a/src/web/app/mobile/tags/drive/file-viewer.tag +++ b/src/web/app/mobile/tags/drive/file-viewer.tag @@ -1,6 +1,11 @@ <mk-drive-file-viewer> <div class="preview"> - <img if={ kind == 'image' } src={ file.url } alt={ file.name } title={ file.name }> + <img if={ kind == 'image' } ref="img" + src={ file.url } + alt={ file.name } + title={ file.name } + onload={ onImageLoaded } + style="background-color:rgb({ file.properties.average_color.join(',') })"> <virtual if={ kind != 'image' }>%fa:file%</virtual> <footer if={ kind == 'image' && file.properties && file.properties.width && file.properties.height }> <span class="size"> @@ -39,6 +44,14 @@ </button> </div> </div> + <div class="exif" show={ exif }> + <div> + <p> + %fa:camera%%i18n:mobile.tags.mk-drive-file-viewer.exif% + </p> + <pre ref="exif" class="json">{ exif ? JSON.stringify(exif, null, 2) : '' }</pre> + </div> + </div> <div class="hash"> <div> <p> @@ -178,12 +191,45 @@ white-space nowrap overflow auto font-size 0.8em + color #222 + border solid 1px #dfdfdf + border-radius 2px + background #f5f5f5 + + > .exif + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > p + display block + margin 0 + padding 0 + color #555 + font-size 0.9em + + > [data-fa] + margin-right 4px + + > pre + display block + width 100% + margin 6px 0 0 0 + padding 8px + height 128px + overflow auto + font-size 0.9em border solid 1px #dfdfdf border-radius 2px background #f5f5f5 </style> <script> + import EXIF from 'exif-js'; + import hljs from 'highlight.js'; import bytesToSize from '../../../common/scripts/bytes-to-size'; import gcd from '../../../common/scripts/gcd'; @@ -195,6 +241,17 @@ this.file = this.opts.file; this.kind = this.file.type.split('/')[0]; + this.onImageLoaded = () => { + const self = this; + EXIF.getData(this.refs.img, function() { + const allMetaData = EXIF.getAllTags(this); + self.update({ + exif: allMetaData + }); + hljs.highlightBlock(self.refs.exif); + }); + }; + this.rename = () => { const name = window.prompt('名前を変更', this.file.name); if (name == null || name == '' || name == this.file.name) return; diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag index 93a8dba7e..684df7dd0 100644 --- a/src/web/app/mobile/tags/drive/file.tag +++ b/src/web/app/mobile/tags/drive/file.tag @@ -1,119 +1,123 @@ -<mk-drive-file onclick={ onclick } data-is-selected={ isSelected }> - <div class="container"> - <div class="thumbnail" style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' }></div> - <div class="body"> - <p class="name"><span>{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }</span><span class="ext" if={ file.name.lastIndexOf('.') != -1 }>{ file.name.substr(file.name.lastIndexOf('.')) }</span></p> - <!-- - if file.tags.length > 0 - ul.tags - each tag in file.tags - li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name - --> - <footer> - <p class="type"><mk-file-type-icon type={ file.type }/>{ file.type }</p> - <p class="separator"></p> - <p class="data-size">{ bytesToSize(file.datasize) }</p> - <p class="separator"></p> - <p class="created-at"> - %fa:R clock%<mk-time time={ file.created_at }/> - </p> - </footer> +<mk-drive-file data-is-selected={ isSelected }> + <a onclick={ onclick } href="/i/drive/file/{ file.id }"> + <div class="container"> + <div class="thumbnail" style={ thumbnail }></div> + <div class="body"> + <p class="name"><span>{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }</span><span class="ext" if={ file.name.lastIndexOf('.') != -1 }>{ file.name.substr(file.name.lastIndexOf('.')) }</span></p> + <!-- + if file.tags.length > 0 + ul.tags + each tag in file.tags + li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name + --> + <footer> + <p class="type"><mk-file-type-icon type={ file.type }/>{ file.type }</p> + <p class="separator"></p> + <p class="data-size">{ bytesToSize(file.datasize) }</p> + <p class="separator"></p> + <p class="created-at"> + %fa:R clock%<mk-time time={ file.created_at }/> + </p> + </footer> + </div> </div> - </div> + </a> <style> :scope display block - &, * - user-select none + > a + display block + text-decoration none !important - * - pointer-events none + * + user-select none + pointer-events none - > .container - max-width 500px - margin 0 auto - padding 16px + > .container + max-width 500px + margin 0 auto + padding 16px - &:after - content "" - display block - clear both - - > .thumbnail - display block - float left - width 64px - height 64px - background-size cover - background-position center center - - > .body - display block - float left - width calc(100% - 74px) - margin-left 10px - - > .name + &:after + content "" display block - margin 0 - padding 0 - font-size 0.9em - font-weight bold - color #555 - text-overflow ellipsis - overflow-wrap break-word + clear both - > .ext - opacity 0.5 - - > .tags + > .thumbnail display block - margin 4px 0 0 0 - padding 0 - list-style none - font-size 0.5em + float left + width 64px + height 64px + background-size cover + background-position center center - > .tag - display inline-block - margin 0 5px 0 0 - padding 1px 5px - border-radius 2px - - > footer + > .body display block - margin 4px 0 0 0 - font-size 0.7em + float left + width calc(100% - 74px) + margin-left 10px - > .separator - display inline - margin 0 - padding 0 4px - color #CDCDCD - - > .type - display inline + > .name + display block margin 0 padding 0 - color #9D9D9D + font-size 0.9em + font-weight bold + color #555 + text-overflow ellipsis + overflow-wrap break-word - > mk-file-type-icon - margin-right 4px + > .ext + opacity 0.5 - > .data-size - display inline - margin 0 + > .tags + display block + margin 4px 0 0 0 padding 0 - color #9D9D9D + list-style none + font-size 0.5em - > .created-at - display inline - margin 0 - padding 0 - color #BDBDBD + > .tag + display inline-block + margin 0 5px 0 0 + padding 1px 5px + border-radius 2px - > [data-fa] - margin-right 2px + > footer + display block + margin 4px 0 0 0 + font-size 0.7em + + > .separator + display inline + margin 0 + padding 0 4px + color #CDCDCD + + > .type + display inline + margin 0 + padding 0 + color #9D9D9D + + > mk-file-type-icon + margin-right 4px + + > .data-size + display inline + margin 0 + padding 0 + color #9D9D9D + + > .created-at + display inline + margin 0 + padding 0 + color #BDBDBD + + > [data-fa] + margin-right 2px &[data-is-selected] background $theme-color @@ -128,14 +132,20 @@ this.browser = this.parent; this.file = this.opts.file; + this.thumbnail = { + 'background-color': this.file.properties.average_color ? `rgb(${this.file.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.file.url}?thumbnail&size=128)` + }; this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id); this.browser.on('change-selection', selections => { this.isSelected = selections.some(f => f.id == this.file.id); }); - this.onclick = () => { + this.onclick = ev => { + ev.preventDefault(); this.browser.chooseFile(this.file); + return false; }; </script> </mk-drive-file> diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag index 196e7e326..6125e0b25 100644 --- a/src/web/app/mobile/tags/drive/folder.tag +++ b/src/web/app/mobile/tags/drive/folder.tag @@ -1,47 +1,53 @@ -<mk-drive-folder onclick={ onclick }> - <div class="container"> - <p class="name">%fa:folder%{ folder.name }</p>%fa:angle-right% - </div> +<mk-drive-folder> + <a onclick={ onclick } href="/i/drive/folder/{ folder.id }"> + <div class="container"> + <p class="name">%fa:folder%{ folder.name }</p>%fa:angle-right% + </div> + </a> <style> :scope display block - color #777 - &, * - user-select none + > a + display block + color #777 + text-decoration none !important - * - pointer-events none + * + user-select none + pointer-events none - > .container - max-width 500px - margin 0 auto - padding 16px + > .container + max-width 500px + margin 0 auto + padding 16px - > .name - display block - margin 0 - padding 0 + > .name + display block + margin 0 + padding 0 + + > [data-fa] + margin-right 6px > [data-fa] - margin-right 6px + position absolute + top 0 + bottom 0 + right 20px - > [data-fa] - position absolute - top 0 - bottom 0 - right 8px - margin auto 0 auto 0 - width 1em - height 1em + > * + height 100% </style> <script> this.browser = this.parent; this.folder = this.opts.folder; - this.onclick = () => { + this.onclick = ev => { + ev.preventDefault(); this.browser.cd(this.folder); + return false; }; </script> </mk-drive-folder> diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag index e96823fa1..397d2b398 100644 --- a/src/web/app/mobile/tags/home-timeline.tag +++ b/src/web/app/mobile/tags/home-timeline.tag @@ -47,7 +47,7 @@ this.more = () => { return this.api('posts/timeline', { - max_id: this.refs.timeline.tail().id + until_id: this.refs.timeline.tail().id }); }; diff --git a/src/web/app/mobile/tags/images-viewer.tag b/src/web/app/mobile/tags/images-viewer.tag deleted file mode 100644 index 8ef4a50be..000000000 --- a/src/web/app/mobile/tags/images-viewer.tag +++ /dev/null @@ -1,26 +0,0 @@ -<mk-images-viewer> - <div class="image" ref="view" onclick={ click }><img ref="img" src={ image.url + '?thumbnail&size=512' } alt={ image.name } title={ image.name }/></div> - <style> - :scope - display block - overflow hidden - border-radius 4px - - > .image - - > img - display block - max-height 256px - max-width 100% - margin 0 auto - - </style> - <script> - this.images = this.opts.images; - this.image = this.images[0]; - - this.click = () => { - window.open(this.image.url); - }; - </script> -</mk-images-viewer> diff --git a/src/web/app/mobile/tags/images.tag b/src/web/app/mobile/tags/images.tag new file mode 100644 index 000000000..5899364ae --- /dev/null +++ b/src/web/app/mobile/tags/images.tag @@ -0,0 +1,82 @@ +<mk-images> + <virtual each={ image in images }> + <mk-images-image image={ image }/> + </virtual> + <style> + :scope + display grid + grid-gap 4px + height 256px + + @media (max-width 500px) + height 192px + </style> + <script> + this.images = this.opts.images; + + this.on('mount', () => { + if (this.images.length == 1) { + this.root.style.gridTemplateRows = '1fr'; + + this.tags['mk-images-image'].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'].root.style.gridRow = '1 / 2'; + } else if (this.images.length == 2) { + this.root.style.gridTemplateColumns = '1fr 1fr'; + this.root.style.gridTemplateRows = '1fr'; + + this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; + } else if (this.images.length == 3) { + this.root.style.gridTemplateColumns = '1fr 0.5fr'; + this.root.style.gridTemplateRows = '1fr 1fr'; + + this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][0].root.style.gridRow = '1 / 3'; + this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][2].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; + } else if (this.images.length == 4) { + this.root.style.gridTemplateColumns = '1fr 1fr'; + this.root.style.gridTemplateRows = '1fr 1fr'; + + this.tags['mk-images-image'][0].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][0].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][1].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][1].root.style.gridRow = '1 / 2'; + this.tags['mk-images-image'][2].root.style.gridColumn = '1 / 2'; + this.tags['mk-images-image'][2].root.style.gridRow = '2 / 3'; + this.tags['mk-images-image'][3].root.style.gridColumn = '2 / 3'; + this.tags['mk-images-image'][3].root.style.gridRow = '2 / 3'; + } + }); + </script> +</mk-images> + +<mk-images-image> + <a ref="view" href={ image.url } target="_blank" style={ styles } title={ image.name }></a> + <style> + :scope + display block + overflow hidden + border-radius 4px + + > a + display block + overflow hidden + width 100% + height 100% + background-position center + background-size cover + + </style> + <script> + this.image = this.opts.image; + this.styles = { + 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + </script> +</mk-images-image> diff --git a/src/web/app/mobile/tags/index.ts b/src/web/app/mobile/tags/index.ts index 19952c20c..20934cdd8 100644 --- a/src/web/app/mobile/tags/index.ts +++ b/src/web/app/mobile/tags/index.ts @@ -14,7 +14,6 @@ require('./page/search.tag'); require('./page/settings.tag'); require('./page/settings/profile.tag'); require('./page/settings/signin.tag'); -require('./page/settings/api.tag'); require('./page/settings/authorized-apps.tag'); require('./page/settings/twitter.tag'); require('./page/messaging.tag'); @@ -25,7 +24,7 @@ require('./home-timeline.tag'); require('./timeline.tag'); require('./post-preview.tag'); require('./sub-post-content.tag'); -require('./images-viewer.tag'); +require('./images.tag'); require('./drive.tag'); require('./drive-selector.tag'); require('./drive-folder-selector.tag'); diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag index c3500d1b8..742cc4514 100644 --- a/src/web/app/mobile/tags/notifications.tag +++ b/src/web/app/mobile/tags/notifications.tag @@ -146,7 +146,7 @@ this.api('i/notifications', { limit: max + 1, - max_id: this.notifications[this.notifications.length - 1].id + until_id: this.notifications[this.notifications.length - 1].id }).then(notifications => { if (notifications.length == max + 1) { this.moreNotifications = true; diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag index 380fb780b..191874caf 100644 --- a/src/web/app/mobile/tags/page/entrance.tag +++ b/src/web/app/mobile/tags/page/entrance.tag @@ -8,7 +8,7 @@ </div> </main> <footer> - <mk-copyright/> + <p class="c">{ _COPYRIGHT_ }</p> </footer> <style> :scope @@ -34,7 +34,7 @@ margin 16px auto 0 auto > footer - > mk-copyright + > .c margin 0 text-align center line-height 64px diff --git a/src/web/app/mobile/tags/page/settings.tag b/src/web/app/mobile/tags/page/settings.tag index 978978214..9a73b0af3 100644 --- a/src/web/app/mobile/tags/page/settings.tag +++ b/src/web/app/mobile/tags/page/settings.tag @@ -24,7 +24,6 @@ <li><a href="./settings/authorized-apps">%fa:puzzle-piece%%i18n:mobile.tags.mk-settings-page.applications%%fa:angle-right%</a></li> <li><a href="./settings/twitter">%fa:B twitter%%i18n:mobile.tags.mk-settings-page.twitter-integration%%fa:angle-right%</a></li> <li><a href="./settings/signin-history">%fa:sign-in-alt%%i18n:mobile.tags.mk-settings-page.signin-history%%fa:angle-right%</a></li> - <li><a href="./settings/api">%fa:key%%i18n:mobile.tags.mk-settings-page.api%%fa:angle-right%</a></li> </ul> <ul> <li><a onclick={ signout }>%fa:power-off%%i18n:mobile.tags.mk-settings-page.signout%</a></li> diff --git a/src/web/app/mobile/tags/page/settings/api.tag b/src/web/app/mobile/tags/page/settings/api.tag deleted file mode 100644 index 8de0e9696..000000000 --- a/src/web/app/mobile/tags/page/settings/api.tag +++ /dev/null @@ -1,36 +0,0 @@ -<mk-api-info-page> - <mk-ui ref="ui"> - <mk-api-info/> - </mk-ui> - <style> - :scope - display block - </style> - <script> - import ui from '../../../scripts/ui-event'; - - this.on('mount', () => { - document.title = 'Misskey | API'; - ui.trigger('title', '%fa:key%API'); - }); - </script> -</mk-api-info-page> - -<mk-api-info> - <p>Token:<code>{ I.token }</code></p> - <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p> - <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p> - <p>万が一このトークンが漏れたりその可能性がある場合はデスクトップ版Misskeyから再生成できます。</p> - <style> - :scope - display block - color #4a535a - - code - padding 4px - background #eee - </style> - <script> - this.mixin('i'); - </script> -</mk-api-info> diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag index 9f212a249..1816d1bf9 100644 --- a/src/web/app/mobile/tags/post-detail.tag +++ b/src/web/app/mobile/tags/post-detail.tag @@ -34,7 +34,7 @@ <div class="body"> <div class="text" ref="text"></div> <div class="media" if={ p.media }> - <virtual each={ file in p.media }><img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/></virtual> + <mk-images images={ p.media }/> </div> <mk-poll if={ p.poll } post={ p }/> </div> diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag index 3ac7296f7..05466a6ec 100644 --- a/src/web/app/mobile/tags/post-form.tag +++ b/src/web/app/mobile/tags/post-form.tag @@ -9,12 +9,11 @@ <div class="form"> <mk-post-preview if={ opts.reply } post={ opts.reply }/> <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder={ opts.reply ? '%i18n:mobile.tags.mk-post-form.reply-placeholder%' : '%i18n:mobile.tags.mk-post-form.post-placeholder%' }></textarea> - <div class="attaches" if={ files.length != 0 }> + <div class="attaches" show={ files.length != 0 }> <ul class="files" ref="attaches"> - <li class="file" each={ files }> - <div class="img" style="background-image: url({ url + '?thumbnail&size=64' })" title={ name }></div> + <li class="file" each={ files } data-id={ id }> + <div class="img" style="background-image: url({ url + '?thumbnail&size=128' })" onclick={ removeFile }></div> </li> - <li class="add" if={ files.length < 4 } title="%i18n:mobile.tags.mk-post-form.attach-media-from-local%" onclick={ selectFile }>%fa:plus%</li> </ul> </div> <mk-poll-editor if={ poll } ref="poll" ondestroy={ onPollDestroyed }/> @@ -93,12 +92,9 @@ > .file display block float left - margin 4px + margin 0 padding 0 - cursor move - - &:hover > .remove - display block + border solid 4px transparent > .img width 64px @@ -106,38 +102,6 @@ background-size cover background-position center center - > .remove - display none - position absolute - top -6px - right -6px - width 16px - height 16px - cursor pointer - - > .add - display block - float left - margin 4px - padding 0 - border dashed 2px rgba($theme-color, 0.2) - cursor pointer - - &:hover - border-color rgba($theme-color, 0.3) - - > [data-fa] - color rgba($theme-color, 0.4) - - > [data-fa] - display block - width 60px - height 60px - line-height 60px - text-align center - font-size 1.2em - color rgba($theme-color, 0.2) - > mk-uploader margin 8px 0 0 0 padding 8px @@ -181,6 +145,7 @@ </style> <script> + import Sortable from 'sortablejs'; import getKao from '../../common/scripts/get-kao'; this.mixin('api'); @@ -200,6 +165,10 @@ }); this.refs.text.focus(); + + new Sortable(this.refs.attaches, { + animation: 150 + }); }); this.onkeydown = e => { @@ -247,6 +216,13 @@ this.update(); }; + this.removeFile = e => { + const file = e.item; + this.files = this.files.filter(x => x.id != file.id); + this.trigger('change-files', this.files); + this.update(); + }; + this.addPoll = () => { this.poll = true; }; @@ -258,15 +234,23 @@ }; this.post = () => { - this.wait = true; + this.update({ + wait: true + }); - const files = this.files && this.files.length > 0 - ? this.files.map(f => f.id) - : undefined; + const files = []; + + if (this.files.length > 0) { + Array.from(this.refs.attaches.children).forEach(el => { + const id = el.getAttribute('data-id'); + const file = this.files.find(f => f.id == id); + files.push(file); + }); + } this.api('posts/create', { text: this.refs.text.value == '' ? undefined : this.refs.text.value, - media_ids: files, + media_ids: this.files.length > 0 ? files.map(f => f.id) : undefined, reply_id: opts.reply ? opts.reply.id : undefined, poll: this.poll ? this.refs.poll.get() : undefined }).then(data => { diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag index 967764bc2..3e3c034f2 100644 --- a/src/web/app/mobile/tags/search-posts.tag +++ b/src/web/app/mobile/tags/search-posts.tag @@ -15,30 +15,28 @@ width calc(100% - 32px) </style> <script> + import parse from '../../common/scripts/parse-search-query'; + this.mixin('api'); - this.max = 30; + this.limit = 30; this.offset = 0; this.query = this.opts.query; - this.withMedia = this.opts.withMedia; this.init = new Promise((res, rej) => { - this.api('posts/search', { - query: this.query - }).then(posts => { + this.api('posts/search', parse(this.query)).then(posts => { res(posts); this.trigger('loaded'); }); }); this.more = () => { - this.offset += this.max; - return this.api('posts/search', { - query: this.query, - max: this.max, + this.offset += this.limit; + return this.api('posts/search', Object.assign({}, parse(this.query), { + limit: this.limit, offset: this.offset - }); + })); }; </script> </mk-search-posts> diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag index 9436b6c1d..adeb84dea 100644 --- a/src/web/app/mobile/tags/sub-post-content.tag +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -2,7 +2,7 @@ <div class="body"><a class="reply" if={ post.reply_id }>%fa:reply%</a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div> <details if={ post.media }> <summary>({ post.media.length }個のメディア)</summary> - <mk-images-viewer images={ post.media }/> + <mk-images images={ post.media }/> </details> <details if={ post.poll }> <summary>%i18n:mobile.tags.mk-sub-post-content.poll%</summary> diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag index 19f90a1c1..9e85f97da 100644 --- a/src/web/app/mobile/tags/timeline.tag +++ b/src/web/app/mobile/tags/timeline.tag @@ -172,7 +172,7 @@ <a class="quote" if={ p.repost != null }>RP:</a> </div> <div class="media" if={ p.media }> - <mk-images-viewer images={ p.media }/> + <mk-images images={ p.media }/> </div> <mk-poll if={ p.poll } post={ p } ref="pollViewer"/> <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span> diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag index 62e128489..77ad14530 100644 --- a/src/web/app/mobile/tags/ui.tag +++ b/src/web/app/mobile/tags/ui.tag @@ -248,7 +248,7 @@ <li><a href="/i/settings">%fa:cog%%i18n:mobile.tags.mk-ui-nav.settings%%fa:angle-right%</a></li> </ul> </div> - <a href={ _ABOUT_URL_ }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> + <a href={ aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> </div> <style> :scope @@ -359,6 +359,8 @@ this.connection = this.stream.getConnection(); this.connectionId = this.stream.use(); + this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/about`; + this.on('mount', () => { this.connection.on('read_all_notifications', this.onReadAllNotifications); this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); @@ -411,7 +413,7 @@ this.search = () => { const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); if (query == null || query == '') return; - this.page('/search:' + query); + this.page('/search?q=' + encodeURIComponent(query)); }; </script> </mk-ui-nav> diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag index 4dbe719f5..86ead5971 100644 --- a/src/web/app/mobile/tags/user-timeline.tag +++ b/src/web/app/mobile/tags/user-timeline.tag @@ -26,7 +26,7 @@ return this.api('users/posts', { user_id: this.user.id, with_media: this.withMedia, - max_id: this.refs.timeline.tail().id + until_id: this.refs.timeline.tail().id }); }; </script> diff --git a/src/web/assets/code-highlight.css b/src/web/assets/code-highlight.css new file mode 100644 index 000000000..f0807dc9c --- /dev/null +++ b/src/web/assets/code-highlight.css @@ -0,0 +1,93 @@ +.hljs { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +.hljs, +.hljs-subst { + color: #444; +} + +.hljs-comment { + color: #888888; +} + +.hljs-keyword { + color: #2973b7; +} + +.hljs-number { + color: #ae81ff; +} + +.hljs-string { + color: #e96900; +} + +.hljs-regexp { + color: #e9003f; +} + +.hljs-attribute, +.hljs-selector-tag, +.hljs-meta-keyword, +.hljs-doctag, +.hljs-name { + font-weight: bold; +} + +.hljs-type, +.hljs-selector-id, +.hljs-selector-class, +.hljs-quote, +.hljs-template-tag, +.hljs-deletion { + color: #880000; +} + +.hljs-title, +.hljs-section { + color: #880000; + font-weight: bold; +} + +.hljs-symbol, +.hljs-variable, +.hljs-template-variable, +.hljs-link, +.hljs-selector-attr, +.hljs-selector-pseudo { + color: #BC6060; +} + +/* Language color: hue: 90; */ + +.hljs-literal { + color: #78A960; +} + +.hljs-built_in, +.hljs-bullet, +.hljs-code, +.hljs-addition { + color: #397300; +} + +/* Meta color: hue: 200 */ + +.hljs-meta { + color: #1f7199; +} + +.hljs-meta-string { + color: #4d99bf; +} + +/* Misc effects */ + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} diff --git a/src/web/assets/recover.html b/src/web/assets/recover.html index 35afd2adf..4922b68d3 100644 --- a/src/web/assets/recover.html +++ b/src/web/assets/recover.html @@ -6,7 +6,7 @@ <title>Misskeyのリカバリ</title> <script> - const yn = window.confirm('キャッシュをクリアしますか?\n\nDo you want to clear caches?'); + const yn = window.confirm('キャッシュをクリアしますか?(他のタブでMisskeyを開いている状態だと正常にクリアできないので、他のMisskeyのタブをすべて閉じてから行ってください)\n\nDo you want to clear caches?'); if (yn) { try { diff --git a/src/web/const.styl b/src/web/const.styl new file mode 100644 index 000000000..b6560701d --- /dev/null +++ b/src/web/const.styl @@ -0,0 +1,4 @@ +json('../const.json') + +$theme-color = themeColor +$theme-color-foreground = themeColorForeground diff --git a/src/web/docs/about.en.pug b/src/web/docs/about.en.pug new file mode 100644 index 000000000..893d9dd6a --- /dev/null +++ b/src/web/docs/about.en.pug @@ -0,0 +1,3 @@ +h1 About Misskey + +p Misskey is a mini blog SNS. diff --git a/src/web/docs/about.ja.pug b/src/web/docs/about.ja.pug new file mode 100644 index 000000000..fec933b0c --- /dev/null +++ b/src/web/docs/about.ja.pug @@ -0,0 +1,3 @@ +h1 Misskeyについて + +p MisskeyはミニブログSNSです。 diff --git a/src/web/docs/api.ja.pug b/src/web/docs/api.ja.pug new file mode 100644 index 000000000..2bb08f7f3 --- /dev/null +++ b/src/web/docs/api.ja.pug @@ -0,0 +1,103 @@ +h1 Misskey API + +p MisskeyはWeb APIを公開しており、様々な操作をプログラム上から行うことができます。 +p APIを自分のアカウントから利用する場合(自分のアカウントのみ操作したい場合)と、アプリケーションから利用する場合(不特定のアカウントを操作したい場合)とで利用手順が異なりますので、それぞれのケースについて説明します。 + +section + h2 自分の所有するアカウントからAPIにアクセスする場合 + p 「設定 > API」で、APIにアクセスするのに必要なAPIキーを取得してください。 + p APIにアクセスする際には、リクエストにAPIキーを「i」というパラメータ名で含めます。 + div.ui.info.warn: p %fa:exclamation-triangle%アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + p APIの詳しい使用法は「Misskey APIの利用」セクションをご覧ください。 + +section + h2 アプリケーションからAPIにアクセスする場合 + p + | 直接ユーザーのAPIキーをアプリケーションが扱うのは危険なので、 + | アプリケーションからAPIを利用する際には、アプリケーションとアプリケーションを利用するユーザーが結び付けられた専用のトークン(アクセストークン)をMisskeyに発行してもらい、 + | そのトークンをリクエストのパラメータに含める必要があります。 + div.ui.info: p %fa:info-circle%アクセストークンは、ユーザーが自分のアカウントにあなたのアプリケーションがアクセスすることを許可した場合のみ発行されます + + p それでは、アクセストークンを取得するまでの流れを説明します。 + + section + h3 1.アプリケーションを登録する + p まず、あなたのアプリケーションやWebサービス(以後、あなたのアプリと呼びます)をMisskeyに登録します。 + p + a(href=common.config.dev_url, target="_blank") デベロッパーセンター + | にアクセスし、「アプリ > アプリ作成」に進みます。 + | フォームに必要事項を記入し、アプリを作成してください。フォームの記入欄の説明は以下の通りです: + + table + thead + tr + th 名前 + th 説明 + tbody + tr + td アプリケーション名 + td あなたのアプリの名称。 + tr + td アプリの概要 + td あなたのアプリの簡単な説明や紹介。 + tr + td コールバックURL + td ユーザーが後述する認証フォームで認証を終えた際にリダイレクトするURLを設定できます。あなたのアプリがWebサービスである場合に有用です。 + tr + td 権限 + td あなたのアプリが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 + + p 登録が済むとあなたのアプリのシークレットキーが入手できます。このシークレットキーは後で使用します。 + div.ui.info.warn: p %fa:exclamation-triangle%アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。 + + section + h3 2.ユーザーに認証させる + p あなたのアプリを使ってもらうには、ユーザーにアカウントへのアクセスの許可をもらう必要があります。 + p + | 認証セッションを開始するには、#{common.config.api_url}/auth/session/generate へパラメータに app_secret としてシークレットキーを含めたリクエストを送信します。 + | リクエスト形式はJSONで、メソッドはPOSTです。 + | レスポンスとして認証セッションのトークンや認証フォームのURLが取得できるので、認証フォームのURLをブラウザで表示し、ユーザーにフォームを提示してください。 + + p + | あなたのアプリがコールバックURLを設定している場合、 + | ユーザーがあなたのアプリの連携を許可すると設定しているコールバックURLに token という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + + p + | あなたのアプリがコールバックURLを設定していない場合、ユーザーがあなたのアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + + section + h3 3.ユーザーのアクセストークンを取得する + p ユーザーが連携を許可したら、#{common.config.api_url}/auth/session/userkey へ次のパラメータを含むリクエストを送信します: + table + thead + tr + th 名前 + th 型 + th 説明 + tbody + tr + td app_secret + td string + td あなたのアプリのシークレットキー + tr + td token + td string + td セッションのトークン + p 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! + + p アクセストークンが取得できたら、「ユーザーのアクセストークン+あなたのアプリのシークレットキーをsha256したもの」を「i」というパラメータでリクエストに含めると、APIにアクセスすることができます。 + + p 「i」パラメータの生成方法を擬似コードで表すと次のようになります: + pre: code + | const i = sha256(accessToken + secretKey); + + p APIの詳しい使用法は「Misskey APIの利用」セクションをご覧ください。 + +section + h2 Misskey APIの利用 + p APIはすべてリクエストのパラメータ・レスポンスともにJSON形式です。また、すべてのエンドポイントはPOSTメソッドのみ受け付けます。 + p APIリファレンスもご確認ください。 + + section + h3 レートリミット + p Misskey APIにはレートリミットがあり、短時間のうちに多数のリクエストを送信すると、一定時間APIを利用することができなくなることがあります。 diff --git a/src/web/docs/api/endpoints/posts/create.yaml b/src/web/docs/api/endpoints/posts/create.yaml new file mode 100644 index 000000000..5e2307dab --- /dev/null +++ b/src/web/docs/api/endpoints/posts/create.yaml @@ -0,0 +1,53 @@ +endpoint: "posts/create" + +desc: + ja: "投稿します。" + en: "Compose new post." + +params: + - name: "text" + type: "string" + optional: true + desc: + ja: "投稿の本文" + en: "The text of your post" + - name: "media_ids" + type: "id(DriveFile)[]" + optional: true + desc: + ja: "添付するメディア(1~4つ)" + en: "Media you want to attach (1~4)" + - name: "reply_id" + type: "id(Post)" + optional: true + desc: + ja: "返信する投稿" + en: "The post you want to reply" + - name: "repost_id" + type: "id(Post)" + optional: true + desc: + ja: "引用する投稿" + en: "The post you want to quote" + - name: "poll" + type: "object" + optional: true + desc: + ja: "投票" + en: "The poll" + defName: "poll" + def: + - name: "choices" + type: "string[]" + optional: false + desc: + ja: "投票の選択肢" + en: "Choices of a poll" + +res: + - name: "created_post" + type: "entity(Post)" + optional: false + desc: + ja: "作成した投稿" + en: "A post that created" diff --git a/src/web/docs/api/endpoints/posts/timeline.yaml b/src/web/docs/api/endpoints/posts/timeline.yaml new file mode 100644 index 000000000..01976b061 --- /dev/null +++ b/src/web/docs/api/endpoints/posts/timeline.yaml @@ -0,0 +1,32 @@ +endpoint: "posts/timeline" + +desc: + ja: "タイムラインを取得します。" + en: "Get your timeline." + +params: + - name: "limit" + type: "number" + optional: true + desc: + ja: "取得する最大の数" + - name: "since_id" + type: "id(Post)" + optional: true + desc: + ja: "指定すると、この投稿を基点としてより新しい投稿を取得します" + - name: "until_id" + type: "id(Post)" + optional: true + desc: + ja: "指定すると、この投稿を基点としてより古い投稿を取得します" + - name: "since_date" + type: "number" + optional: true + desc: + ja: "指定した時間を基点としてより新しい投稿を取得します。数値は、1970 年 1 月 1 日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。" + - name: "until_date" + type: "number" + optional: true + desc: + ja: "指定した時間を基点としてより古い投稿を取得します。数値は、1970 年 1 月 1 日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。" diff --git a/src/web/docs/api/endpoints/style.styl b/src/web/docs/api/endpoints/style.styl new file mode 100644 index 000000000..2af9fe9a7 --- /dev/null +++ b/src/web/docs/api/endpoints/style.styl @@ -0,0 +1,21 @@ +@import "../style" + +#url + padding 8px 12px 8px 8px + font-family Consolas, 'Courier New', Courier, Monaco, monospace + color #fff + background #222e40 + border-radius 4px + + > .method + display inline-block + margin 0 8px 0 0 + padding 0 6px + color #f4fcff + background #17afc7 + border-radius 4px + user-select none + pointer-events none + + > .host + opacity 0.7 diff --git a/src/web/docs/api/endpoints/view.pug b/src/web/docs/api/endpoints/view.pug new file mode 100644 index 000000000..d271a5517 --- /dev/null +++ b/src/web/docs/api/endpoints/view.pug @@ -0,0 +1,32 @@ +extends ../../layout.pug +include ../mixins + +block meta + link(rel="stylesheet" href="/assets/api/endpoints/style.css") + +block main + h1= endpoint + + p#url + span.method POST + span.host + = url.host + | / + span.path= url.path + + p#desc= desc[lang] || desc['ja'] + + section + h2 %i18n:docs.api.endpoints.params% + +propTable(params) + + if paramDefs + each paramDef in paramDefs + section(id= paramDef.name) + h3= paramDef.name + +propTable(paramDef.params) + + if res + section + h2 %i18n:docs.api.endpoints.res% + +propTable(res) diff --git a/src/web/docs/api/entities/drive-file.yaml b/src/web/docs/api/entities/drive-file.yaml new file mode 100644 index 000000000..2ebbb089a --- /dev/null +++ b/src/web/docs/api/entities/drive-file.yaml @@ -0,0 +1,73 @@ +name: "DriveFile" + +desc: + ja: "ドライブのファイル。" + en: "A file of Drive." + +props: + - name: "id" + type: "id" + optional: false + desc: + ja: "ファイルID" + en: "The ID of this file" + - name: "created_at" + type: "date" + optional: false + desc: + ja: "アップロード日時" + en: "The upload date of this file" + - name: "user_id" + type: "id(User)" + optional: false + desc: + ja: "所有者ID" + en: "The ID of the owner of this file" + - name: "user" + type: "entity(User)" + optional: true + desc: + ja: "所有者" + en: "The owner of this file" + - name: "name" + type: "string" + optional: false + desc: + ja: "ファイル名" + en: "The name of this file" + - name: "md5" + type: "string" + optional: false + desc: + ja: "ファイルのMD5ハッシュ値" + en: "The md5 hash value of this file" + - name: "type" + type: "string" + optional: false + desc: + ja: "ファイルの種類" + en: "The type of this file" + - name: "datasize" + type: "number" + optional: false + desc: + ja: "ファイルサイズ(bytes)" + en: "The size of this file (bytes)" + - name: "url" + type: "string" + optional: false + desc: + ja: "ファイルのURL" + en: "The URL of this file" + - name: "folder_id" + type: "id(DriveFolder)" + optional: true + desc: + ja: "フォルダID" + en: "The ID of the folder of this file" + - name: "folder" + type: "entity(DriveFolder)" + optional: true + desc: + ja: "フォルダ" + en: "The folder of this file" diff --git a/src/web/docs/api/entities/post.yaml b/src/web/docs/api/entities/post.yaml new file mode 100644 index 000000000..551f3b7c3 --- /dev/null +++ b/src/web/docs/api/entities/post.yaml @@ -0,0 +1,124 @@ +name: "Post" + +desc: + ja: "投稿。" + en: "A post." + +props: + - name: "id" + type: "id" + optional: false + desc: + ja: "投稿ID" + en: "The ID of this post" + - name: "created_at" + type: "date" + optional: false + desc: + ja: "投稿日時" + en: "The posted date of this post" + - name: "text" + type: "string" + optional: true + desc: + ja: "投稿の本文" + en: "The text of this post" + - name: "media_ids" + type: "id(DriveFile)[]" + optional: true + desc: + ja: "添付されているメディアのID" + en: "The IDs of the attached media" + - name: "media" + type: "entity(DriveFile)[]" + optional: true + desc: + ja: "添付されているメディア" + en: "The attached media" + - name: "user_id" + type: "id(User)" + optional: false + desc: + ja: "投稿者ID" + en: "The ID of author of this post" + - name: "user" + type: "entity(User)" + optional: true + desc: + ja: "投稿者" + en: "The author of this post" + - name: "my_reaction" + type: "string" + optional: true + desc: + ja: "この投稿に対する自分の<a href='/docs/api/reactions'>リアクション</a>" + en: "The your <a href='/docs/api/reactions'>reaction</a> of this post" + - name: "reaction_counts" + type: "object" + optional: false + desc: + ja: "<a href='/docs/api/reactions'>リアクション</a>をキーとし、この投稿に対するそのリアクションの数を値としたオブジェクト" + - name: "reply_id" + type: "id(Post)" + optional: true + desc: + ja: "返信した投稿のID" + en: "The ID of the replyed post" + - name: "reply" + type: "entity(Post)" + optional: true + desc: + ja: "返信した投稿" + en: "The replyed post" + - name: "repost_id" + type: "id(Post)" + optional: true + desc: + ja: "引用した投稿のID" + en: "The ID of the quoted post" + - name: "repost" + type: "entity(Post)" + optional: true + desc: + ja: "引用した投稿" + en: "The quoted post" + - name: "poll" + type: "object" + optional: true + desc: + ja: "投票" + en: "The poll" + defName: "poll" + def: + - name: "choices" + type: "object[]" + optional: false + desc: + ja: "投票の選択肢" + en: "The choices of this poll" + defName: "choice" + def: + - name: "id" + type: "number" + optional: false + desc: + ja: "選択肢ID" + en: "The ID of this choice" + - name: "is_voted" + type: "boolean" + optional: true + desc: + ja: "自分がこの選択肢に投票したかどうか" + en: "Whether you voted to this choice" + - name: "text" + type: "string" + optional: false + desc: + ja: "選択肢本文" + en: "The text of this choice" + - name: "votes" + type: "number" + optional: false + desc: + ja: "この選択肢に投票された数" + en: "The number voted for this choice" diff --git a/src/web/docs/api/entities/style.styl b/src/web/docs/api/entities/style.styl new file mode 100644 index 000000000..bddf0f53a --- /dev/null +++ b/src/web/docs/api/entities/style.styl @@ -0,0 +1 @@ +@import "../style" diff --git a/src/web/docs/api/entities/user.yaml b/src/web/docs/api/entities/user.yaml new file mode 100644 index 000000000..e62ad84db --- /dev/null +++ b/src/web/docs/api/entities/user.yaml @@ -0,0 +1,153 @@ +name: "User" + +desc: + ja: "ユーザー。" + en: "A user." + +props: + - name: "id" + type: "id" + optional: false + desc: + ja: "ユーザーID" + en: "The ID of this user" + - name: "created_at" + type: "date" + optional: false + desc: + ja: "アカウント作成日時" + en: "The registered date of this user" + - name: "username" + type: "string" + optional: false + desc: + ja: "ユーザー名" + en: "The username of this user" + - name: "description" + type: "string" + optional: false + desc: + ja: "アカウントの説明(自己紹介)" + en: "The description of this user" + - name: "avatar_id" + type: "id(DriveFile)" + optional: true + desc: + ja: "アバターのID" + en: "The ID of the avatar of this user" + - name: "avatar_url" + type: "string" + optional: false + desc: + ja: "アバターのURL" + en: "The URL of the avatar of this user" + - name: "banner_id" + type: "id(DriveFile)" + optional: true + desc: + ja: "バナーのID" + en: "The ID of the banner of this user" + - name: "banner_url" + type: "string" + optional: false + desc: + ja: "バナーのURL" + en: "The URL of the banner of this user" + - name: "followers_count" + type: "number" + optional: false + desc: + ja: "フォロワーの数" + en: "The number of the followers for this user" + - name: "following_count" + type: "number" + optional: false + desc: + ja: "フォローしているユーザーの数" + en: "The number of the following users for this user" + - name: "is_following" + type: "boolean" + optional: true + desc: + ja: "自分がこのユーザーをフォローしているか" + - name: "is_followed" + type: "boolean" + optional: true + desc: + ja: "自分がこのユーザーにフォローされているか" + - name: "is_muted" + type: "boolean" + optional: true + desc: + ja: "自分がこのユーザーをミュートしているか" + en: "Whether you muted this user" + - name: "last_used_at" + type: "date" + optional: false + desc: + ja: "最終利用日時" + en: "The last used date of this user" + - name: "posts_count" + type: "number" + optional: false + desc: + ja: "投稿の数" + en: "The number of the posts of this user" + - name: "pinned_post" + type: "entity(Post)" + optional: true + desc: + ja: "ピン留めされた投稿" + en: "The pinned post of this user" + - name: "pinned_post_id" + type: "id(Post)" + optional: true + desc: + ja: "ピン留めされた投稿のID" + en: "The ID of the pinned post of this user" + - name: "drive_capacity" + type: "number" + optional: false + desc: + ja: "ドライブの容量(bytes)" + en: "The capacity of drive of this user (bytes)" + - name: "twitter" + type: "object" + optional: true + desc: + ja: "連携されているTwitterアカウント情報" + en: "The info of the connected twitter account of this user" + defName: "twitter" + def: + - name: "user_id" + type: "string" + optional: false + desc: + ja: "ユーザーID" + en: "The user ID" + - name: "screen_name" + type: "string" + optional: false + desc: + ja: "ユーザー名" + en: "The screen name of this user" + - name: "profile" + type: "object" + optional: false + desc: + ja: "プロフィール" + en: "The profile of this user" + defName: "profile" + def: + - name: "location" + type: "string" + optional: true + desc: + ja: "場所" + en: "The location of this user" + - name: "birthday" + type: "string" + optional: true + desc: + ja: "誕生日 (YYYY-MM-DD)" + en: "The birthday of this user (YYYY-MM-DD)" diff --git a/src/web/docs/api/entities/view.pug b/src/web/docs/api/entities/view.pug new file mode 100644 index 000000000..2156463dc --- /dev/null +++ b/src/web/docs/api/entities/view.pug @@ -0,0 +1,20 @@ +extends ../../layout.pug +include ../mixins + +block meta + link(rel="stylesheet" href="/assets/api/entities/style.css") + +block main + h1= name + + p#desc= desc[lang] || desc['ja'] + + section + h2 %i18n:docs.api.entities.properties% + +propTable(props) + + if propDefs + each propDef in propDefs + section(id= propDef.name) + h3= propDef.name + +propTable(propDef.params) diff --git a/src/web/docs/api/gulpfile.ts b/src/web/docs/api/gulpfile.ts new file mode 100644 index 000000000..cd1bf1530 --- /dev/null +++ b/src/web/docs/api/gulpfile.ts @@ -0,0 +1,188 @@ +/** + * Gulp tasks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; +import * as gulp from 'gulp'; +import * as pug from 'pug'; +import * as yaml from 'js-yaml'; +import * as mkdirp from 'mkdirp'; + +import locales from '../../../../locales'; +import I18nReplacer from '../../../common/build/i18n'; +import fa from '../../../common/build/fa'; +import config from './../../../conf'; + +import generateVars from '../vars'; + +const langs = Object.keys(locales); + +const kebab = string => string.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase(); + +const parseParam = param => { + const id = param.type.match(/^id\((.+?)\)|^id/); + const entity = param.type.match(/^entity\((.+?)\)/); + const isObject = /^object/.test(param.type); + const isDate = /^date/.test(param.type); + const isArray = /\[\]$/.test(param.type); + if (id) { + param.kind = 'id'; + param.type = 'string'; + param.entity = id[1]; + if (isArray) { + param.type += '[]'; + } + } + if (entity) { + param.kind = 'entity'; + param.type = 'object'; + param.entity = entity[1]; + if (isArray) { + param.type += '[]'; + } + } + if (isObject) { + param.kind = 'object'; + } + if (isDate) { + param.kind = 'date'; + param.type = 'string'; + if (isArray) { + param.type += '[]'; + } + } + + return param; +}; + +const sortParams = params => { + params.sort((a, b) => { + if (a.name < b.name) + return -1; + if (a.name > b.name) + return 1; + return 0; + }); + return params; +}; + +const extractDefs = params => { + let defs = []; + + params.forEach(param => { + if (param.def) { + defs.push({ + name: param.defName, + params: sortParams(param.def.map(p => parseParam(p))) + }); + + const childDefs = extractDefs(param.def); + + defs = defs.concat(childDefs); + } + }); + + return sortParams(defs); +}; + +gulp.task('doc:api', [ + 'doc:api:endpoints', + 'doc:api:entities' +]); + +gulp.task('doc:api:endpoints', async () => { + const commonVars = await generateVars(); + glob('./src/web/docs/api/endpoints/**/*.yaml', (globErr, files) => { + if (globErr) { + console.error(globErr); + return; + } + //console.log(files); + files.forEach(file => { + const ep = yaml.safeLoad(fs.readFileSync(file, 'utf-8')); + const vars = { + endpoint: ep.endpoint, + url: { + host: config.api_url, + path: ep.endpoint + }, + desc: ep.desc, + params: sortParams(ep.params.map(p => parseParam(p))), + paramDefs: extractDefs(ep.params), + res: ep.res ? sortParams(ep.res.map(p => parseParam(p))) : null, + resDefs: ep.res ? extractDefs(ep.res) : null, + }; + langs.forEach(lang => { + pug.renderFile('./src/web/docs/api/endpoints/view.pug', Object.assign({}, vars, { + lang, + title: ep.endpoint, + src: `https://github.com/syuilo/misskey/tree/master/src/web/docs/api/endpoints/${ep.endpoint}.yaml`, + kebab, + common: commonVars + }), (renderErr, html) => { + if (renderErr) { + console.error(renderErr); + return; + } + const i18n = new I18nReplacer(lang); + html = html.replace(i18n.pattern, i18n.replacement); + html = fa(html); + const htmlPath = `./built/web/docs/${lang}/api/endpoints/${ep.endpoint}.html`; + mkdirp(path.dirname(htmlPath), (mkdirErr) => { + if (mkdirErr) { + console.error(mkdirErr); + return; + } + fs.writeFileSync(htmlPath, html, 'utf-8'); + }); + }); + }); + }); + }); +}); + +gulp.task('doc:api:entities', async () => { + const commonVars = await generateVars(); + glob('./src/web/docs/api/entities/**/*.yaml', (globErr, files) => { + if (globErr) { + console.error(globErr); + return; + } + files.forEach(file => { + const entity = yaml.safeLoad(fs.readFileSync(file, 'utf-8')); + const vars = { + name: entity.name, + desc: entity.desc, + props: sortParams(entity.props.map(p => parseParam(p))), + propDefs: extractDefs(entity.props), + }; + langs.forEach(lang => { + pug.renderFile('./src/web/docs/api/entities/view.pug', Object.assign({}, vars, { + lang, + title: entity.name, + src: `https://github.com/syuilo/misskey/tree/master/src/web/docs/api/entities/${kebab(entity.name)}.yaml`, + kebab, + common: commonVars + }), (renderErr, html) => { + if (renderErr) { + console.error(renderErr); + return; + } + const i18n = new I18nReplacer(lang); + html = html.replace(i18n.pattern, i18n.replacement); + html = fa(html); + const htmlPath = `./built/web/docs/${lang}/api/entities/${kebab(entity.name)}.html`; + mkdirp(path.dirname(htmlPath), (mkdirErr) => { + if (mkdirErr) { + console.error(mkdirErr); + return; + } + fs.writeFileSync(htmlPath, html, 'utf-8'); + }); + }); + }); + }); + }); +}); diff --git a/src/web/docs/api/mixins.pug b/src/web/docs/api/mixins.pug new file mode 100644 index 000000000..686bf6a2b --- /dev/null +++ b/src/web/docs/api/mixins.pug @@ -0,0 +1,37 @@ +mixin propTable(props) + table.props + thead: tr + th %i18n:docs.api.props.name% + th %i18n:docs.api.props.type% + th %i18n:docs.api.props.optional% + th %i18n:docs.api.props.description% + tbody + each prop in props + tr + td.name= prop.name + td.type + i= prop.type + if prop.kind == 'id' + if prop.entity + | ( + a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity + | ID) + else + | (ID) + else if prop.kind == 'entity' + | ( + a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity + | ) + else if prop.kind == 'object' + if prop.def + | ( + a(href=`#${prop.defName}`)= prop.defName + | ) + else if prop.kind == 'date' + | (Date) + td.optional + if prop.optional + | %i18n:docs.api.props.yes% + else + | %i18n:docs.api.props.no% + td.desc!= prop.desc[lang] || prop.desc['ja'] diff --git a/src/web/docs/api/style.styl b/src/web/docs/api/style.styl new file mode 100644 index 000000000..3675a4da6 --- /dev/null +++ b/src/web/docs/api/style.styl @@ -0,0 +1,11 @@ +@import "../style" + +table.props + .name + font-weight bold + + .name + .type + .optional + font-family Consolas, 'Courier New', Courier, Monaco, monospace + diff --git a/src/web/docs/gulpfile.ts b/src/web/docs/gulpfile.ts new file mode 100644 index 000000000..d5ddda108 --- /dev/null +++ b/src/web/docs/gulpfile.ts @@ -0,0 +1,77 @@ +/** + * Gulp tasks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; +import * as gulp from 'gulp'; +import * as pug from 'pug'; +import * as mkdirp from 'mkdirp'; +import stylus = require('gulp-stylus'); +import cssnano = require('gulp-cssnano'); + +import I18nReplacer from '../../common/build/i18n'; +import fa from '../../common/build/fa'; +import generateVars from './vars'; + +require('./api/gulpfile.ts'); + +gulp.task('doc', [ + 'doc:docs', + 'doc:api', + 'doc:styles' +]); + +gulp.task('doc:docs', async () => { + const commonVars = await generateVars(); + + glob('./src/web/docs/**/*.*.pug', (globErr, files) => { + if (globErr) { + console.error(globErr); + return; + } + files.forEach(file => { + const [, name, lang] = file.match(/docs\/(.+?)\.(.+?)\.pug$/); + const vars = { + common: commonVars, + lang: lang, + title: fs.readFileSync(file, 'utf-8').match(/^h1 (.+?)\r?\n/)[1], + src: `https://github.com/syuilo/misskey/tree/master/src/web/docs/${name}.${lang}.pug`, + }; + pug.renderFile(file, vars, (renderErr, content) => { + if (renderErr) { + console.error(renderErr); + return; + } + + pug.renderFile('./src/web/docs/layout.pug', Object.assign({}, vars, { + content + }), (renderErr2, html) => { + if (renderErr2) { + console.error(renderErr2); + return; + } + const i18n = new I18nReplacer(lang); + html = html.replace(i18n.pattern, i18n.replacement); + html = fa(html); + const htmlPath = `./built/web/docs/${lang}/${name}.html`; + mkdirp(path.dirname(htmlPath), (mkdirErr) => { + if (mkdirErr) { + console.error(mkdirErr); + return; + } + fs.writeFileSync(htmlPath, html, 'utf-8'); + }); + }); + }); + }); + }); +}); + +gulp.task('doc:styles', () => + gulp.src('./src/web/docs/**/*.styl') + .pipe(stylus()) + .pipe((cssnano as any)()) + .pipe(gulp.dest('./built/web/docs/assets/')) +); diff --git a/src/web/docs/index.en.pug b/src/web/docs/index.en.pug new file mode 100644 index 000000000..1fcc870d3 --- /dev/null +++ b/src/web/docs/index.en.pug @@ -0,0 +1,3 @@ +h1 Misskey Docs + +p Welcome to docs of Misskey. diff --git a/src/web/docs/index.ja.pug b/src/web/docs/index.ja.pug new file mode 100644 index 000000000..4a0bf7fa1 --- /dev/null +++ b/src/web/docs/index.ja.pug @@ -0,0 +1,3 @@ +h1 Misskey ドキュメント + +p Misskeyのドキュメントへようこそ diff --git a/src/web/docs/layout.pug b/src/web/docs/layout.pug new file mode 100644 index 000000000..9dfd0ab7a --- /dev/null +++ b/src/web/docs/layout.pug @@ -0,0 +1,41 @@ +doctype html + +html(lang= lang) + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no") + title + | #{title} | Misskey Docs + link(rel="stylesheet" href="/assets/style.css") + block meta + + //- FontAwesome style + style #{common.facss} + + body + nav + ul + each doc in common.docs + li: a(href=`/${lang}/${doc.name}`)= doc.title[lang] || doc.title['ja'] + section + h2 API + ul + li Entities + ul + each entity in common.entities + li: a(href=`/${lang}/api/entities/${common.kebab(entity)}`)= entity + li Endpoints + ul + each endpoint in common.endpoints + li: a(href=`/${lang}/api/endpoints/${common.kebab(endpoint)}`)= endpoint + main + article + block main + if content + | !{content} + + footer + p + | %i18n:docs.edit-this-page-on-github% + a(href=src target="_blank") %i18n:docs.edit-this-page-on-github-link% + small= common.copyright diff --git a/src/web/docs/license.en.pug b/src/web/docs/license.en.pug new file mode 100644 index 000000000..45d8b7647 --- /dev/null +++ b/src/web/docs/license.en.pug @@ -0,0 +1,17 @@ +h1 License + +div!= common.license + +details + summary Libraries + + section + h2 Libraries + + each dependency, name in common.dependencies + details + summary= name + + section + h3= name + pre= dependency.licenseText diff --git a/src/web/docs/license.ja.pug b/src/web/docs/license.ja.pug new file mode 100644 index 000000000..7bd9a6294 --- /dev/null +++ b/src/web/docs/license.ja.pug @@ -0,0 +1,17 @@ +h1 ライセンス + +div!= common.license + +details + summary ライブラリ + + section + h2 ライブラリ + + each dependency, name in common.dependencies + details + summary= name + + section + h3= name + pre= dependency.licenseText diff --git a/src/web/docs/mute.ja.pug b/src/web/docs/mute.ja.pug new file mode 100644 index 000000000..5e79af5f8 --- /dev/null +++ b/src/web/docs/mute.ja.pug @@ -0,0 +1,13 @@ +h1 ミュート + +p ユーザーページから、そのユーザーをミュートすることができます。 + +p ユーザーをミュートすると、そのユーザーに関する次のコンテンツがMisskeyに表示されなくなります: +ul + li タイムラインや投稿の検索結果内の、そのユーザーの投稿(およびそれらの投稿に対する返信やRepost) + li そのユーザーからの通知 + li メッセージ履歴一覧内の、そのユーザーとのメッセージ履歴 + +p ミュートを行ったことは相手に通知されず、ミュートされていることを知ることもできません。 + +p 設定>ミュート から、自分がミュートしているユーザー一覧を確認することができます。 diff --git a/src/web/docs/search.ja.pug b/src/web/docs/search.ja.pug new file mode 100644 index 000000000..f33091ee6 --- /dev/null +++ b/src/web/docs/search.ja.pug @@ -0,0 +1,116 @@ +h1 検索 + +p 投稿を検索することができます。 +p + | キーワードを半角スペースで区切ると、and検索になります。 + | 例えば、「git コミット」と検索すると、「gitで編集したファイルの特定の行だけコミットする方法がわからない」などがマッチします。 + +section + h2 キーワードの除外 + p キーワードの前に「-」(ハイフン)をプリフィクスすると、そのキーワードを含まない投稿に限定します。 + p 例えば、「gitというキーワードを含むが、コミットというキーワードは含まない投稿」を検索したい場合、クエリは以下のようになります: + code git -コミット + +section + h2 完全一致 + p テキストを「"""」で囲むと、そのテキストと完全に一致する投稿を検索します。 + p 例えば、「"""にゃーん"""」と検索すると、「にゃーん」という投稿のみがヒットし、「にゃーん…」という投稿はヒットしません。 + +section + h2 オプション + p + | オプションを使用して、より高度な検索を行えます。 + | オプションを指定するには、「オプション名:値」という形式でクエリに含めます。 + p 利用可能なオプション一覧です: + + table + thead + tr + th 名前 + th 説明 + tbody + tr + td user + td + | 指定されたユーザー名のユーザーの投稿に限定します。 + | 「,」(カンマ)で区切って、複数ユーザーを指定することもできます。 + br + | 例えば、 + code user:himawari,sakurako + | と検索すると「@himawariまたは@sakurakoの投稿」だけに限定します。 + | (つまりユーザーのホワイトリストです) + tr + td exclude_user + td + | 指定されたユーザー名のユーザーの投稿を除外します。 + | 「,」(カンマ)で区切って、複数ユーザーを指定することもできます。 + br + | 例えば、 + code exclude_user:akari,chinatsu + | と検索すると「@akariまたは@chinatsu以外の投稿」に限定します。 + | (つまりユーザーのブラックリストです) + tr + td follow + td + | true ... フォローしているユーザーに限定。 + br + | false ... フォローしていないユーザーに限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td mute + td + | mute_all ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostを除外する(デフォルト) + br + | mute_related ... ミュートしているユーザーの投稿に対する返信やRepostだけ除外する + br + | mute_direct ... ミュートしているユーザーの投稿だけ除外する + br + | disabled ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostも含める + br + | direct_only ... ミュートしているユーザーの投稿だけに限定 + br + | related_only ... ミュートしているユーザーの投稿に対する返信やRepostだけに限定 + br + | all_only ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostに限定 + tr + td reply + td + | true ... 返信に限定。 + br + | false ... 返信でない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td repost + td + | true ... Repostに限定。 + br + | false ... Repostでない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td media + td + | true ... メディアが添付されている投稿に限定。 + br + | false ... メディアが添付されていない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td poll + td + | true ... 投票が添付されている投稿に限定。 + br + | false ... 投票が添付されていない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td until + td 上限の日時。(YYYY-MM-DD) + tr + td since + td 下限の日時。(YYYY-MM-DD) + + p 例えば、「@syuiloの2017年11月1日から2017年12月31日までの『Misskey』というテキストを含む返信ではない投稿」を検索したい場合、クエリは以下のようになります: + code user:syuilo since:2017-11-01 until:2017-12-31 reply:false Misskey diff --git a/src/web/docs/server.ts b/src/web/docs/server.ts new file mode 100644 index 000000000..b2e50457e --- /dev/null +++ b/src/web/docs/server.ts @@ -0,0 +1,21 @@ +/** + * Docs Server + */ + +import * as express from 'express'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); + +app.use('/assets', express.static(`${__dirname}/assets`)); + +/** + * Routing + */ +app.get(/^\/([a-z_\-\/]+?)$/, (req, res) => + res.sendFile(`${__dirname}/${req.params[0]}.html`)); + +module.exports = app; diff --git a/src/web/docs/style.styl b/src/web/docs/style.styl new file mode 100644 index 000000000..bc165f872 --- /dev/null +++ b/src/web/docs/style.styl @@ -0,0 +1,120 @@ +@import "../style" +@import "./ui" + +body + margin 0 + color #34495e + word-break break-word + +main + margin 0 0 0 256px + padding 64px + width 100% + max-width 768px + + section + margin 32px 0 + + h1 + margin 0 0 24px 0 + padding 16px 0 + font-size 1.5em + border-bottom solid 2px #eee + + h2 + margin 0 0 24px 0 + padding 0 0 16px 0 + font-size 1.4em + border-bottom solid 1px #eee + + h3 + margin 0 + padding 0 + font-size 1.25em + + h4 + margin 0 + + p + margin 1em 0 + line-height 1.6em + + footer + margin 32px 0 0 0 + border-top solid 2px #eee + + > small + margin 16px 0 0 0 + color #aaa + +nav + display block + position fixed + z-index 10000 + top 0 + left 0 + width 256px + height 100% + overflow auto + padding 32px + background #fff + border-right solid 2px #eee + +@media (max-width 1025px) + main + margin 0 + max-width 100% + + nav + position relative + width 100% + max-height 128px + background #f9f9f9 + border-right none + +@media (max-width 768px) + main + padding 32px + +@media (max-width 512px) + main + padding 16px + +table + display block + width 100% + max-width 100% + overflow auto + border-spacing 0 + border-collapse collapse + + thead + font-weight bold + border-bottom solid 2px #eee + + tr + th + text-align left + + tbody + tr + &:nth-child(odd) + background #fbfbfb + + th, td + padding 8px 16px + min-width 128px + +code + display inline-block + padding 8px 10px + font-family Consolas, 'Courier New', Courier, Monaco, monospace + color #295c92 + background #f2f2f2 + border-radius 4px + +pre + overflow auto + + > code + display block diff --git a/src/web/docs/tou.ja.pug b/src/web/docs/tou.ja.pug new file mode 100644 index 000000000..7663258f8 --- /dev/null +++ b/src/web/docs/tou.ja.pug @@ -0,0 +1,3 @@ +h1 利用規約 + +p 公序良俗に反する行為はおやめください。 diff --git a/src/web/docs/ui.styl b/src/web/docs/ui.styl new file mode 100644 index 000000000..8d5515712 --- /dev/null +++ b/src/web/docs/ui.styl @@ -0,0 +1,19 @@ +.ui.info + display block + margin 1em 0 + padding 0 1em + font-size 90% + color rgba(#000, 0.87) + background #f8f8f9 + border-radius 4px + overflow hidden + + > p + opacity 0.8 + + > [data-fa]:first-child + margin-right 0.25em + + &.warn + color #573a08 + background #FFFAF3 diff --git a/src/web/docs/vars.ts b/src/web/docs/vars.ts new file mode 100644 index 000000000..6f713f21d --- /dev/null +++ b/src/web/docs/vars.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as util from 'util'; +import * as glob from 'glob'; +import * as yaml from 'js-yaml'; +import * as licenseChecker from 'license-checker'; +import * as tmp from 'tmp'; + +import { fa } from '../../common/build/fa'; +import config from '../../conf'; +import { licenseHtml } from '../../common/build/license'; +const constants = require('../../const.json'); + +export default async function(): Promise<{ [key: string]: any }> { + const vars = {} as { [key: string]: any }; + + const endpoints = glob.sync('./src/web/docs/api/endpoints/**/*.yaml'); + vars['endpoints'] = endpoints.map(ep => { + const _ep = yaml.safeLoad(fs.readFileSync(ep, 'utf-8')); + return _ep.endpoint; + }); + + const entities = glob.sync('./src/web/docs/api/entities/**/*.yaml'); + vars['entities'] = entities.map(x => { + const _x = yaml.safeLoad(fs.readFileSync(x, 'utf-8')); + return _x.name; + }); + + const docs = glob.sync('./src/web/docs/**/*.*.pug'); + vars['docs'] = {}; + docs.forEach(x => { + const [, name, lang] = x.match(/docs\/(.+?)\.(.+?)\.pug$/); + if (vars['docs'][name] == null) { + vars['docs'][name] = { + name, + title: {} + }; + } + vars['docs'][name]['title'][lang] = fs.readFileSync(x, 'utf-8').match(/^h1 (.+?)\r?\n/)[1]; + }); + + vars['kebab'] = string => string.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase(); + + vars['config'] = config; + + vars['copyright'] = constants.copyright; + + vars['facss'] = fa.dom.css(); + + vars['license'] = licenseHtml; + + const tmpObj = tmp.fileSync(); + fs.writeFileSync(tmpObj.name, JSON.stringify({ + licenseText: '' + }), 'utf-8'); + const dependencies = await util.promisify(licenseChecker.init).bind(licenseChecker)({ + start: __dirname + '/../../../', + customPath: tmpObj.name + }); + tmpObj.removeCallback(); + + vars['dependencies'] = dependencies; + + return vars; +} diff --git a/src/web/server.ts b/src/web/server.ts index 1d3687f89..062d1f197 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -10,6 +10,9 @@ import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as favicon from 'serve-favicon'; import * as compression from 'compression'; +import vhost = require('vhost'); + +import config from '../conf'; /** * Init app @@ -17,6 +20,8 @@ import * as compression from 'compression'; const app = express(); app.disable('x-powered-by'); +app.use(vhost(`docs.${config.host}`, require('./docs/server'))); + app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json({ type: ['application/json', 'text/plain'] diff --git a/src/web/style.styl b/src/web/style.styl new file mode 100644 index 000000000..c25fc8fb5 --- /dev/null +++ b/src/web/style.styl @@ -0,0 +1,35 @@ +@charset 'utf-8' + +@import "./const" + +/* + ::selection + background $theme-color + color #fff +*/ + +* + 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) + +html, body + margin 0 + padding 0 + scroll-behavior smooth + text-size-adjust 100% + font-family sans-serif + +a + text-decoration none + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + * + cursor pointer + diff --git a/test/api.js b/test/api.js index 49f1faa53..500b9adb7 100644 --- a/test/api.js +++ b/test/api.js @@ -224,7 +224,8 @@ describe('API', () => { const res = await request('/posts/create', post, me); res.should.have.status(200); res.body.should.be.a('object'); - res.body.should.have.property('text').eql(post.text); + res.body.should.have.property('created_post'); + res.body.created_post.should.have.property('text').eql(post.text); })); it('ファイルを添付できる', async(async () => { @@ -237,7 +238,8 @@ describe('API', () => { }, me); res.should.have.status(200); res.body.should.be.a('object'); - res.body.should.have.property('media_ids').eql([file._id.toString()]); + res.body.should.have.property('created_post'); + res.body.created_post.should.have.property('media_ids').eql([file._id.toString()]); })); it('他人のファイルは添付できない', async(async () => { @@ -283,10 +285,11 @@ describe('API', () => { const res = await request('/posts/create', post, me); res.should.have.status(200); res.body.should.be.a('object'); - res.body.should.have.property('text').eql(post.text); - res.body.should.have.property('reply_id').eql(post.reply_id); - res.body.should.have.property('reply'); - res.body.reply.should.have.property('text').eql(himaPost.text); + res.body.should.have.property('created_post'); + res.body.created_post.should.have.property('text').eql(post.text); + res.body.created_post.should.have.property('reply_id').eql(post.reply_id); + res.body.created_post.should.have.property('reply'); + res.body.created_post.reply.should.have.property('text').eql(himaPost.text); })); it('repostできる', async(async () => { @@ -303,9 +306,10 @@ describe('API', () => { const res = await request('/posts/create', post, me); res.should.have.status(200); res.body.should.be.a('object'); - res.body.should.have.property('repost_id').eql(post.repost_id); - res.body.should.have.property('repost'); - res.body.repost.should.have.property('text').eql(himaPost.text); + res.body.should.have.property('created_post'); + res.body.created_post.should.have.property('repost_id').eql(post.repost_id); + res.body.created_post.should.have.property('repost'); + res.body.created_post.repost.should.have.property('text').eql(himaPost.text); })); it('引用repostできる', async(async () => { @@ -323,10 +327,11 @@ describe('API', () => { const res = await request('/posts/create', post, me); res.should.have.status(200); res.body.should.be.a('object'); - res.body.should.have.property('text').eql(post.text); - res.body.should.have.property('repost_id').eql(post.repost_id); - res.body.should.have.property('repost'); - res.body.repost.should.have.property('text').eql(himaPost.text); + res.body.should.have.property('created_post'); + res.body.created_post.should.have.property('text').eql(post.text); + res.body.created_post.should.have.property('repost_id').eql(post.repost_id); + res.body.created_post.should.have.property('repost'); + res.body.created_post.repost.should.have.property('text').eql(himaPost.text); })); it('文字数ぎりぎりで怒られない', async(async () => { @@ -395,7 +400,8 @@ describe('API', () => { }, me); res.should.have.status(200); res.body.should.be.a('object'); - res.body.should.have.property('poll'); + res.body.should.have.property('created_post'); + res.body.created_post.should.have.property('poll'); })); it('投票の選択肢が無くて怒られる', async(async () => { diff --git a/test/text.js b/test/text.js index 49e2f02b5..24800ac44 100644 --- a/test/text.js +++ b/test/text.js @@ -8,7 +8,7 @@ const analyze = require('../built/api/common/text').default; const syntaxhighlighter = require('../built/api/common/text/core/syntax-highlighter').default; describe('Text', () => { - it('is correctly analyzed', () => { + it('can be analyzed', () => { const tokens = analyze('@himawari お腹ペコい :cat: #yryr'); assert.deepEqual([ { type: 'mention', content: '@himawari', username: 'himawari' }, @@ -19,7 +19,7 @@ describe('Text', () => { ], tokens); }); - it('逆関数で正しく復元できる', () => { + it('can be inverted', () => { const text = '@himawari お腹ペコい :cat: #yryr'; assert.equal(analyze(text).map(x => x.content).join(''), text); }); diff --git a/tools/letsencrypt/get-cert.sh b/tools/letsencrypt/get-cert.sh index 409f2fa5e..d44deb144 100644 --- a/tools/letsencrypt/get-cert.sh +++ b/tools/letsencrypt/get-cert.sh @@ -4,7 +4,7 @@ certbot certonly --standalone\ -d $1\ -d api.$1\ -d auth.$1\ - -d about.$1\ + -d docs.$1\ -d ch.$1\ -d stats.$1\ -d status.$1\ diff --git a/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js b/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js index 9128d852c..d7b2a6eff 100644 --- a/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js +++ b/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js @@ -34,7 +34,7 @@ async function main () { 1, async (time) => { console.log(`${time} / ${idop}`) - const doc = await db.get('drive_files').find(query, { + const doc = await DriveFile.find(query, { limit: dop, skip: time * dop }) return Promise.all(doc.map(applyNewChange)) diff --git a/tools/migration/node.1510056272.issue_882.js b/tools/migration/node.1510056272.issue_882.js index aa1141325..302ef3de6 100644 --- a/tools/migration/node.1510056272.issue_882.js +++ b/tools/migration/node.1510056272.issue_882.js @@ -31,7 +31,7 @@ async function main() { 1, async (time) => { console.log(`${time} / ${idop}`) - const doc = await db.get('drive_files').find(query, { + const doc = await DriveFile.find(query, { limit: dop, skip: time * dop }) return Promise.all(doc.map(migrate)) diff --git a/tools/migration/node.2017-12-11.js b/tools/migration/node.2017-12-11.js new file mode 100644 index 000000000..b9686b8b4 --- /dev/null +++ b/tools/migration/node.2017-12-11.js @@ -0,0 +1,71 @@ +// for Node.js interpret + +const { default: DriveFile, getGridFSBucket } = require('../../built/api/models/drive-file') +const { default: zip } = require('@prezzemolo/zip') + +const _gm = require('gm'); +const gm = _gm.subClass({ + imageMagick: true +}); + +const migrate = doc => new Promise(async (res, rej) => { + const bucket = await getGridFSBucket(); + + const readable = bucket.openDownloadStream(doc._id); + + gm(readable) + .setFormat('ppm') + .resize(1, 1) + .toBuffer(async (err, buffer) => { + if (err) { + console.error(err); + res(false); + return; + } + const r = buffer.readUInt8(buffer.length - 3); + const g = buffer.readUInt8(buffer.length - 2); + const b = buffer.readUInt8(buffer.length - 1); + + const result = await DriveFile.update(doc._id, { + $set: { + 'metadata.properties.average_color': [r, g, b] + } + }) + + res(result.ok === 1); + }); +}); + +async function main() { + const query = { + contentType: { + $in: [ + 'image/png', + 'image/jpeg' + ] + } + } + + const count = await DriveFile.count(query); + + const dop = Number.parseInt(process.argv[2]) || 5 + const idop = ((count - (count % dop)) / dop) + 1 + + return zip( + 1, + async (time) => { + console.log(`${time} / ${idop}`) + const doc = await DriveFile.find(query, { + limit: dop, skip: time * dop + }) + return Promise.all(doc.map(migrate)) + }, + idop + ).then(a => { + const rv = [] + a.forEach(e => rv.push(...e)) + return rv + }) +} + +main().then(console.dir).catch(console.error) diff --git a/tools/migration/node.2017-12-22.hiseikika.js b/tools/migration/node.2017-12-22.hiseikika.js new file mode 100644 index 000000000..ff8294c8d --- /dev/null +++ b/tools/migration/node.2017-12-22.hiseikika.js @@ -0,0 +1,67 @@ +// for Node.js interpret + +const { default: Post } = require('../../built/api/models/post') +const { default: zip } = require('@prezzemolo/zip') + +const migrate = async (post) => { + const x = {}; + if (post.reply_id != null) { + const reply = await Post.findOne({ + _id: post.reply_id + }); + x['_reply.user_id'] = reply.user_id; + } + if (post.repost_id != null) { + const repost = await Post.findOne({ + _id: post.repost_id + }); + x['_repost.user_id'] = repost.user_id; + } + if (post.reply_id != null || post.repost_id != null) { + const result = await Post.update(post._id, { + $set: x, + }); + return result.ok === 1; + } else { + return true; + } +} + +async function main() { + const query = { + $or: [{ + reply_id: { + $exists: true, + $ne: null + } + }, { + repost_id: { + $exists: true, + $ne: null + } + }] + } + + const count = await Post.count(query); + + const dop = Number.parseInt(process.argv[2]) || 5 + const idop = ((count - (count % dop)) / dop) + 1 + + return zip( + 1, + async (time) => { + console.log(`${time} / ${idop}`) + const doc = await Post.find(query, { + limit: dop, skip: time * dop + }) + return Promise.all(doc.map(migrate)) + }, + idop + ).then(a => { + const rv = [] + a.forEach(e => rv.push(...e)) + return rv + }) +} + +main().then(console.dir).catch(console.error) diff --git a/webpack/module/index.ts b/webpack/module/index.ts index 15f36557c..088aca723 100644 --- a/webpack/module/index.ts +++ b/webpack/module/index.ts @@ -1,5 +1,5 @@ import rules from './rules'; -export default (lang, locale) => ({ - rules: rules(lang, locale) +export default lang => ({ + rules: rules(lang) }); diff --git a/webpack/module/rules/fa.ts b/webpack/module/rules/fa.ts index 47c72a28a..891b78ece 100644 --- a/webpack/module/rules/fa.ts +++ b/webpack/module/rules/fa.ts @@ -3,16 +3,7 @@ */ const StringReplacePlugin = require('string-replace-webpack-plugin'); - -import * as fontawesome from '@fortawesome/fontawesome'; -import * as regular from '@fortawesome/fontawesome-free-regular'; -import * as solid from '@fortawesome/fontawesome-free-solid'; -import * as brands from '@fortawesome/fontawesome-free-brands'; - -// Add icons -fontawesome.library.add(regular); -fontawesome.library.add(solid); -fontawesome.library.add(brands); +import { pattern, replacement } from '../../../src/common/build/fa'; export default () => ({ enforce: 'pre', @@ -20,41 +11,7 @@ export default () => ({ exclude: /node_modules/, loader: StringReplacePlugin.replace({ replacements: [{ - pattern: /%fa:(.+?)%/g, replacement: (_, key) => { - const args = key.split(' '); - let prefix = 'fas'; - const classes = []; - let transform = ''; - let name; - - args.forEach(arg => { - if (arg == 'R' || arg == 'S' || arg == 'B') { - prefix = - arg == 'R' ? 'far' : - arg == 'S' ? 'fas' : - arg == 'B' ? 'fab' : - ''; - } else if (arg[0] == '.') { - classes.push('fa-' + arg.substr(1)); - } else if (arg[0] == '-') { - transform = arg.substr(1).split('|').join(' '); - } else { - name = arg; - } - }); - - const icon = fontawesome.icon({ prefix, iconName: name }, { - classes: classes - }); - - if (icon) { - icon.transform = fontawesome.parse.transform(transform); - return `<i data-fa class="${name}">${icon.html[0]}</i>`; - } else { - console.warn(`'${name}' not found in fa`); - return ''; - } - } + pattern, replacement }] }) }); diff --git a/webpack/module/rules/i18n.ts b/webpack/module/rules/i18n.ts index aa4e58448..7261548be 100644 --- a/webpack/module/rules/i18n.ts +++ b/webpack/module/rules/i18n.ts @@ -3,28 +3,10 @@ */ const StringReplacePlugin = require('string-replace-webpack-plugin'); +import Replacer from '../../../src/common/build/i18n'; -export default (lang, locale) => { - function get(key: string) { - let text = locale; - - // Check the key existance - const error = key.split('.').some(k => { - if (text.hasOwnProperty(k)) { - text = text[k]; - return false; - } else { - return true; - } - }); - - if (error) { - console.warn(`key '${key}' not found in '${lang}'`); - return key; // Fallback - } else { - return text; - } - } +export default lang => { + const replacer = new Replacer(lang); return { enforce: 'pre', @@ -32,14 +14,7 @@ export default (lang, locale) => { exclude: /node_modules/, loader: StringReplacePlugin.replace({ replacements: [{ - pattern: /"%i18n:(.+?)%"/g, replacement: (_, key) => - '"' + get(key).replace(/"/g, '\\"') + '"' - }, { - pattern: /'%i18n:(.+?)%'/g, replacement: (_, key) => - '\'' + get(key).replace(/'/g, '\\\'') + '\'' - }, { - pattern: /%i18n:(.+?)%/g, replacement: (_, key) => - get(key) + pattern: replacer.pattern, replacement: replacer.replacement }] }) }; diff --git a/webpack/module/rules/index.ts b/webpack/module/rules/index.ts index 79740ce48..b02bdef72 100644 --- a/webpack/module/rules/index.ts +++ b/webpack/module/rules/index.ts @@ -1,4 +1,5 @@ import i18n from './i18n'; +import license from './license'; import fa from './fa'; import base64 from './base64'; import themeColor from './theme-color'; @@ -6,8 +7,9 @@ import tag from './tag'; import stylus from './stylus'; import typescript from './typescript'; -export default (lang, locale) => [ - i18n(lang, locale), +export default lang => [ + i18n(lang), + license(), fa(), base64(), themeColor(), diff --git a/webpack/module/rules/license.ts b/webpack/module/rules/license.ts new file mode 100644 index 000000000..de8b7d79f --- /dev/null +++ b/webpack/module/rules/license.ts @@ -0,0 +1,17 @@ +/** + * Inject license + */ + +const StringReplacePlugin = require('string-replace-webpack-plugin'); +import { licenseHtml } from '../../../src/common/build/license'; + +export default () => ({ + enforce: 'pre', + test: /\.(tag|js)$/, + exclude: /node_modules/, + loader: StringReplacePlugin.replace({ + replacements: [{ + pattern: '%license%', replacement: () => licenseHtml + }] + }) +}); diff --git a/webpack/plugins/banner.ts b/webpack/plugins/banner.ts index 47b8cd355..a8774e0a3 100644 --- a/webpack/plugins/banner.ts +++ b/webpack/plugins/banner.ts @@ -3,7 +3,7 @@ import * as webpack from 'webpack'; export default version => new webpack.BannerPlugin({ banner: - `Misskey v${version} | MIT Licensed, (c) syuilo 2014-2017\n` + + `Misskey v${version} | MIT Licensed, (c) syuilo 2014-2018\n` + 'https://github.com/syuilo/misskey\n' + `built by ${os.hostname()} at ${new Date()}\n` + 'hash:[hash], chunkhash:[chunkhash]' diff --git a/webpack/plugins/consts.ts b/webpack/plugins/consts.ts index 7d1ff7c8d..16a569162 100644 --- a/webpack/plugins/consts.ts +++ b/webpack/plugins/consts.ts @@ -13,10 +13,11 @@ export default lang => { _RECAPTCHA_SITEKEY_: config.recaptcha.site_key, _SW_PUBLICKEY_: config.sw ? config.sw.public_key : null, _THEME_COLOR_: constants.themeColor, + _COPYRIGHT_: constants.copyright, _VERSION_: version, _STATUS_URL_: config.status_url, _STATS_URL_: config.stats_url, - _ABOUT_URL_: config.about_url, + _DOCS_URL_: config.docs_url, _API_URL_: config.api_url, _DEV_URL_: config.dev_url, _CH_URL_: config.ch_url, diff --git a/webpack/webpack.config.ts b/webpack/webpack.config.ts index 753d89fed..d67b8ef77 100644 --- a/webpack/webpack.config.ts +++ b/webpack/webpack.config.ts @@ -5,10 +5,10 @@ import module_ from './module'; import plugins from './plugins'; -import langs from './langs'; +import langs from '../locales'; import version from '../src/version'; -module.exports = langs.map(([lang, locale]) => { +module.exports = Object.keys(langs).map(lang => { // Chunk name const name = lang; @@ -32,7 +32,7 @@ module.exports = langs.map(([lang, locale]) => { return { name, entry, - module: module_(lang, locale), + module: module_(lang), plugins: plugins(version, lang), output, resolve: {