diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd17cd9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.{ts,tsx,js,json,pug}] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_size = 2 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 68af308..4847d34 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,11 +18,10 @@ module.exports = { 'rules': { 'indent': [ 'error', - 'tab' - ], - 'linebreak-style': [ - 'error', - 'unix' + 'tab', + { + 'SwitchCase': 1, + } ], 'quotes': [ 'error', diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..910a2a5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..98eeddd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +## 2.0 + +* デザイン面・機能面での大幅な作り直し + +## 1.5.1 + +* インスタンスの接続エラーにより後続処理が行えなくなる重大な不具合を修正 +* 全員分の算出が終わるまで、ランキングを非表示に \ No newline at end of file diff --git a/LICENSE b/LICENSE index fcb2806..53e9e2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ -Misshaialert -Copyright (C) 2020 Xeltica +Misskey Tools +Copyright (C) 2020-2021 Xeltica This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as diff --git a/README.md b/README.md index 44a1063..a76be60 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# みす廃あらーと +# Misskey Tools (aka みす廃あらーと) -みす廃あらーとは、Misskeyでのノート、フォロー、フォロワーの数および前日比を毎日0時にノートするサービスです。 +Misskey Toolsは、Misskeyのために設計された、様々な機能を取り揃えたアカウント管理ツールです。 + +以前は「みす廃あらーと」という、Misskeyでのノート、フォロー、フォロワーの数および前日比を毎日0時にノートするサービスとして開発されていましたが、現在様々な機能に対応したオールインワンツールとして開発中です。 ## 対応 -Misskey v10 以降および互換性のあるサーバー +Misskey v10 以降およびGroundpolis, MeisskeyなどのMisskeyと互換性のあるサーバー ## ビルド @@ -19,9 +21,14 @@ yarn build yarn start # デバッグ用に起動 -yarn watch +yarn dev ``` ## LICENSE -[AGPL 3.0](LICENSE) \ No newline at end of file +[AGPL 3.0](LICENSE) + +## Conrtibutors + +- @Xeltica (Main Developer) +- @4ioskd (English initial Translator) diff --git a/config.example.json b/config.example.json index ab9126c..6647865 100644 --- a/config.example.json +++ b/config.example.json @@ -1,6 +1,6 @@ { "port": 4000, - "url": "https://misshaialert.com", + "url": "https://misskey.tools", "db": { "host": "localhost", "port": 5432, @@ -8,5 +8,9 @@ "pass": "pass", "db": "misshaialert", "extra": {} + }, + "admin": { + "username": "your_user_name", + "host": "your-instance-host" } -} \ No newline at end of file +} diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..8017c41 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,4 @@ +files: + - source: /src/frontend/langs/ja-JP.json + translation: /src/frontend/langs/%locale%.json + update_option: update_as_unapproved \ No newline at end of file diff --git a/migration/1633841235323-announcement.ts b/migration/1633841235323-announcement.ts new file mode 100644 index 0000000..6d2c46a --- /dev/null +++ b/migration/1633841235323-announcement.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from 'typeorm'; + +export class Announcement1633841235323 implements MigrationInterface { + name = 'Announcement1633841235323' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE TABLE "announcement" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL, "title" character varying(128) NOT NULL, "body" character varying(8192) NOT NULL, "like" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_e0ef0550174fd1099a308fd18a0" PRIMARY KEY ("id"))'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE "announcement"'); + } + +} diff --git a/nodemon.json b/nodemon.json index 5a4b862..fc92d36 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,5 +1,10 @@ { - "watch": ["src", ["styles"]], - "ext": "ts,pug,scss", - "exec": "run-s build start" -} \ No newline at end of file + "watch": [ + "src" + ], + "ignore": [ + "src/frontend/*" + ], + "ext": "ts,tsx,pug,scss", + "exec": "run-s build:backend start" +} diff --git a/ormconfig.js b/ormconfig.js index caa751d..4555057 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -1,6 +1,6 @@ const fs = require('fs'); -const entities = require('./built/services/db').entities; +const entities = require('./built/backend/services/db').entities; const config = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/config.json', 'utf-8'))); @@ -17,4 +17,4 @@ module.exports = { cli: { migrationsDir: 'migration' } -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index e0ce783..d9a3723 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,107 @@ { - "name": "misshaialert", - "version": "1.5.1", - "description": "", - "main": "built/app.js", - "author": "Xeltica", - "private": true, - "scripts": { - "tsc": "tsc", - "start": "node built/app.js", - "lint": "eslint src/index.ts", - "lint:fix": "eslint --fix src/index.ts", - "build:views": "copyfiles -u 1 src/views/*.pug ./built/", - "clean": "rimraf built", - "build:scripts": "tsc", - "build:styles": "sass styles/:built/assets", - "build": "run-p build:*", - "migrate": "ts-node --project ./tsconfig.migration.json ./node_modules/typeorm/cli.js migration:run", - "migrate:revert": "ts-node --project ./tsconfig.migration.json ./node_modules/typeorm/cli.js migration:revert", - "dev": "nodemon" - }, - "dependencies": { - "@types/koa-bodyparser": "^4.3.0", - "@types/koa-mount": "^4.0.0", - "@types/koa-multer": "^1.0.0", - "@types/koa-static": "^4.0.1", - "@types/node-cron": "^2.0.3", - "@types/uuid": "^8.0.0", - "axios": "^0.21.2", - "dayjs": "^1.10.2", - "delay": "^4.4.0", - "koa": "^2.13.0", - "koa-bodyparser": "^4.3.0", - "koa-mount": "^4.0.0", - "koa-multer": "^1.0.2", - "koa-router": "^9.1.0", - "koa-session": "^6.0.0", - "koa-static": "^5.0.0", - "koa-views": "^6.3.0", - "node-cron": "^2.0.3", - "pg": "^8.3.0", - "pug": "^3.0.1", - "reflect-metadata": "^0.1.10", - "rndstr": "^1.0.0", - "sass": "^1.26.10", - "typeorm": "0.2.25", - "typescript": "^3.9.7", - "uuid": "^8.3.0" - }, - "devDependencies": { - "@types/axios": "^0.14.0", - "@types/koa": "^2.11.3", - "@types/koa-router": "^7.4.1", - "@types/koa-session": "^5.10.2", - "@types/koa-views": "^2.0.4", - "@types/node": "^8.0.29", - "@typescript-eslint/eslint-plugin": "^3.7.0", - "@typescript-eslint/parser": "^3.7.0", - "copyfiles": "^2.3.0", - "eslint": "^7.5.0", - "eslint-config-prettier": "^6.11.0", - "eslint-plugin-prettier": "^3.1.4", - "nodemon": "^2.0.4", - "npm-run-all": "^4.1.5", - "prettier": "^2.0.5", - "rimraf": "^3.0.2", - "ts-node": "3.3.0" - } + "name": "misskey-tools", + "version": "2.0.0", + "description": "", + "main": "built/app.js", + "author": "Xeltica", + "private": true, + "scripts": { + "build": "run-p build:*", + "build:frontend": "webpack", + "build:backend": "run-p build:backend-source build:views build:styles", + "build:backend-source": "tsc", + "build:views": "copyfiles -u 1 src/backend/views/*.pug ./built/", + "build:styles": "sass styles/:built/assets", + "start": "node built/app.js", + "dev": "run-p dev:*", + "dev:backend": "nodemon", + "dev:frontend": "webpack --watch", + "clean": "rimraf built", + "tsc": "tsc", + "lint": "eslint --ext .ts,.tsx src", + "lint:fix": "eslint --fix --ext .ts,.tsx src", + "migrate": "ts-node --project ./tsconfig.migration.json ./node_modules/typeorm/cli.js migration:run", + "migrate:revert": "ts-node --project ./tsconfig.migration.json ./node_modules/typeorm/cli.js migration:revert" + }, + "dependencies": { + "@babel/preset-react": "^7.14.5", + "@reduxjs/toolkit": "^1.6.1", + "@types/insert-text-at-cursor": "^0.3.0", + "@types/koa-bodyparser": "^4.3.0", + "@types/koa-multer": "^1.0.0", + "@types/koa-send": "^4.1.3", + "@types/ms": "^0.7.31", + "@types/node-cron": "^2.0.3", + "@types/object.pick": "^1.3.1", + "@types/react": "^17.0.19", + "@types/react-dom": "^17.0.9", + "@types/react-router-dom": "^5.1.8", + "@types/styled-components": "^5.1.13", + "@types/uuid": "^8.0.0", + "axios": "^0.19.2", + "class-transformer": "^0.4.0", + "class-validator": "^0.13.1", + "css-loader": "^6.2.0", + "dayjs": "^1.10.7", + "delay": "^4.4.0", + "fibers": "^5.0.0", + "i18next": "^20.6.1", + "i18next-browser-languagedetector": "^6.1.2", + "insert-text-at-cursor": "^0.3.0", + "json5-loader": "^4.0.1", + "koa": "^2.13.0", + "koa-bodyparser": "^4.3.0", + "koa-multer": "^1.0.2", + "koa-router": "^9.1.0", + "koa-send": "^5.0.1", + "koa-session": "^6.0.0", + "koa-views": "^6.3.0", + "misskey-js": "^0.0.6", + "ms": "^2.1.3", + "node-cron": "^2.0.3", + "object.pick": "^1.3.0", + "pg": "^8.3.0", + "pug": "^3.0.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-i18next": "^11.12.0", + "react-modal-hook": "^3.0.0", + "react-redux": "^7.2.4", + "react-router-dom": "^5.2.1", + "reflect-metadata": "^0.1.13", + "rndstr": "^1.0.0", + "routing-controllers": "^0.9.0", + "sass": "^1.38.2", + "sass-loader": "^12.1.0", + "style-loader": "^3.2.1", + "styled-components": "^5.3.1", + "ts-loader": "^9.2.5", + "tsc-alias": "^1.3.9", + "tsconfig-paths-webpack-plugin": "^3.5.1", + "typeorm": "0.2.25", + "typescript": "^4.4.2", + "uuid": "^8.3.0", + "webpack": "^5.51.1", + "webpack-cli": "^4.8.0", + "xeltica-ui": "xeltica/ui" + }, + "devDependencies": { + "@types/axios": "^0.14.0", + "@types/koa": "^2.11.3", + "@types/koa-router": "^7.4.1", + "@types/koa-session": "^5.10.2", + "@types/koa-views": "^2.0.4", + "@types/node": "^8.0.29", + "@typescript-eslint/eslint-plugin": "^4.30.0", + "@typescript-eslint/parser": "^4.30.0", + "copyfiles": "^2.3.0", + "eslint": "^7.32.0", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-prettier": "^3.1.4", + "nodemon": "^2.0.4", + "npm-run-all": "^4.1.5", + "prettier": "^2.0.5", + "rimraf": "^3.0.2", + "ts-node": "3.3.0" + } } diff --git a/src/app.ts b/src/app.ts index f7f3319..baa9456 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,8 @@ -import { initDb } from './services/db'; +import { initDb } from './backend/services/db'; import 'reflect-metadata'; (async () => { await initDb(); - (await import('./services/worker')).default(); - (await import('./server/server')).default(); -})(); \ No newline at end of file + (await import('./backend/services/worker')).default(); + (await import('./backend/server')).default(); +})(); diff --git a/src/backend/const.ts b/src/backend/const.ts new file mode 100644 index 0000000..3674531 --- /dev/null +++ b/src/backend/const.ts @@ -0,0 +1,9 @@ +export default { + version: '2.0.0', + changelog: [ + 'インスタンスの接続エラーにより後続処理が行えなくなる重大な不具合を修正', + '全員分の算出が終わるまで、ランキングを非表示に', + ], +}; + +export const defaultTemplate = '昨日のMisskeyの活動は\n\nノート: {notesCount}({notesDelta})\nフォロー : {followingCount}({followingDelta})\nフォロワー :{followersCount}({followersDelta})\n\nでした。\n{url}'; diff --git a/src/backend/controllers/admin.ts b/src/backend/controllers/admin.ts new file mode 100644 index 0000000..cf03482 --- /dev/null +++ b/src/backend/controllers/admin.ts @@ -0,0 +1,18 @@ +/** + * バージョン情報など、サーバーのメタデータを返すAPI + * @author Xeltica + */ + +import { Get, JsonController } from 'routing-controllers'; +import { config } from '../../config'; + + @JsonController('/admin') +export class AdminController { + @Get() getAdmin() { + const { username, host } = config.admin; + return { + username, host, + acct: `@${username}@${host}`, + }; + } +} diff --git a/src/backend/controllers/announcement.ts b/src/backend/controllers/announcement.ts new file mode 100644 index 0000000..604830c --- /dev/null +++ b/src/backend/controllers/announcement.ts @@ -0,0 +1,104 @@ +/** + * バージョン情報など、サーバーのメタデータを返すAPI + * @author Xeltica + */ + +import { BadRequestError, Body, CurrentUser, Delete, Get, JsonController, NotFoundError, OnUndefined, Param, Post, Put } from 'routing-controllers'; +import { IUser } from '../../common/types/user'; +import { Announcements } from '../models'; +import { AnnounceCreate } from './body/announce-create'; +import { AnnounceUpdate } from './body/announce-update'; +import { IdProp } from './body/id-prop'; + + @JsonController('/announcements') +export class AdminController { + @Get() get() { + const query = Announcements.createQueryBuilder('announcement') + .orderBy('"announcement"."createdAt"', 'DESC'); + + return query.getMany(); + } + + @OnUndefined(204) + @Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) { + if (!user.isAdmin) { + throw new BadRequestError('Not an Admin'); + } + if (!title || !body) { + throw new BadRequestError(); + } + await Announcements.insert({ + createdAt: new Date(), + title, + body, + }); + } + + @OnUndefined(204) + @Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) { + if (!user.isAdmin) { + throw new BadRequestError('Not an Admin'); + } + if (!id || !title || !body) { + throw new BadRequestError(); + } + if (!(await Announcements.findOne(id))) { + throw new NotFoundError(); + } + + await Announcements.update(id, { + title, + body, + }); + } + + @OnUndefined(204) + @Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) { + if (!user.isAdmin) { + throw new BadRequestError('Not an Admin'); + } + const idNumber = Number(id); + if (isNaN(idNumber)) { + throw new NotFoundError(); + } + if (!id) { + throw new BadRequestError(); + } + + const announcement = await Announcements.findOne(Number(idNumber)); + + if (!announcement) { + throw new NotFoundError(); + } + + await Announcements.update(id, { + like: announcement.like + 1, + }); + + return announcement.like + 1; + } + + @Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) { + if (!user.isAdmin) { + throw new BadRequestError('Not an Admin'); + } + + if (!id) { + throw new BadRequestError(); + } + + await Announcements.delete(id); + } + + @Get('/:id') async getDetail(@Param('id') id: string) { + const idNumber = Number(id); + if (isNaN(idNumber)) { + throw new NotFoundError(); + } + const announcement = await Announcements.findOne(idNumber); + if (!announcement) { + throw new NotFoundError(); + } + return announcement; + } +} diff --git a/src/backend/controllers/body/announce-create.ts b/src/backend/controllers/body/announce-create.ts new file mode 100644 index 0000000..16c4bfb --- /dev/null +++ b/src/backend/controllers/body/announce-create.ts @@ -0,0 +1,4 @@ +export class AnnounceCreate { + title: string; + body: string; +} diff --git a/src/backend/controllers/body/announce-update.ts b/src/backend/controllers/body/announce-update.ts new file mode 100644 index 0000000..7b46f20 --- /dev/null +++ b/src/backend/controllers/body/announce-update.ts @@ -0,0 +1,5 @@ +export class AnnounceUpdate { + id: number; + title: string; + body: string; +} diff --git a/src/backend/controllers/body/id-prop.ts b/src/backend/controllers/body/id-prop.ts new file mode 100644 index 0000000..ed090c7 --- /dev/null +++ b/src/backend/controllers/body/id-prop.ts @@ -0,0 +1,3 @@ +export class IdProp { + id: number; +} diff --git a/src/backend/controllers/body/user-setting.ts b/src/backend/controllers/body/user-setting.ts new file mode 100644 index 0000000..7383e7d --- /dev/null +++ b/src/backend/controllers/body/user-setting.ts @@ -0,0 +1,22 @@ +import { IsIn, IsOptional } from 'class-validator'; +import { AlertMode, alertModes } from '../../../common/types/alert-mode'; +import { visibilities, Visibility } from '../../../common/types/visibility'; + +export class UserSetting { + @IsIn(alertModes) + @IsOptional() + alertMode?: AlertMode; + + @IsIn(visibilities) + @IsOptional() + visibility?: Visibility; + + @IsOptional() + localOnly?: boolean; + + @IsOptional() + remoteFollowersOnly?: boolean; + + @IsOptional() + template?: string; +} diff --git a/src/backend/controllers/meta.ts b/src/backend/controllers/meta.ts new file mode 100644 index 0000000..871001c --- /dev/null +++ b/src/backend/controllers/meta.ts @@ -0,0 +1,15 @@ +/** + * バージョン情報など、サーバーのメタデータを返すAPI + * @author Xeltica + */ + +import { Get, JsonController } from 'routing-controllers'; + +@JsonController('/meta') +export class MetaController { + @Get() get() { + return { + honi: 'ほに', + }; + } +} diff --git a/src/backend/controllers/ranking.ts b/src/backend/controllers/ranking.ts new file mode 100644 index 0000000..0fa6ed8 --- /dev/null +++ b/src/backend/controllers/ranking.ts @@ -0,0 +1,37 @@ +/** + * ランキング一覧取得API + * @author Xeltica + */ + +import { Get, JsonController, QueryParam } from 'routing-controllers'; +import { getRanking } from '../functions/ranking'; +import { getUserCount } from '../functions/users'; +import { getState } from '../store'; + +@JsonController('/ranking') +export class RankingController { + @Get() + async get(@QueryParam('limit', { required: false }) limit?: string) { + return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined); + } + + /** + * DBに問い合わせてランキングを取得する + * @param isCalculating 現在算出中かどうか + * @param limit 何件取得するか + * @returns ランキング + */ + private async getResponse(isCalculating: boolean, limit?: number) { + const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({ + id: u.id, + username: u.username, + host: u.host, + rating: u.rating, + })); + return { + isCalculating, + userCount: await getUserCount(), + ranking, + }; + } +} diff --git a/src/backend/controllers/session.ts b/src/backend/controllers/session.ts new file mode 100644 index 0000000..eedb57b --- /dev/null +++ b/src/backend/controllers/session.ts @@ -0,0 +1,48 @@ +/** + * トークンを必要とするセッションAPI + * @author Xeltica + */ + +import { Body, CurrentUser, Delete, Get, JsonController, OnUndefined, Post, Put } from 'routing-controllers'; +import { DeepPartial } from 'typeorm'; +import { getScores } from '../functions/get-scores'; +import { deleteUser, updateUser } from '../functions/users'; +import { User } from '../models/entities/user'; +import { sendAlert } from '../services/send-alert'; +import { UserSetting } from './body/user-setting'; + +@JsonController('/session') +export class SessionController { + @Get() get(@CurrentUser({ required: true }) user: User) { + return user; + } + + @Get('/score') + async getScore(@CurrentUser({ required: true }) user: User) { + return getScores(user); + } + + @OnUndefined(204) + @Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) { + const s: DeepPartial = {}; + if (setting.alertMode != null) s.alertMode = setting.alertMode; + if (setting.visibility != null) s.visibility = setting.visibility; + if (setting.localOnly != null) s.localOnly = setting.localOnly; + if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly; + if (setting.template !== undefined) s.template = setting.template; + if (Object.keys(s).length === 0) return; + await updateUser(user.username, user.host, s); + } + + @OnUndefined(204) + @Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) { + await sendAlert(user); + } + + @OnUndefined(204) + @Delete() async delete(@CurrentUser({ required: true }) user: User) { + await deleteUser(user.username, user.host); + } +} + + diff --git a/src/backend/die.ts b/src/backend/die.ts new file mode 100644 index 0000000..b3844ca --- /dev/null +++ b/src/backend/die.ts @@ -0,0 +1,7 @@ +import { Context } from 'koa'; +import { ErrorCode } from '../common/types/error-code'; + +export const die = (ctx: Context, error: ErrorCode = 'other', status = 400): Promise => { + ctx.status = status; + return ctx.render('frontend', { error }); +}; diff --git a/src/functions/gen-token.ts b/src/backend/functions/gen-token.ts similarity index 74% rename from src/functions/gen-token.ts rename to src/backend/functions/gen-token.ts index af520f2..25bdd68 100644 --- a/src/functions/gen-token.ts +++ b/src/backend/functions/gen-token.ts @@ -1,7 +1,10 @@ import rndstr from 'rndstr'; -import { UsedToken } from '../models/entities/usedToken'; +import { UsedToken } from '../models/entities/used-token'; import { UsedTokens } from '../models'; +/** + * トークンを生成します + */ export const genToken = async (): Promise => { let used: UsedToken | undefined = undefined; let token: string; @@ -10,4 +13,4 @@ export const genToken = async (): Promise => { used = await UsedTokens.findOne({ token }); } while (used !== undefined); return token; -}; \ No newline at end of file +}; diff --git a/src/functions/get-scores.ts b/src/backend/functions/get-scores.ts similarity index 66% rename from src/functions/get-scores.ts rename to src/backend/functions/get-scores.ts index 0ddbb7b..88b66cd 100644 --- a/src/functions/get-scores.ts +++ b/src/backend/functions/get-scores.ts @@ -1,9 +1,15 @@ import { User } from '../models/entities/user'; -import { Score } from '../types/Score'; +import { Score } from '../../common/types/score'; import { api } from '../services/misskey'; -import { toSignedString } from './to-signed-string'; +import { toSignedString } from '../../common/functions/to-signed-string'; +/** + * ユーザーのスコアを取得します。 + * @param user ユーザー + * @returns ユーザーのスコア + */ export const getScores = async (user: User): Promise => { + // TODO 毎回取ってくるのも微妙なので、ある程度キャッシュしたいかも const miUser = await api>(user.host, 'users/show', { username: user.username }, user.token); if (miUser.error) { throw miUser.error; @@ -16,4 +22,4 @@ export const getScores = async (user: User): Promise => { followingDelta: toSignedString(miUser.followingCount - user.prevFollowingCount), followersDelta: toSignedString(miUser.followersCount - user.prevFollowersCount), }; -}; \ No newline at end of file +}; diff --git a/src/functions/ranking.ts b/src/backend/functions/ranking.ts similarity index 74% rename from src/functions/ranking.ts rename to src/backend/functions/ranking.ts index cc3b105..310fb11 100644 --- a/src/functions/ranking.ts +++ b/src/backend/functions/ranking.ts @@ -1,14 +1,19 @@ import { Users } from '../models'; import { User } from '../models/entities/user'; +/** + * ミス廃ランキングを取得する + * @param limit 取得する件数 + * @returns ミス廃ランキング + */ export const getRanking = async (limit: number | null = 10): Promise => { const query = Users.createQueryBuilder('user') .where('"user"."bannedFromRanking" IS NOT TRUE') .orderBy('"user".rating', 'DESC'); - + if (limit !== null) { query.limit(limit); } - + return await query.getMany(); -}; \ No newline at end of file +}; diff --git a/src/functions/update-rating.ts b/src/backend/functions/update-rating.ts similarity index 76% rename from src/functions/update-rating.ts rename to src/backend/functions/update-rating.ts index 1760dd0..bd4b17d 100644 --- a/src/functions/update-rating.ts +++ b/src/backend/functions/update-rating.ts @@ -4,6 +4,11 @@ import { User } from '../models/entities/user'; import { updateUser } from './users'; import { MiUser } from './update-score'; +/** + * ユーザーのレーティングを更新します + * @param user ユーザー + * @param miUser Misskeyのユーザー + */ export const updateRating = async (user: User, miUser: MiUser): Promise => { const elapsedDays = dayjs().diff(dayjs(miUser.createdAt), 'd') + 1; await updateUser(user.username, user.host, { diff --git a/src/functions/update-score.ts b/src/backend/functions/update-score.ts similarity index 72% rename from src/functions/update-score.ts rename to src/backend/functions/update-score.ts index fabce6e..018893e 100644 --- a/src/functions/update-score.ts +++ b/src/backend/functions/update-score.ts @@ -1,6 +1,9 @@ import { User } from '../models/entities/user'; import { updateUser } from './users'; +/** + * Misskeyのユーザーモデル + */ export type MiUser = { notesCount: number, followingCount: number, @@ -8,10 +11,15 @@ export type MiUser = { createdAt: string, }; -export const updateScore = async (user: User, miUser: MiUser): Promise => { +/** + * スコアを更新します + * @param user ユーザー + * @param miUser Misskeyのユーザー + */ +export const updateScore = async (user: User, miUser: MiUser): Promise => { await updateUser(user.username, user.host, { prevNotesCount: miUser.notesCount ?? 0, prevFollowingCount: miUser.followingCount ?? 0, prevFollowersCount: miUser.followersCount ?? 0, }); -}; \ No newline at end of file +}; diff --git a/src/backend/functions/users.ts b/src/backend/functions/users.ts new file mode 100644 index 0000000..4139f96 --- /dev/null +++ b/src/backend/functions/users.ts @@ -0,0 +1,95 @@ +import { User } from '../models/entities/user'; +import { Users } from '../models'; +import { DeepPartial } from 'typeorm'; +import { genToken } from './gen-token'; +import { IUser } from '../../common/types/user'; +import { config } from '../../config'; + +/** + * IUser インターフェイスに変換します。 + */ +const packUser = (user: User | undefined): IUser | undefined => { + if (!user) return undefined; + const { username: adminName, host: adminHost } = config.admin; + + return { + ...user, + isAdmin: adminName === user.username && adminHost === user.host, + }; +}; + +/** + * ユーザーを取得します + * @param username ユーザー名 + * @param host ホスト名 + * @returns ユーザー + */ +export const getUser = (username: string, host: string): Promise => { + return Users.findOne({ username, host }).then(packUser); +}; + +/** + * ユーザーのミス廃トークンを更新します。 + * @param user ユーザー + * @returns ミス廃トークン + */ +export const updateUsersToolsToken = async (user: User | User['id']): Promise => { + const u = typeof user === 'number' + ? user + : user.id; + + const misshaiToken = await genToken(); + Users.update(u, { misshaiToken }); + return misshaiToken; +}; + +/** + * ミス廃トークンからユーザーを取得します。 + * @param token ミス廃トークン + * @returns ユーザー + */ +export const getUserByToolsToken = (token: string): Promise => { + return Users.findOne({ misshaiToken: token }).then(packUser); +}; + +/** + * ユーザー情報を更新するか新規作成します。 + * @param username ユーザー名 + * @param host ホスト名 + * @param token トークン + */ +export const upsertUser = async (username: string, host: string, token: string): Promise => { + const u = await getUser(username, host); + if (u) { + await Users.update(u.id, { token }); + } else { + await Users.insert({ username, host, token }); + } +}; + +/** + * ユーザー情報を更新します。 + * @param username ユーザー名 + * @param host ホスト名 + * @param record 既存のユーザー情報 + */ +export const updateUser = async (username: string, host: string, record: DeepPartial): Promise => { + await Users.update({ username, host }, record); +}; + +/** + * 指定したユーザーを削除します。 + * @param username ユーザー名 + * @param host ホスト名 + */ +export const deleteUser = async (username: string, host: string): Promise => { + await Users.delete({ username, host }); +}; + +/** + * ユーザー数を取得します。 + * @returns ユーザー数 + */ +export const getUserCount = (): Promise => { + return Users.count(); +}; diff --git a/src/backend/models/entities/announcement.ts b/src/backend/models/entities/announcement.ts new file mode 100644 index 0000000..471f60d --- /dev/null +++ b/src/backend/models/entities/announcement.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { IAnnouncement } from '../../../common/types/announcement'; + +@Entity() +export class Announcement implements IAnnouncement { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ + type: 'timestamp without time zone', + }) + public createdAt: Date; + + @Column({ + type: 'varchar', + length: 128, + }) + public title: string; + + @Column({ + type: 'varchar', + length: 8192, + }) + public body: string; + + @Column({ + type: 'integer', + default: 0, + }) + public like: number; +} diff --git a/src/models/entities/usedToken.ts b/src/backend/models/entities/used-token.ts similarity index 100% rename from src/models/entities/usedToken.ts rename to src/backend/models/entities/used-token.ts diff --git a/src/models/entities/user.ts b/src/backend/models/entities/user.ts similarity index 82% rename from src/models/entities/user.ts rename to src/backend/models/entities/user.ts index 92094d8..3b5e5cd 100644 --- a/src/models/entities/user.ts +++ b/src/backend/models/entities/user.ts @@ -1,10 +1,11 @@ import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm'; -import { AlertMode, alertModes } from '../../types/AlertMode'; -import { visibilities, Visibility } from '../../types/Visibility'; +import { AlertMode, alertModes } from '../../../common/types/alert-mode'; +import { visibilities, Visibility } from '../../../common/types/visibility'; +import { IUser } from '../../../common/types/user'; @Entity() -@Index([ 'username', 'host' ], { unique: true }) -export class User { +@Index(['username', 'host'], { unique: true }) +export class User implements IUser { @PrimaryGeneratedColumn() public id: number; @@ -97,4 +98,4 @@ export class User { default: false, }) public bannedFromRanking: boolean; -} \ No newline at end of file +} diff --git a/src/backend/models/index.ts b/src/backend/models/index.ts new file mode 100644 index 0000000..c9bd934 --- /dev/null +++ b/src/backend/models/index.ts @@ -0,0 +1,8 @@ +import { User } from './entities/user'; +import { UsedToken } from './entities/used-token'; +import { getRepository } from 'typeorm'; +import { Announcement } from './entities/announcement'; + +export const Users = getRepository(User); +export const UsedTokens = getRepository(UsedToken); +export const Announcements = getRepository(Announcement); diff --git a/src/models/repositories/.gitkeep b/src/backend/models/repositories/.gitkeep similarity index 100% rename from src/models/repositories/.gitkeep rename to src/backend/models/repositories/.gitkeep diff --git a/src/backend/render.ts b/src/backend/render.ts new file mode 100644 index 0000000..9558a42 --- /dev/null +++ b/src/backend/render.ts @@ -0,0 +1,9 @@ +import views from 'koa-views'; + +import constant from './const'; + +export const render = views(__dirname + '/views', { + extension: 'pug', options: { + ...constant, + } +}); diff --git a/src/backend/router.ts b/src/backend/router.ts new file mode 100644 index 0000000..0e1078f --- /dev/null +++ b/src/backend/router.ts @@ -0,0 +1,164 @@ +import { Context, DefaultState } from 'koa'; +import Router from 'koa-router'; +import axios from 'axios'; +import crypto from 'crypto'; +import koaSend from 'koa-send'; +import { v4 as uuid } from 'uuid'; +import ms from 'ms'; + +import { config } from '../config'; +import { upsertUser, getUser, updateUser, updateUsersToolsToken } from './functions/users'; +import { api } from './services/misskey'; +import { die } from './die'; + +export const router = new Router(); + +const sessionHostCache: Record = {}; +const tokenSecretCache: Record = {}; + +router.get('/login', async ctx => { + let host = ctx.query.host as string | undefined; + if (!host) { + await die(ctx, 'invalidParamater'); + return; + } + + const meta = await api<{ name: string, uri: string, version: string, features: Record }>(host, 'meta', {}); + if (typeof meta !== 'object') { + await die(ctx, 'other'); + return; + } + + if (meta.version.includes('hitori')) { + await die(ctx, 'hitorisskeyIsDenied'); + return; + } + + // ホスト名の正規化 + host = meta.uri.replace(/^https?:\/\//, ''); + const name = 'みす廃あらーと'; + const description = 'ついついノートしすぎていませんか?'; + const permission = ['write:notes', 'write:notifications']; + + if (meta.features.miauth) { + // MiAuthを使用する + const callback = encodeURI(`${config.url}/miauth`); + + const session = uuid(); + const url = `https://${host}/miauth/${session}?name=${encodeURI(name)}&callback=${encodeURI(callback)}&permission=${encodeURI(permission.join(','))}`; + sessionHostCache[session] = host; + + ctx.redirect(url); + } else { + // 旧型認証を使用する + const callbackUrl = encodeURI(`${config.url}/legacy-auth`); + + const { secret } = await api<{ secret: string }>(host, 'app/create', { + name, description, permission, callbackUrl, + }); + + const { token, url } = await api<{ token: string, url: string }>(host, 'auth/session/generate', { + appSecret: secret + }); + + sessionHostCache[token] = host; + tokenSecretCache[token] = secret; + + ctx.redirect(url); + } +}); + +router.get('/teapot', async ctx => { + await die(ctx, 'teapot', 418); +}); + +router.get('/miauth', async ctx => { + const session = ctx.query.session as string | undefined; + if (!session) { + await die(ctx, 'sessionRequired'); + return; + } + const host = sessionHostCache[session]; + delete sessionHostCache[session]; + if (!host) { + await die(ctx); + return; + } + + const url = `https://${host}/api/miauth/${session}/check`; + const { token, user } = (await axios.post(url)).data; + + if (!token || !user) { + await die(ctx); + return; + } + + await login(ctx, user, host, token); + +}); + +router.get('/legacy-auth', async ctx => { + const token = ctx.query.token as string | undefined; + if (!token) { + await die(ctx, 'tokenRequired'); + return; + } + const host = sessionHostCache[token]; + delete sessionHostCache[token]; + if (!host) { + await die(ctx); + return; + } + const appSecret = tokenSecretCache[token]; + delete tokenSecretCache[token]; + if (!appSecret) { + await die(ctx); + return; + } + + const { accessToken, user } = await api<{ accessToken: string, user: Record }>(host, 'auth/session/userkey', { + appSecret, token, + }); + const i = crypto.createHash('sha256').update(accessToken + appSecret, 'utf8').digest('hex'); + + await login(ctx, user, host, i); +}); + +router.get('/assets/(.*)', async ctx => { + await koaSend(ctx as any, ctx.path.replace('/assets/', ''), { + root: `${__dirname}/../assets/`, + maxage: process.env.NODE_ENV !== 'production' ? 0 : ms('7 days'), + }); +}); + +router.get('/api(.*)', async (ctx, next) => { + next(); +}); + +router.get('(.*)', async (ctx) => { + await ctx.render('frontend'); +}); + +async function login(ctx: Context, user: Record, host: string, token: string) { + const isNewcomer = !(await getUser(user.username as string, host)); + await upsertUser(user.username as string, host, token); + + const u = await getUser(user.username as string, host); + + if (!u) { + await die(ctx); + return; + } + + if (isNewcomer) { + await updateUser(u.username, u.host, { + prevNotesCount: user.notesCount as number ?? 0, + prevFollowingCount: user.followingCount as number ?? 0, + prevFollowersCount: user.followersCount as number ?? 0, + }); + } + + const toolsToken = await updateUsersToolsToken(u); + + await ctx.render('frontend', { token: toolsToken }); +} diff --git a/src/backend/server.ts b/src/backend/server.ts new file mode 100644 index 0000000..10dd859 --- /dev/null +++ b/src/backend/server.ts @@ -0,0 +1,45 @@ +import Koa from 'koa'; +import bodyParser from 'koa-bodyparser'; +import { Action, useKoaServer } from 'routing-controllers'; + +import constant from './const'; +import { config } from '../config'; +import { render } from './render'; +import { router } from './router'; +import { getUserByToolsToken } from './functions/users'; + +import 'reflect-metadata'; + +export default (): void => { + const app = new Koa(); + + console.log('Misskey Tools v' + constant.version); + + console.log('Initializing DB connection...'); + + app.use(render); + app.use(bodyParser()); + + useKoaServer(app, { + controllers: [__dirname + '/controllers/**/*{.ts,.js}'], + routePrefix: '/api/v1', + classTransformer: true, + validation: true, + currentUserChecker: async ({ request }: Action) => { + const { authorization } = request.header; + if (!authorization || !authorization.startsWith('Bearer ')) return null; + + const token = authorization.split(' ')[1].trim(); + const user = await getUserByToolsToken(token); + return user; + }, + }); + + app.use(router.routes()); + app.use(router.allowedMethods()); + + console.log(`listening port ${config.port}...`); + console.log('App launched!'); + + app.listen(config.port || 3000); +}; diff --git a/src/services/db.ts b/src/backend/services/db.ts similarity index 52% rename from src/services/db.ts rename to src/backend/services/db.ts index ab62265..8e2f957 100644 --- a/src/services/db.ts +++ b/src/backend/services/db.ts @@ -1,14 +1,22 @@ import { getConnection, createConnection, Connection } from 'typeorm'; -import { config } from '../config'; +import { config } from '../../config'; import { User } from '../models/entities/user'; -import { UsedToken } from '../models/entities/usedToken'; +import { UsedToken } from '../models/entities/used-token'; +import { Announcement } from '../models/entities/announcement'; export const entities = [ User, UsedToken, + Announcement, ]; +/** + * データベース接続が既に存在すれば取得し、なければ新規作成する + * @param force 既存の接続があっても新規作成するかどうか + * @returns 取得または作成したDBコネクション + */ export const initDb = async (force = false): Promise => { + // forceがtrueでない限り、既に接続が存在する場合はそれを返す if (!force) { try { const conn = getConnection(); @@ -19,6 +27,7 @@ export const initDb = async (force = false): Promise => { } } + // 接続がないか、forceがtrueの場合は新規作成する return createConnection({ type: 'postgres', host: config.db.host, @@ -29,4 +38,4 @@ export const initDb = async (force = false): Promise => { extra: config.db.extra, entities, }); -}; \ No newline at end of file +}; diff --git a/src/services/misskey.ts b/src/backend/services/misskey.ts similarity index 63% rename from src/services/misskey.ts rename to src/backend/services/misskey.ts index 71afa3b..a1b476e 100644 --- a/src/services/misskey.ts +++ b/src/backend/services/misskey.ts @@ -1,12 +1,14 @@ import axios from 'axios'; import _const from '../const'; -export const ua = `Mozilla/5.0 misshaialertBot/${_const.version} +https://github.com/Xeltica/misshaialert Node/${process.version}`; +export const ua = `Mozilla/5.0 MisskeyTools/${_const.version} +https://github.com/Xeltica/MisskeyTools Node/${process.version}`; axios.defaults.headers['User-Agent'] = ua; - axios.defaults.validateStatus = (stat) => stat < 500; +/** + * Misskey APIを呼び出す + */ export const api = >(host: string, endpoint: string, arg: Record, i?: string): Promise => { const a = { ...arg }; if (i) { @@ -15,6 +17,12 @@ export const api = >(host: string, endpoint: string, return axios.post(`https://${host}/api/${endpoint}`, a).then(res => res.data); }; +/** + * トークンが有効かどうかを確認する + * @param host 対象のホスト名 + * @param i トークン + * @returns トークンが有効ならtrue、無効ならfalse + */ export const apiAvailable = async (host: string, i: string): Promise => { try { const res = await api(host, 'i', {}, i); @@ -22,4 +30,4 @@ export const apiAvailable = async (host: string, i: string): Promise => } catch { return false; } -}; \ No newline at end of file +}; diff --git a/src/backend/services/send-alert.ts b/src/backend/services/send-alert.ts new file mode 100644 index 0000000..9d02adb --- /dev/null +++ b/src/backend/services/send-alert.ts @@ -0,0 +1,55 @@ +import { User } from '../models/entities/user'; +import { format } from '../../common/functions/format'; +import { getScores } from '../functions/get-scores'; +import { api } from './misskey'; + +/** + * アラートを送信する + * @param user ユーザー + */ +export const sendAlert = async (user: User) => { + const text = format(await getScores(user), user); + switch (user.alertMode) { + case 'note': + await sendNoteAlert(text, user); + break; + case 'notification': + await sendNotificationAlert(text, user); + break; + } +}; + +/** + * ノートアラートを送信する + * @param text 通知内容 + * @param user ユーザー + */ +const sendNoteAlert = async (text: string, user: User) => { + const res = await api>(user.host, 'notes/create', { + text, + visibility: user.visibility, + localOnly: user.localOnly, + remoteFollowersOnly: user.remoteFollowersOnly, + }, user.token); + + if (res.error) { + throw res.error || res; + } +}; + +/** + * 通知アラートを送信する + * @param text 通知内容 + * @param user ユーザー + */ +const sendNotificationAlert = async (text: string, user: User) => { + const res = await api(user.host, 'notifications/create', { + header: 'みす廃あらーと', + icon: 'https://i.imgur.com/B991yTl.png', + body: text, + }, user.token); + + if (res.error) { + throw res.error || res; + } +}; diff --git a/src/backend/services/worker.ts b/src/backend/services/worker.ts new file mode 100644 index 0000000..1dd3e53 --- /dev/null +++ b/src/backend/services/worker.ts @@ -0,0 +1,64 @@ +import cron from 'node-cron'; +import delay from 'delay'; +import { Not } from 'typeorm'; + +import { deleteUser } from '../functions/users'; +import { MiUser, updateScore } from '../functions/update-score'; +import { updateRating } from '../functions/update-rating'; +import { AlertMode } from '../../common/types/alert-mode'; +import { Users } from '../models'; +import { sendAlert } from './send-alert'; +import { api } from './misskey'; +import * as Store from '../store'; +import { User } from '../models/entities/user'; + +export default (): void => { + cron.schedule('0 0 0 * * *', async () => { + Store.dispatch({ nowCalculating: true }); + + const users = await Users.find({ alertMode: Not('nothing') }); + for (const user of users) { + await update(user).catch(e => handleError(user, e)); + + if (user.alertMode === 'note') { + return delay(3000); + } + } + + Store.dispatch({ nowCalculating: false }); + }); +}; + +/** + * アラートを送信します。 + * @param user アラートの送信先ユーザー + */ +const update = async (user: User) => { + const miUser = await api(user.host, 'users/show', { username: user.username }, user.token); + if (miUser.error) throw miUser.error; + + await updateRating(user, miUser); + await sendAlert(user); + + await updateScore(user, miUser); +}; + +/** + * アラート送信失敗のエラーをハンドリングします。 + * @param user 送信に失敗したアラートの送信先ユーザー + * @param e エラー。ErrorだったりObjectだったりするのでanyだけど、いずれ型定義したい + */ +const handleError = async (user: User, e: any) => { + if (e.code) { + if (e.code === 'NO_SUCH_USER' || e.code === 'AUTHENTICATION_FAILED') { + // ユーザーが削除されている場合、レコードからも消してとりやめ + console.info(`${user.username}@${user.host} is deleted, so delete this user from the system`); + await deleteUser(user.username, user.host); + } else { + console.error(`Misskey Error: ${JSON.stringify(e)}`); + } + } else { + // おそらく通信エラー + console.error(`Unknown error: ${e.name} ${e.message}`); + } +}; diff --git a/src/store.ts b/src/backend/store.ts similarity index 55% rename from src/store.ts rename to src/backend/store.ts index 0cd2829..e24a0bc 100644 --- a/src/store.ts +++ b/src/backend/store.ts @@ -2,21 +2,35 @@ // getStateを介してステートを取得し、dispatchによって更新する // stateを直接編集できないようになっている +/** + * 初期値 + */ const defaultState: State = { nowCalculating: false, }; let _state: Readonly = defaultState; +/** + * ステートの型 + */ export type State = { nowCalculating: boolean, }; -export const getState = () => Object.freeze({..._state}); +/** + * 現在のステートを読み取り専用な形式で取得します。 + * @returns + */ +export const getState = () => Object.freeze({ ..._state }); +/** + * ステートを更新します。 + * @param mutation ステートの一部を更新するためのオブジェクト + */ export const dispatch = (mutation: Partial) => { _state = { ..._state, ...mutation, }; -}; \ No newline at end of file +}; diff --git a/src/backend/views/frontend.pug b/src/backend/views/frontend.pug new file mode 100644 index 0000000..fb78e58 --- /dev/null +++ b/src/backend/views/frontend.pug @@ -0,0 +1,48 @@ +doctype html +html + head + meta(charset="UTF-8") + meta(name="viewport", content="width=device-width, initial-scale=1.0") + block meta + - const title = 'Misskey Tools' + - const desc = '✨Misskey での1日のノート数、フォロー数、フォロワー数をカウントし、深夜0時にお知らせする便利サービスです。'; + title= title + meta(name='description' content=desc) + meta(property='og:title' content=title) + meta(property='og:description' content=desc) + meta(property='og:type' content='website') + meta(name='twitter:card' content='summary') + meta(name='twitter:site' content='@Xeltica') + meta(name='twitter:creator' content='@Xeltica') + link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css") + style. + .loading { + display: flex; + position: fixed; + inset: 0; + background: #222; + color: #fff; + font-size: 16px; + align-items: center; + justify-content: center; + } + body + #app: .loading Loading... + + if token + script. + const token = '#{token}'; + const previousToken = localStorage.getItem('token'); + const accounts = JSON.parse(localStorage.getItem('accounts') || '[]'); + if (previousToken && !accounts.includes(previousToken)) { + accounts.push(previousToken); + } + localStorage.setItem('accounts', JSON.stringify(accounts)); + localStorage.setItem('token', token); + history.replaceState(null, null, '/'); + + if error + script. + window.__misshaialert = { error: '#{error}' }; + + script(src=`/assets/fe.${version}.js` async defer) diff --git a/src/functions/format.ts b/src/common/functions/format.ts similarity index 52% rename from src/functions/format.ts rename to src/common/functions/format.ts index 5ef3336..5ff2ff9 100644 --- a/src/functions/format.ts +++ b/src/common/functions/format.ts @@ -1,67 +1,60 @@ -import { config } from '../config'; -import { User } from '../models/entities/user'; -import { Score } from '../types/Score'; - -export const defaultTemplate = `昨日のMisskeyの活動は - -ノート: {notesCount}({notesDelta}) -フォロー : {followingCount}({followingDelta}) -フォロワー :{followersCount}({followersDelta}) - -でした。 -{url}`; +import { config } from '../../config'; +import { Score } from '../types/score'; +import { defaultTemplate } from '../../backend/const'; +import { IUser } from '../types/user'; +/** + * 埋め込み変数の型 + */ export type Variable = { - description?: string; - replace?: string | ((score: Score, user: User) => string); + replace?: string | ((score: Score, user: IUser) => string); }; +/** + * 埋め込み可能な変数のリスト + */ export const variables: Record = { notesCount: { - description: 'ノート数', replace: (score) => String(score.notesCount), }, followingCount: { - description: 'フォロー数', replace: (score) => String(score.followingCount), }, followersCount: { - description: 'フォロワー数', replace: (score) => String(score.followersCount), }, notesDelta: { - description: '昨日とのノート数の差', replace: (score) => String(score.notesDelta), }, followingDelta: { - description: '昨日とのフォロー数の差', replace: (score) => String(score.followingDelta), }, followersDelta: { - description: '昨日とのフォロワー数の差', replace: (score) => String(score.followersDelta), }, url: { - description: 'みす廃アラートのURL', replace: config.url, }, username: { - description: 'ユーザー名', replace: (_, user) => String(user.username), }, host: { - description: '所属するインスタンスのホスト名', replace: (_, user) => String(user.host), }, rating: { - description: 'みす廃レート', replace: (_, user) => String(user.rating), }, }; const variableRegex = /\{([a-zA-Z0-9_]+?)\}/g; -export const format = (score: Score, user: User): string => { +/** + * スコア情報とユーザー情報からテキストを生成する + * @param score スコア情報 + * @param user ユーザー情報 + * @returns 生成したテキスト + */ +export const format = (score: Score, user: IUser): string => { const template = user.template || defaultTemplate; return template.replace(variableRegex, (m, name) => { const v = variables[name]; diff --git a/src/common/functions/to-signed-string.ts b/src/common/functions/to-signed-string.ts new file mode 100644 index 0000000..dcc29a0 --- /dev/null +++ b/src/common/functions/to-signed-string.ts @@ -0,0 +1,6 @@ +/** + * 数値を符号付き数値の文字列に変換する + * @param num 数値 + * @returns 符号付き数値の文字列 + */ +export const toSignedString = (num: number): string => num < 0 ? num.toString() : '+' + num; diff --git a/src/types/AlertMode.ts b/src/common/types/alert-mode.ts similarity index 60% rename from src/types/AlertMode.ts rename to src/common/types/alert-mode.ts index bde520a..3100831 100644 --- a/src/types/AlertMode.ts +++ b/src/common/types/alert-mode.ts @@ -4,4 +4,4 @@ export const alertModes = [ 'nothing' ] as const; -export type AlertMode = typeof alertModes[number]; \ No newline at end of file +export type AlertMode = typeof alertModes[number]; diff --git a/src/common/types/announcement.ts b/src/common/types/announcement.ts new file mode 100644 index 0000000..87e9e41 --- /dev/null +++ b/src/common/types/announcement.ts @@ -0,0 +1,7 @@ +export interface IAnnouncement { + id: number; + createdAt: Date; + title: string; + body: string; + like: number; +} diff --git a/src/common/types/error-code.ts b/src/common/types/error-code.ts new file mode 100644 index 0000000..dc0ec26 --- /dev/null +++ b/src/common/types/error-code.ts @@ -0,0 +1,11 @@ +export const errorCodes = [ + 'hitorisskeyIsDenied', + 'teapot', + 'sessionRequired', + 'tokenRequired', + 'invalidParamater', + 'notAuthorized', + 'other', +] as const; + +export type ErrorCode = typeof errorCodes[number]; diff --git a/src/types/Score.ts b/src/common/types/score.ts similarity index 84% rename from src/types/Score.ts rename to src/common/types/score.ts index 32a36a2..9d0b01a 100644 --- a/src/types/Score.ts +++ b/src/common/types/score.ts @@ -1,9 +1,9 @@ -export type Score = { +export interface Score { notesCount: number; followingCount: number; followersCount: number; notesDelta: string; followingDelta: string; followersDelta: string; -}; +} diff --git a/src/common/types/user.ts b/src/common/types/user.ts new file mode 100644 index 0000000..49f8460 --- /dev/null +++ b/src/common/types/user.ts @@ -0,0 +1,23 @@ +import { AlertMode } from './alert-mode'; +import { Visibility } from './visibility'; + +export interface IUser { + id: number; + username: string; + host: string; + token: string; + misshaiToken: string; + prevNotesCount: number; + prevFollowingCount: number; + prevFollowersCount: number; + alertMode: AlertMode; + visibility: Visibility; + localOnly: boolean; + remoteFollowersOnly: boolean; + template: string | null; + prevRating: number; + rating: number; + bannedFromRanking: boolean; + isAdmin?: boolean; +} + diff --git a/src/types/Visibility.ts b/src/common/types/visibility.ts similarity index 100% rename from src/types/Visibility.ts rename to src/common/types/visibility.ts diff --git a/src/const.ts b/src/const.ts deleted file mode 100644 index 7c84202..0000000 --- a/src/const.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default { - version: '1.5.1', - changelog: [ - 'インスタンスの接続エラーにより後続処理が行えなくなる重大な不具合を修正', - '全員分の算出が終わるまで、ランキングを非表示に', - ], -}; \ No newline at end of file diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx new file mode 100644 index 0000000..59123e1 --- /dev/null +++ b/src/frontend/App.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { BrowserRouter, Link, Route, Switch, useLocation } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { IndexPage } from './pages'; +import { RankingPage } from './pages/ranking'; +import { Header } from './components/Header'; +import { TermPage } from './pages/term'; +import { store } from './store'; +import { ModalComponent } from './Modal'; +import { useTheme } from './misc/theme'; +import { getBrowserLanguage, resources } from './langs'; +import { LOCALSTORAGE_KEY_LANG } from './const'; + +import 'xeltica-ui/dist/css/xeltica-ui.min.css'; +import './style.scss'; +import { AnnouncementPage } from './pages/announcement'; + +document.body.classList.add('dark'); + +if (!localStorage[LOCALSTORAGE_KEY_LANG]) { + localStorage[LOCALSTORAGE_KEY_LANG] = getBrowserLanguage(); +} + +i18n + .use(initReactI18next) + .init({ + resources, + lng: localStorage[LOCALSTORAGE_KEY_LANG], + interpolation: { + escapeValue: false // react already safes from xss + } + }); + +const AppInner : React.VFC = () => { + const $location = useLocation(); + + useTheme(); + + const {t} = useTranslation(); + + const error = (window as any).__misshaialert?.error; + + return error ? ( +
+
+
+

{t('error')}

+

{t('_error.sorry')}

+

+ {t('_error.additionalInfo')} + {t(`_error.${error}`)} +

+ (window as any).__misshaialert.error = null}>{t('retry')} +
+
+ ) : ( +
+ {$location.pathname !== '/' &&
} + + + + + + +
+

(C)2020-2021 Xeltica

+

+

{t('termsOfService')}

+
+ +
+ ); +}; + +export const App: React.VFC = () => ( + + + + + +); diff --git a/src/frontend/Modal.tsx b/src/frontend/Modal.tsx new file mode 100644 index 0000000..5cf3275 --- /dev/null +++ b/src/frontend/Modal.tsx @@ -0,0 +1,107 @@ +import React, { useCallback } from 'react'; +import { useSelector } from './store'; +import { + builtinDialogButtonNo, + builtinDialogButtonOk, + builtinDialogButtonYes, + DialogButton, + DialogButtonType, + DialogIcon, + ModalTypeDialog +} from './modal/dialog'; +import { Modal } from './modal/modal'; +import { useDispatch } from 'react-redux'; +import { hideModal } from './store/slices/screen'; +import { ModalTypeMenu } from './modal/menu'; + +const getButtons = (button: DialogButtonType): DialogButton[] => { + if (typeof button === 'object') return button; + switch (button) { + case 'ok': return [builtinDialogButtonOk]; + case 'yesNo': return [builtinDialogButtonYes, builtinDialogButtonNo]; + } +}; + +const dialogIconPattern: Record = { + error: 'bi bi-x-circle-fill text-danger', + info: 'bi bi-info-circle-fill text-primary', + question: 'bi bi-question-circle-fill text-primary', + warning: 'bi bi-exclamation-circle-fill text-warning', +}; + +const Dialog: React.VFC<{modal: ModalTypeDialog}> = ({modal}) => { + const buttons = getButtons(modal.buttons ?? 'ok'); + const dispatch = useDispatch(); + + const onClickButton = useCallback((i: number) => { + dispatch(hideModal()); + if (modal.onSelect) { + modal.onSelect(i); + } + }, [dispatch, modal]); + + return ( +
+
+ {modal.icon &&
} + {modal.title &&

{modal.title}

} +

{modal.message}

+
+ { + buttons.map((b, i) => ( + + )) + } +
+
+
+ ); +}; + +const Menu: React.VFC<{modal: ModalTypeMenu}> = ({modal}) => { + const dispatch = useDispatch(); + + return ( +
+ { + modal.items.map((item, i) => ( + + )) + } +
+ ); +}; + +const ModalInner = (modal: Modal) => { + switch (modal.type) { + case 'dialog': return ; + case 'menu': return ; + } +}; + +export const ModalComponent: React.VFC = () => { + const shown = useSelector(state => state.screen.modalShown); + const modal = useSelector(state => state.screen.modal); + const dispatch = useDispatch(); + if (!shown || !modal) return null; + + return ( +
dispatch(hideModal())}> +
e.stopPropagation()}> + { ModalInner(modal) } +
+
+ ); +}; diff --git a/src/frontend/components/AccountsPage.tsx b/src/frontend/components/AccountsPage.tsx new file mode 100644 index 0000000..27a1eff --- /dev/null +++ b/src/frontend/components/AccountsPage.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_TOKEN } from '../const'; +import { useGetSessionQuery } from '../services/session'; +import { useSelector } from '../store'; +import { setAccounts } from '../store/slices/screen'; +import { LoginForm } from './LoginForm'; +import { Skeleton } from './Skeleton'; + +export const AccountsPage: React.VFC = () => { + const {data} = useGetSessionQuery(undefined); + const {t} = useTranslation(); + const dispatch = useDispatch(); + + const {accounts, accountTokens} = useSelector(state => state.screen); + + const switchAccount = (token: string) => { + const newAccounts = accountTokens.filter(a => a !== token); + newAccounts.push(localStorage.getItem(LOCALSTORAGE_KEY_TOKEN) ?? ''); + localStorage.setItem(LOCALSTORAGE_KEY_ACCOUNTS, JSON.stringify(newAccounts)); + localStorage.setItem(LOCALSTORAGE_KEY_TOKEN, token); + location.reload(); + }; + + return !data ? ( +
+ + + +
+ ) : ( +
+
+
+

{t('_accounts.currentAccount')}

+

@{data.username}@{data.host}

+
+
+
+
+

{t('_accounts.switchAccount')}

+
+ { + accounts.length === accountTokens.length ? ( + accounts.map(account => ( + + + )) + ) : ( +
...
+ ) + } +
+ +
+
+
+ ); +}; diff --git a/src/frontend/components/AdminPage.tsx b/src/frontend/components/AdminPage.tsx new file mode 100644 index 0000000..b48d424 --- /dev/null +++ b/src/frontend/components/AdminPage.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useState } from 'react'; + +import { LOCALSTORAGE_KEY_TOKEN } from '../const'; +import { useGetSessionQuery } from '../services/session'; +import { Skeleton } from './Skeleton'; +import { IAnnouncement } from '../../common/types/announcement'; +import { $delete, $get, $post, $put } from '../misc/api'; +import { Card } from './Card'; + + +export const AdminPage: React.VFC = () => { + const { data, error } = useGetSessionQuery(undefined); + + const [announcements, setAnnouncements] = useState([]); + const [selectedAnnouncement, selectAnnouncement] = useState(null); + const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false); + const [isEditMode, setEditMode] = useState(false); + const [isDeleteMode, setDeleteMode] = useState(false); + const [draftTitle, setDraftTitle] = useState(''); + const [draftBody, setDraftBody] = useState(''); + + const submitAnnouncement = async () => { + if (selectedAnnouncement) { + await $put('announcements', { + id: selectedAnnouncement.id, + title: draftTitle, + body: draftBody, + }); + } else { + await $post('announcements', { + title: draftTitle, + body: draftBody, + }); + } + selectAnnouncement(null); + setDraftTitle(''); + setDraftBody(''); + setEditMode(false); + fetchAll(); + }; + + const deleteAnnouncement = ({id}: IAnnouncement) => { + $delete('announcements', {id}).then(() => { + fetchAll(); + }); + }; + + const fetchAll = () => { + setAnnouncements([]); + setAnnouncementsLoaded(false); + $get('announcements').then(announcements => { + setAnnouncements(announcements ?? []); + setAnnouncementsLoaded(true); + }); + }; + + /** + * Session APIのエラーハンドリング + * このAPIがエラーを返した = トークンが無効 なのでトークンを削除してログアウトする + */ + useEffect(() => { + if (error) { + console.error(error); + localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN); + location.reload(); + } + }, [error]); + + /** + * Edit Modeがオンのとき、Delete Modeを無効化する(誤操作防止) + */ + useEffect(() => { + if (isEditMode) { + setDeleteMode(false); + } + }, [isEditMode]); + + /** + * お知らせ取得 + */ + useEffect(() => { + fetchAll(); + }, []); + + useEffect(() => { + if (selectedAnnouncement) { + setDraftTitle(selectedAnnouncement.title); + setDraftBody(selectedAnnouncement.body); + } else { + setDraftTitle(''); + setDraftBody(''); + } + }, [selectedAnnouncement]); + + return !data || !isAnnouncementsLoaded ? ( +
+ + + + +
+ ) : ( +
+ { + !data.isAdmin ? ( +

You are not an administrator and cannot open this page.

+ ) : ( + <> +
+

Announcements

+ {!isEditMode && ( + + )} + + { !isEditMode ? ( + <> + {isDeleteMode &&
Click the item to delete.
} +
+ {announcements.map(a => ( + + ))} + {!isDeleteMode && ( + + )} +
+ + ) : ( +
+ +