mirror of
https://github.com/byulmaru/quesdon
synced 2024-11-30 15:58:01 +09:00
Merge branch 'master' into 'master'
Misskey API 지원 추가 및 디펜던시 버전업 See merge request byulmaru/quesdon!1
This commit is contained in:
commit
a71a9f0bb2
@ -1,3 +1,3 @@
|
||||
VIRTUAL_HOST=localhost:8080
|
||||
VIRTUAL_HOST=localhost:3000
|
||||
TWITTER_CONSUMER_KEY=6A0tknTUMd4ZagM88inMUiHOd
|
||||
TWITTER_CONSUMER_SECRET=atACgq90R49FbDDPTEKHVacucCNMYKoDaDbyAYxGzh6qcnAoA8
|
37
.eslintrc.json
Normal file
37
.eslintrc.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"env":
|
||||
{
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions":
|
||||
{
|
||||
"ecmaVersion": 2017,
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends":
|
||||
[
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking"
|
||||
],
|
||||
"rules":
|
||||
{
|
||||
"no-console": "warn",
|
||||
"indent": ["error", "tab"],
|
||||
"quotes": [ "warn", "single" ],
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["warn", "never"],
|
||||
"brace-style": ["error", "allman", { "allowSingleLine": true }],
|
||||
"eqeqeq": ["error", "always"],
|
||||
"require-atomic-updates": "off",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/interface-name-prefix": "off"
|
||||
}
|
||||
}
|
31
.vscode/launch.json
vendored
Normal file
31
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations":
|
||||
[
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "TSC Build and Debug",
|
||||
"program": "${workspaceFolder}\\src\\server\\index.ts",
|
||||
"env": { "MONGODB_URL": "mongodb://localhost/quesdon", "BACK_PORT": "3000" },
|
||||
"preLaunchTask": "build",
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
// "name": "Yarn start",
|
||||
// "runtimeExecutable": "yarn",
|
||||
// "runtimeArgs": [ "start" ],
|
||||
"name": "TSC Debug without Build",
|
||||
"program": "${workspaceFolder}\\src\\server\\index.ts",
|
||||
"env": { "MONGODB_URL": "mongodb://localhost/quesdon", "BACK_PORT": "3000" },
|
||||
// "stopOnEntry": false,
|
||||
// "preLaunchTask": "build",
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
|
||||
}
|
||||
]
|
||||
}
|
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "yarn build",
|
||||
"type": "shell",
|
||||
"group": "build",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
126
API.md
Normal file
126
API.md
Normal file
@ -0,0 +1,126 @@
|
||||
# API Layout
|
||||
> Note: all non-GET methods require X-CSRF-Token
|
||||
|
||||
## OAuth
|
||||
API endpoints used for OAuth-ing Mastodon and Twitter.
|
||||
|
||||
### `POST /api/web/oauth/get_url`
|
||||
- instance: string - Instance URL
|
||||
|
||||
### `POST /api/web/oauth/redirect`
|
||||
OAuth callback endpoint
|
||||
|
||||
## Accounts
|
||||
API endpoints relating to accounts
|
||||
|
||||
### `GET /api/web/accounts/verify_credentials`
|
||||
Get the object of user currently logged in with.
|
||||
Login required.
|
||||
|
||||
- returns user object
|
||||
|
||||
### `GET /api/web/accounts/followers`
|
||||
Get your followers who is registered in Quesdon.
|
||||
Login required.
|
||||
|
||||
- max_id: string - Max follower ID (twitter is not supported)
|
||||
|
||||
- returns `{ accounts: 'followersObject', max_id: 'new max id' }`
|
||||
|
||||
### `POST /api/web/accounts/update`
|
||||
Update Quesdon profile.
|
||||
Login rquired.
|
||||
|
||||
- description: string
|
||||
- questionBoxName: string
|
||||
- allAnon: boolean
|
||||
- stopNewQuestion: boolean
|
||||
|
||||
### `GET /api/web/accounts/id/:id`
|
||||
Get object of specified user `:id`.
|
||||
|
||||
- returns user object
|
||||
|
||||
### `GET /api/web/accounts/pushbullet/redirect`
|
||||
Redirects to pushbullet authorization screen.
|
||||
Login required.
|
||||
|
||||
### `GET /api/web/accounts/pushbullet/callback`
|
||||
Pushbullet callback endpoint.
|
||||
Login required.
|
||||
|
||||
### `GET /api/web/accounts/pushbullet/disconnect`
|
||||
Disconnect pushbullet and delete pushbullet token.
|
||||
Login required.
|
||||
|
||||
### `GET /api/web/accounts/:acct`
|
||||
Get object of the specified user `:acct`.
|
||||
|
||||
- returns user object
|
||||
|
||||
### `GET /api/web/accounts/:acct/question`
|
||||
Ask a question to user `:acct`
|
||||
|
||||
- question: string
|
||||
|
||||
### `GET /api/web/accounts/:acct/questions`
|
||||
Get answered questions of `:acct`
|
||||
|
||||
- returns array of questions
|
||||
|
||||
### `GET /api/web/accounts/:acct/answers`
|
||||
Same as `GET /api/web/accounts/:acct/questions`
|
||||
|
||||
## Questions
|
||||
API endpoints relating to questions
|
||||
|
||||
### `GET /api/web/questions`
|
||||
Get unanswered questions of current user.
|
||||
Login required.
|
||||
|
||||
- returns array of questions
|
||||
|
||||
### `GET /api/web/questions/count`
|
||||
Get number of remaining unanswered questions.
|
||||
Login required.
|
||||
|
||||
- returns number of remaining questions
|
||||
|
||||
### `GET /api/web/questions/latest`
|
||||
Get 20 most recent answered questions
|
||||
|
||||
- returns array of questions
|
||||
|
||||
### `POST /api/web/questions/:id/answer`
|
||||
Answer question `:id`
|
||||
Login required.
|
||||
|
||||
- answer: string - answer to the question
|
||||
- isNSFW: boolean - true if NSFW
|
||||
- visibility: string - visibility of the answer (public, unlisted, or private)
|
||||
|
||||
### `POST /api/web/questions/:id/delete`
|
||||
Delete question `:id`
|
||||
Login required.
|
||||
|
||||
### `POST /api/web/questions/:id/like`
|
||||
Like question `:id`
|
||||
Login required.
|
||||
|
||||
### `POST /api/web/questions/:id/unlike`
|
||||
Unlike question `:id`
|
||||
Login required.
|
||||
|
||||
### `GET /api/web/questions/:id`
|
||||
Get question `:id`
|
||||
|
||||
- returns question object
|
||||
|
||||
### `POST /api/web/questions/all_delete`
|
||||
Delete all questions.
|
||||
Login required.
|
||||
|
||||
## Logout
|
||||
|
||||
### `GET /api/web/logout`
|
||||
Delete current session.
|
26
README.md
26
README.md
@ -1,6 +1,6 @@
|
||||
# quesdon
|
||||
|
||||
ザ・インタビューズとかaskfmとかそういうののMastodon版
|
||||
Mastodon/Misskey를 위한 ask.fm 같은거
|
||||
|
||||
LICENSE: [AGPL 3.0](LICENSE)
|
||||
|
||||
@ -14,20 +14,20 @@ yarn build
|
||||
MONGODB_URL=mongodb://localhost/quesdon BACK_PORT=3000 yarn start
|
||||
```
|
||||
|
||||
## 開発のしかた
|
||||
## 개발
|
||||
|
||||
### 開発環境を立てる
|
||||
### 개발 환경 구축
|
||||
|
||||
`cp .env.development .env`したあと`yarn dev`とするといろいろwatchしながら動くやつが立ち上がるのであとは <http://localhost:8080> を開くだけ
|
||||
`cp .env.development .env`한 뒤 `yarn dev`로 빌드 후 <http://localhost:8080> 로 접속
|
||||
|
||||
### ファイル構造
|
||||
### 디렉터리 구조
|
||||
|
||||
言わなくても見ればわかると思いますが念のため
|
||||
굳이 안 적어놔도 보면 알겠지만 혹시 모르니까
|
||||
|
||||
- `src/`: ソース一式
|
||||
- `server/`: サーバーサイドのソース
|
||||
- `api/`: APIまわりが入ってるやつ
|
||||
- `db/`: データベースのModel
|
||||
- `utils/`: あちこちで使うやつ
|
||||
- `client/`: クライアントのソース
|
||||
- `views/`: サーバーサイドが見るテンプレート(pugで書かれている)
|
||||
- `src/`: 소스
|
||||
- `server/`: 서버 사이드 소스
|
||||
- `api/`: API 엔드포인트
|
||||
- `db/`: 데이터베이스 모델
|
||||
- `utils/`: 잡다한 것들
|
||||
- `client/`: 클라이언트 소스
|
||||
- `views/`: 서버 사이드 템플릿 (pug 템플릿 엔진 사용)
|
@ -1,24 +1,25 @@
|
||||
// インスタンス別ユーザー数ランキング
|
||||
// RoboMongoで実行
|
||||
// 인스턴스별 사용자 수 랭킹
|
||||
// RoboMongo 사용
|
||||
|
||||
(function(){
|
||||
(function()
|
||||
{
|
||||
let hostNames = db.getCollection('users').find({}, {acctLower: 1}).map(user => user.acctLower.split("@").pop()).filter(hostName => hostName)
|
||||
let count = {}
|
||||
|
||||
var hostNames = db.getCollection('users').find({}, {acctLower: 1}).map(user => user.acctLower.split("@").pop()).filter(hostName => hostName)
|
||||
var count = {}
|
||||
hostNames.forEach(hostName =>
|
||||
{
|
||||
if (count[hostName] == null) count[hostName] = 0
|
||||
count[hostName]++
|
||||
})
|
||||
|
||||
hostNames.forEach(hostName => {
|
||||
if (count[hostName] == null) count[hostName] = 0
|
||||
count[hostName]++
|
||||
})
|
||||
let sortedCount = Object.keys(count).map(hostName => [hostName, count[hostName]]).sort((a, b) => b[1] - a[1])
|
||||
|
||||
var sortedCount = Object.keys(count).map(hostName => [hostName, count[hostName]]).sort((a, b) => b[1] - a[1])
|
||||
count = {}
|
||||
|
||||
count = {}
|
||||
sortedCount.forEach(a =>
|
||||
{
|
||||
count[a[0]] = a[1]
|
||||
})
|
||||
|
||||
sortedCount.forEach(a => {
|
||||
count[a[0]] = a[1]
|
||||
})
|
||||
|
||||
return count
|
||||
|
||||
return count
|
||||
})()
|
||||
|
102
package.json
102
package.json
@ -13,66 +13,74 @@
|
||||
"dev:tsc": "tsc -w --preserveWatchOutput",
|
||||
"dev:webpack-dev-server": "webpack-dev-server",
|
||||
"start": "node dist/server/index.js",
|
||||
"lint": "tslint 'src/**/*.ts' 'src/**/*.tsx'"
|
||||
"lint": "eslint \"./src/**/*.ts\" \"./src/**/*.tsx\"",
|
||||
"lint-autofix": "eslint \"./src/**/*.ts\" \"./src/**/*.tsx\" --fix"
|
||||
},
|
||||
"author": "rinsuki",
|
||||
"license": "AGPL-3.0+",
|
||||
"devDependencies": {
|
||||
"@types/dotenv": "^4.0.3",
|
||||
"@types/koa": "^2.0.42",
|
||||
"@types/koa-mount": "^3.0.1",
|
||||
"@types/koa-pug": "^3.0.3",
|
||||
"@types/koa-router": "^7.0.27",
|
||||
"@types/koa-session": "^5.7.3",
|
||||
"@types/koa-static": "^4.0.0",
|
||||
"@types/mongoose": "^4.7.28",
|
||||
"@types/node-fetch": "^1.6.7",
|
||||
"@types/common-tags": "^1.8.0",
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/koa": "^2.0.51",
|
||||
"@types/koa-mount": "^4.0.0",
|
||||
"@types/koa-pug": "^4.0.0",
|
||||
"@types/koa-router": "^7.0.42",
|
||||
"@types/koa-session": "^5.10.1",
|
||||
"@types/koa-static": "^4.0.1",
|
||||
"@types/koa-static-cache": "^5.1.0",
|
||||
"@types/mongoose": "^5.5.29",
|
||||
"@types/node-fetch": "^2.5.3",
|
||||
"@types/parse-link-header": "^1.0.0",
|
||||
"@types/react": "^16.0.34",
|
||||
"@types/react-dom": "^16.0.3",
|
||||
"@types/react-router-dom": "^4.2.3",
|
||||
"@types/reactstrap": "^5.0.10",
|
||||
"@types/pug": "^2.0.4",
|
||||
"@types/react": "^16.9.11",
|
||||
"@types/react-dom": "^16.9.3",
|
||||
"@types/react-router-dom": "^5.1.2",
|
||||
"@types/reactstrap": "^8.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^2.6.1",
|
||||
"@typescript-eslint/parser": "^2.6.1",
|
||||
"babel-core": "^6.26.0",
|
||||
"babel-loader": "^7.1.4",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"bootstrap": "^4.1.1",
|
||||
"bootswatch": "^4.1.1",
|
||||
"bootstrap": "^4.3.1",
|
||||
"bootswatch": "^4.3.1",
|
||||
"cpx": "^1.5.0",
|
||||
"css-loader": "^0.28.11",
|
||||
"css-loader": "^3.2.0",
|
||||
"eslint": "^6.6.0",
|
||||
"fetch-defaults": "^1.0.0",
|
||||
"file-loader": "^1.1.11",
|
||||
"jquery": "^3.2.1",
|
||||
"moment": "^2.22.2",
|
||||
"node-dev": "^3.1.3",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"page": "^1.7.1",
|
||||
"popper.js": "^1.12.9",
|
||||
"react": "^16.2.0",
|
||||
"react-dom": "^16.2.0",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"reactstrap": "^5.0.0-alpha.4",
|
||||
"style-loader": "^0.21.0",
|
||||
"ts-loader": "^4.3.0",
|
||||
"tslint": "^5.10.0",
|
||||
"typescript": "^2.6.2",
|
||||
"uglifyjs-webpack-plugin": "^1.2.5",
|
||||
"webpack": "^4.8.3",
|
||||
"webpack-cli": "^2.1.3",
|
||||
"webpack-dev-server": "^3.1.4"
|
||||
"file-loader": "^4.2.0",
|
||||
"jquery": "^3.4.1",
|
||||
"megalodon": "^2.0.0",
|
||||
"moment": "^2.24.0",
|
||||
"node-dev": "^4.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"page": "^1.11.5",
|
||||
"popper.js": "^1.16.0",
|
||||
"react": "^16.11.0",
|
||||
"react-dom": "^16.11.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"reactstrap": "^8.1.1",
|
||||
"style-loader": "^1.0.0",
|
||||
"ts-loader": "^6.2.1",
|
||||
"typescript": "^3.7.2",
|
||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.3.10",
|
||||
"webpack-dev-server": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^5.0.1",
|
||||
"koa": "^2.4.1",
|
||||
"koa-body": "^2.5.0",
|
||||
"koa-mount": "^3.0.0",
|
||||
"koa-pug": "^3.0.0-2",
|
||||
"common-tags": "^1.8.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"koa": "^2.11.0",
|
||||
"koa-body": "^4.1.1",
|
||||
"koa-mount": "^4.0.0",
|
||||
"koa-pug": "^4.0.2",
|
||||
"koa-router": "^7.3.0",
|
||||
"koa-session": "^5.5.1",
|
||||
"koa-static": "^4.0.2",
|
||||
"koa-session": "^5.12.3",
|
||||
"koa-static": "^5.0.0",
|
||||
"koa-static-cache": "^5.1.1",
|
||||
"mongoose": "^4.13.6",
|
||||
"mongoose-autopopulate": "^0.7.0",
|
||||
"node-fetch": "^1.7.3",
|
||||
"mongoose": "^5.7.8",
|
||||
"mongoose-autopopulate": "^0.9.1",
|
||||
"node-fetch": "^2.6.0",
|
||||
"oauth-1.0a": "github:rinsuki/oauth-1.0a",
|
||||
"parse-link-header": "^1.0.1",
|
||||
"rndstr": "^1.0.0"
|
||||
|
@ -1,32 +1,36 @@
|
||||
export interface APIQuestion {
|
||||
_id: string
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
user: APIUser
|
||||
question: string
|
||||
isNSFW: boolean
|
||||
likesCount: number
|
||||
isDeleted: boolean
|
||||
/* eslint @typescript-eslint/interface-name-prefix: 0 */
|
||||
|
||||
questionUser: APIUser | undefined
|
||||
answer: string | undefined
|
||||
answeredAt: string | undefined
|
||||
export interface APIQuestion
|
||||
{
|
||||
_id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
user: APIUser;
|
||||
question: string;
|
||||
isNSFW: boolean;
|
||||
likesCount: number;
|
||||
isDeleted: boolean;
|
||||
|
||||
questionUser: APIUser | undefined;
|
||||
answer: string | undefined;
|
||||
answeredAt: string | undefined;
|
||||
}
|
||||
|
||||
export interface APIUser {
|
||||
_id: string
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
name: string
|
||||
acct: string
|
||||
acctDisplay: string
|
||||
avatarUrl: string
|
||||
url: string
|
||||
allAnon: boolean
|
||||
questionBoxName: string | undefined
|
||||
description: string | undefined
|
||||
hostName: string
|
||||
pushbulletEnabled: boolean
|
||||
isTwitter: boolean
|
||||
stopNewQuestion: boolean
|
||||
export interface APIUser
|
||||
{
|
||||
_id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
acct: string;
|
||||
acctDisplay: string;
|
||||
avatarUrl: string;
|
||||
url: string;
|
||||
allAnon: boolean;
|
||||
questionBoxName: string | undefined;
|
||||
description: string | undefined;
|
||||
hostName: string;
|
||||
pushbulletEnabled: boolean;
|
||||
isTwitter: boolean;
|
||||
stopNewQuestion: boolean;
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { csrfToken } from "./initial-state"
|
||||
import { csrfToken } from './initial-state';
|
||||
|
||||
export function apiFetch(url: string, params?: RequestInit) {
|
||||
if (!params) params = {}
|
||||
params = Object.assign({
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
}, params)
|
||||
return window.fetch(url, params)
|
||||
export function apiFetch(url: string, params?: RequestInit)
|
||||
{
|
||||
if (!params) params = {};
|
||||
params = Object.assign({
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken
|
||||
}
|
||||
}, params);
|
||||
return window.fetch(url, params);
|
||||
}
|
||||
|
@ -1,47 +1,50 @@
|
||||
import * as React from "react"
|
||||
import { BrowserRouter, Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"
|
||||
import { APIUser } from "../../api-interfaces"
|
||||
import { me } from "../initial-state"
|
||||
import { Footer } from "./footer"
|
||||
import { Header } from "./header"
|
||||
import { PageIndex } from "./pages/index"
|
||||
import { PageLatest } from "./pages/latest"
|
||||
import { PageLogin } from "./pages/login"
|
||||
import { PageMyFollowers } from "./pages/my/followers"
|
||||
import { PageMyIndex } from "./pages/my/index"
|
||||
import { PageMyQuestions } from "./pages/my/questions"
|
||||
import { PageMySettings } from "./pages/my/settings"
|
||||
import { PageNotFound } from "./pages/notfound"
|
||||
import { PageUserIndex } from "./pages/user/index"
|
||||
import { PageUserQuestion } from "./pages/user/question"
|
||||
import * as React from 'react';
|
||||
import { BrowserRouter, Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom';
|
||||
import { APIUser } from '../../api-interfaces';
|
||||
import { me } from '../initial-state';
|
||||
import { Footer } from './footer';
|
||||
import { Header } from './header';
|
||||
import { PageIndex } from './pages/index';
|
||||
import { PageLatest } from './pages/latest';
|
||||
import { PageLogin } from './pages/login';
|
||||
import { PageMyFollowers } from './pages/my/followers';
|
||||
import { PageMyIndex } from './pages/my/index';
|
||||
import { PageMyQuestions } from './pages/my/questions';
|
||||
import { PageMySettings } from './pages/my/settings';
|
||||
import { PageNotFound } from './pages/notfound';
|
||||
import { PageUserIndex } from './pages/user/index';
|
||||
import { PageUserQuestion } from './pages/user/question';
|
||||
|
||||
export class App extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="all-container">
|
||||
<Header/>
|
||||
<main className="container mt-2">
|
||||
<Switch>
|
||||
<Route exact path="/" component={PageIndex}/>
|
||||
<Route exact path="/latest" component={PageLatest}/>
|
||||
<Route exact path="/login" component={PageLogin}/>
|
||||
{!me && <Redirect from="/my" to="/login"/>}
|
||||
<Route exact path="/my" component={PageMyIndex}/>
|
||||
<Route exact path="/my/questions" component={PageMyQuestions}/>
|
||||
<Route exact path="/my/followers" component={PageMyFollowers}/>
|
||||
<Route exact path="/my/settings" component={PageMySettings}/>
|
||||
<Route exact path="/@:user_id" render={(props: RouteComponentProps<{user_id: string}>) => {
|
||||
const userId = props.match.params.user_id
|
||||
return <PageUserIndex key={userId} userId={userId}/>
|
||||
}}/>
|
||||
<Route exact path="/@:user_id/questions/:question_id" component={PageUserQuestion}/>
|
||||
<Route component={PageNotFound}/>
|
||||
</Switch>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
export class App extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="all-container">
|
||||
<Header/>
|
||||
<main className="container mt-2">
|
||||
<Switch>
|
||||
<Route exact path="/" component={PageIndex}/>
|
||||
<Route exact path="/latest" component={PageLatest}/>
|
||||
<Route exact path="/login" component={PageLogin}/>
|
||||
{!me && <Redirect from="/my" to="/login"/>}
|
||||
<Route exact path="/my" component={PageMyIndex}/>
|
||||
<Route exact path="/my/questions" component={PageMyQuestions}/>
|
||||
<Route exact path="/my/followers" component={PageMyFollowers}/>
|
||||
<Route exact path="/my/settings" component={PageMySettings}/>
|
||||
<Route exact path="/@:user_id" render={(props: RouteComponentProps<{user_id: string}>) =>
|
||||
{
|
||||
const userId = props.match.params.user_id;
|
||||
return <PageUserIndex key={userId} userId={userId}/>;
|
||||
}}/>
|
||||
<Route exact path="/@:user_id/questions/:question_id" component={PageUserQuestion}/>
|
||||
<Route component={PageNotFound}/>
|
||||
</Switch>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,41 +1,44 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
interface Props {
|
||||
id?: string
|
||||
name: string
|
||||
checked?: boolean | undefined
|
||||
value: string
|
||||
onChange?: (e: any) => void | undefined
|
||||
className?: string| undefined
|
||||
id?: string;
|
||||
name: string;
|
||||
checked?: boolean | undefined;
|
||||
value: string;
|
||||
onChange?: (e: any) => void | undefined;
|
||||
className?: string| undefined;
|
||||
}
|
||||
|
||||
interface State {
|
||||
id: string
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class Checkbox extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
export class Checkbox extends React.Component<Props, State>
|
||||
{
|
||||
constructor(props: Props)
|
||||
{
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
id: "checkbox_temp_" + (Math.random().toString().replace("0.", "")),
|
||||
}
|
||||
}
|
||||
this.state = {
|
||||
id: 'checkbox_temp_' + (Math.random().toString().replace('0.', ''))
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const domId = this.props.id || this.state.id
|
||||
return <span className={`custom-control custom-checkbox ${this.props.className}`} style={{
|
||||
display: "inline",
|
||||
}}>
|
||||
<input className="custom-control-input"
|
||||
type="checkbox"
|
||||
name={this.props.name}
|
||||
defaultChecked={this.props.checked}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
id={domId}
|
||||
/>
|
||||
<label className="custom-control-label" htmlFor={domId}>{this.props.children}</label>
|
||||
</span>
|
||||
}
|
||||
render()
|
||||
{
|
||||
const domId = this.props.id || this.state.id;
|
||||
return <span className={`custom-control custom-checkbox ${this.props.className}`} style={{
|
||||
display: 'inline'
|
||||
}}>
|
||||
<input className="custom-control-input"
|
||||
type="checkbox"
|
||||
name={this.props.name}
|
||||
defaultChecked={this.props.checked}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
id={domId}
|
||||
/>
|
||||
<label className="custom-control-label" htmlFor={domId}>{this.props.children}</label>
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import * as React from "react"
|
||||
import { NavLink as Link } from "react-router-dom"
|
||||
import * as React from 'react';
|
||||
import { NavLink as Link } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
to: string;
|
||||
}
|
||||
|
||||
export class NavLink extends React.Component<Props> {
|
||||
render() {
|
||||
return <Link className="nav-link" activeClassName="active" to={this.props.to}>{this.props.children}</Link>
|
||||
}
|
||||
export class NavLink extends React.Component<Props>
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <Link className="nav-link" activeClassName="active" to={this.props.to}>{this.props.children}</Link>;
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,16 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
to: string;
|
||||
}
|
||||
|
||||
export class NavbarBrand extends React.Component<Props> {
|
||||
render() {
|
||||
return <Link className="navbar-brand" to={this.props.to}>
|
||||
{this.props.children}
|
||||
</Link>
|
||||
}
|
||||
export class NavbarBrand extends React.Component<Props>
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <Link className="navbar-brand" to={this.props.to}>
|
||||
{this.props.children}
|
||||
</Link>;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
export class Title extends React.Component {
|
||||
render() {
|
||||
return <title>{this.props.children} - Quesdon</title>
|
||||
}
|
||||
export class Title extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <title>{this.props.children} - Quesdon</title>;
|
||||
}
|
||||
}
|
||||
|
@ -1,37 +1,41 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { gitVersion, upstreamUrl, usingDarkTheme } from "../initial-state"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { gitVersion, upstreamUrl, usingDarkTheme } from '../initial-state';
|
||||
|
||||
export class Footer extends React.Component {
|
||||
render() {
|
||||
return <footer className="container">
|
||||
<p>
|
||||
export class Footer extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <footer className="container">
|
||||
<p>
|
||||
Quesdon@Planet은 <a href="https://quesdon.rinsuki.net/">Quesdon</a>의 포크로,
|
||||
AGPL-3.0 라이센스로 제공되고 있어요.
|
||||
<a href={upstreamUrl}>소스 코드</a>
|
||||
<a href={upstreamUrl}>소스 코드</a>
|
||||
(<a href={`${upstreamUrl}/commits/${gitVersion}`}>{gitVersion.slice(0, 7)}</a>) /
|
||||
<a href="https://github.com/rinsuki/quesdon">원본 소스 코드 (rinsuki/quesdon)</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/rinsuki/quesdon">원본 소스 코드 (rinsuki/quesdon)</a>
|
||||
</p>
|
||||
<p>
|
||||
플래닛 공식 계정: <a href="https://planet.moe/@planet">@planet@planet.moe</a>
|
||||
</p>
|
||||
<p>원작자: <a href="https://mstdn.maud.io/@rinsuki">@rinsuki@mstdn.maud.io</a></p>
|
||||
<p>
|
||||
{usingDarkTheme
|
||||
? <a href="#" onClick={this.leaveDarkTheme.bind(this)}>밝은 배경</a>
|
||||
: <a href="#" onClick={this.enterDarkTheme.bind(this)}>어두운 배경(β)</a>
|
||||
}
|
||||
</p>
|
||||
</footer>
|
||||
}
|
||||
</p>
|
||||
<p>원작자: <a href="https://mstdn.maud.io/@rinsuki">@rinsuki@mstdn.maud.io</a></p>
|
||||
<p>
|
||||
{usingDarkTheme
|
||||
? <a href="#" onClick={this.leaveDarkTheme.bind(this)}>밝은 배경</a>
|
||||
: <a href="#" onClick={this.enterDarkTheme.bind(this)}>어두운 배경(β)</a>
|
||||
}
|
||||
</p>
|
||||
</footer>;
|
||||
}
|
||||
|
||||
leaveDarkTheme() {
|
||||
localStorage.removeItem("using-dark-theme")
|
||||
location.reload()
|
||||
}
|
||||
leaveDarkTheme()
|
||||
{
|
||||
localStorage.removeItem('using-dark-theme');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
enterDarkTheme() {
|
||||
localStorage.setItem("using-dark-theme", "1")
|
||||
location.reload()
|
||||
}
|
||||
enterDarkTheme()
|
||||
{
|
||||
localStorage.setItem('using-dark-theme', '1');
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
@ -1,40 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Collapse, Container, Nav, Navbar, NavbarToggler, NavItem } from "reactstrap"
|
||||
import { APIUser } from "../../api-interfaces"
|
||||
import { me } from "../initial-state"
|
||||
import { NavbarBrand } from "./common/navbarBrand"
|
||||
import { NavLink } from "./common/navLink"
|
||||
import { QuestionRemaining } from "./question-remaining"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collapse, Container, Nav, Navbar, NavbarToggler, NavItem } from 'reactstrap';
|
||||
import { APIUser } from '../../api-interfaces';
|
||||
import { me } from '../initial-state';
|
||||
import { NavbarBrand } from './common/navbarBrand';
|
||||
import { NavLink } from './common/navLink';
|
||||
import { QuestionRemaining } from './question-remaining';
|
||||
|
||||
interface State {
|
||||
isOpen: boolean
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export class Header extends React.Component<{}, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isOpen: false,
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return <Navbar light expand="md" color="light"><Container>
|
||||
<NavbarBrand to="/">Quesdon@Planet</NavbarBrand>
|
||||
<NavbarToggler onClick={this.toggle.bind(this)} />
|
||||
<Collapse navbar isOpen={this.state.isOpen}>
|
||||
<Nav className="mr-auto" navbar>
|
||||
<NavItem>
|
||||
{me
|
||||
? <NavLink to="/my">@{me.acctDisplay}<QuestionRemaining/></NavLink>
|
||||
: <NavLink to="/login">로그인</NavLink>}
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Container></Navbar>
|
||||
}
|
||||
export class Header extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: any)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
render()
|
||||
{
|
||||
return <Navbar light expand="md" color="light"><Container>
|
||||
<NavbarBrand to="/">Quesdon@Planet</NavbarBrand>
|
||||
<NavbarToggler onClick={this.toggle.bind(this)} />
|
||||
<Collapse navbar isOpen={this.state.isOpen}>
|
||||
<Nav className="mr-auto" navbar>
|
||||
<NavItem>
|
||||
{me
|
||||
? <NavLink to="/my">@{me.acctDisplay}<QuestionRemaining/></NavLink>
|
||||
: <NavLink to="/login">로그인</NavLink>}
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Container></Navbar>;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.setState({isOpen: !this.state.isOpen})
|
||||
}
|
||||
toggle()
|
||||
{
|
||||
this.setState({isOpen: !this.state.isOpen});
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
export class Loading extends React.Component {
|
||||
render() {
|
||||
return <div className="mt-2 mb-4">
|
||||
<h1 style={{textAlign: "center"}}>Loading...</h1>
|
||||
<div className="progress progress-anime">
|
||||
<div className="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
export class Loading extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <div className="mt-2 mb-4">
|
||||
<h1 style={{textAlign: 'center'}}>Loading...</h1>
|
||||
<div className="progress progress-anime">
|
||||
<div className="progress-bar"></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { me } from "../../initial-state"
|
||||
import { PageLatest } from "./latest"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { me } from '../../initial-state';
|
||||
import { PageLatest } from './latest';
|
||||
|
||||
export class PageIndex extends React.Component {
|
||||
render() {
|
||||
return <div>
|
||||
<title>Quesdon@Planet</title>
|
||||
<h1>Quesdon@Planet</h1>
|
||||
<p>Mastodon에서 사용할 수 있는 askfm스러운 무언가</p>
|
||||
<p>{me ? <Link to="/my">마이페이지</Link> : <Link to="/login">로그인</Link>}</p>
|
||||
<PageLatest />
|
||||
</div>
|
||||
}
|
||||
export class PageIndex extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <div>
|
||||
<title>Quesdon@Planet</title>
|
||||
<h1>Quesdon@Planet</h1>
|
||||
<p>Mastodon에서 사용할 수 있는 askfm스러운 무언가</p>
|
||||
<p>{me ? <Link to="/my">마이페이지</Link> : <Link to="/login">로그인</Link>}</p>
|
||||
<PageLatest />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
@ -1,90 +1,101 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "reactstrap"
|
||||
import { APIQuestion } from "../../../api-interfaces"
|
||||
import { apiFetch } from "../../api-fetch"
|
||||
import { Title } from "../common/title"
|
||||
import { Loading } from "../loading"
|
||||
import { Question } from "../question"
|
||||
import * as React from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { APIQuestion } from '../../../api-interfaces';
|
||||
import { apiFetch } from '../../api-fetch';
|
||||
import { Title } from '../common/title';
|
||||
import { Loading } from '../loading';
|
||||
import { Question } from '../question';
|
||||
|
||||
interface State {
|
||||
questions: APIQuestion[]
|
||||
loading: boolean
|
||||
loadFailed?: number
|
||||
loadTimer?: number
|
||||
questions: APIQuestion[];
|
||||
loading: boolean;
|
||||
loadFailed?: number;
|
||||
loadTimer?: number;
|
||||
}
|
||||
|
||||
export class PageLatest extends React.Component<{}, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {
|
||||
questions: [],
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
loadFailed,
|
||||
questions,
|
||||
} = this.state
|
||||
return <div>
|
||||
<Title>최근 답변 - Quesdon</Title>
|
||||
<h2>최근 올라온 답변들 <Button color="white" onClick={this.load.bind(this)} disabled={loading}>새로고침</Button></h2>
|
||||
{ loading
|
||||
? <Loading/>
|
||||
: loadFailed
|
||||
? <span>
|
||||
export class PageLatest extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: any)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
questions: [],
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
render()
|
||||
{
|
||||
const {
|
||||
loading,
|
||||
loadFailed,
|
||||
questions
|
||||
} = this.state;
|
||||
return <div>
|
||||
<Title>최근 답변 - Quesdon</Title>
|
||||
<h2>최근 올라온 답변들 <Button color="white" onClick={this.load.bind(this)} disabled={loading}>새로고침</Button></h2>
|
||||
{ loading
|
||||
? <Loading/>
|
||||
: loadFailed
|
||||
? <span>
|
||||
불러오기에 실패했어요. 위쪽 새로고침 버튼을 눌러서 다시 시도해 주세요.
|
||||
({loadFailed < 0 ? loadFailed : "HTTP-" + loadFailed})
|
||||
</span>
|
||||
: questions.map((question) => <Question {...question} key={question._id}/>)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
({loadFailed < 0 ? loadFailed : 'HTTP-' + loadFailed})
|
||||
</span>
|
||||
: questions.map((question) => <Question {...question} key={question._id}/>)
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.load()
|
||||
this.setState({
|
||||
loadTimer: window.setInterval(() => {
|
||||
this.load()
|
||||
}, 5 * 60 * 1000),
|
||||
})
|
||||
}
|
||||
componentDidMount()
|
||||
{
|
||||
this.load();
|
||||
this.setState({
|
||||
loadTimer: window.setInterval(() =>
|
||||
{
|
||||
this.load();
|
||||
}, 5 * 60 * 1000)
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {loadTimer} = this.state
|
||||
if (loadTimer != null) {
|
||||
window.clearInterval(loadTimer)
|
||||
}
|
||||
}
|
||||
componentWillUnmount()
|
||||
{
|
||||
const {loadTimer} = this.state;
|
||||
if (loadTimer != null)
|
||||
{
|
||||
window.clearInterval(loadTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.setState({loading: true})
|
||||
const req = await apiFetch("/api/web/questions/latest").catch((err) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -1,
|
||||
})
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: req.status,
|
||||
})
|
||||
}
|
||||
async load()
|
||||
{
|
||||
this.setState({loading: true});
|
||||
const req = await apiFetch('/api/web/questions/latest').catch((err) =>
|
||||
{
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -1
|
||||
});
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: req.status
|
||||
});
|
||||
}
|
||||
|
||||
const questions = await req.json().catch((err) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -2,
|
||||
})
|
||||
})
|
||||
if (!questions) return
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: undefined,
|
||||
questions,
|
||||
})
|
||||
}
|
||||
const questions = await req.json().catch((err) =>
|
||||
{
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -2
|
||||
});
|
||||
});
|
||||
if (!questions) return;
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: undefined,
|
||||
questions
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,74 +1,83 @@
|
||||
import * as React from "react"
|
||||
import { Alert, Button, FormGroup, Input } from "reactstrap"
|
||||
import { apiFetch } from "../../api-fetch"
|
||||
import majorInstances from "../../major-instances"
|
||||
import { Title } from "../common/title"
|
||||
import * as React from 'react';
|
||||
import { Alert, Button, FormGroup, Input } from 'reactstrap';
|
||||
import { apiFetch } from '../../api-fetch';
|
||||
import majorInstances from '../../major-instances';
|
||||
import { Title } from '../common/title';
|
||||
|
||||
interface State {
|
||||
loading: boolean
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export class PageLogin extends React.Component<{}, State> {
|
||||
constructor(props: {}) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
export class PageLogin extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: {})
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading } = this.state
|
||||
return <div>
|
||||
<Title>로그인</Title>
|
||||
<h1>로그인</h1>
|
||||
<p>사용중인 Mastodon 계정이 있는 인스턴스를 입력해 주세요.</p>
|
||||
<form action="javascript://" onSubmit={this.send.bind(this)}>
|
||||
<FormGroup>
|
||||
<Input name="instance" placeholder="planet.moe" list="major-instances"/>
|
||||
<datalist id="major-instances">
|
||||
{majorInstances.map((instance) => <option value={instance} />)}
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<Button type="submit" color="primary" disabled={loading}>{ loading ? "불러오는 중" : "로그인" }</Button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
render()
|
||||
{
|
||||
const { loading } = this.state;
|
||||
return <div>
|
||||
<Title>로그인</Title>
|
||||
<h1>로그인</h1>
|
||||
<p>사용중인 Mastodon 계정이 있는 인스턴스를 입력해 주세요.</p>
|
||||
<form action="javascript://" onSubmit={this.send.bind(this)}>
|
||||
<FormGroup>
|
||||
<Input name="instance" placeholder="planet.moe" list="major-instances"/>
|
||||
<datalist id="major-instances">
|
||||
{majorInstances.map((instance) => <option value={instance} />)}
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<Button type="submit" color="primary" disabled={loading}>{ loading ? '불러오는 중' : '로그인' }</Button>
|
||||
</form>
|
||||
</div>;
|
||||
}
|
||||
|
||||
send(e: any) {
|
||||
const form = new FormData(e.target)
|
||||
this.callApi(form)
|
||||
}
|
||||
async callApi(form: FormData) {
|
||||
this.setState({
|
||||
loading: true,
|
||||
})
|
||||
function errorMsg(code: number | string) {
|
||||
return "로그인에 실패했어요. 입력한 내용을 확인하신 후 다시 시도해 주세요. (" + code + ")"
|
||||
}
|
||||
const req = await apiFetch("/api/web/oauth/get_url", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}).catch((e) => {
|
||||
alert(errorMsg(-1))
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
alert(errorMsg("HTTP-" + req.status))
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
const urlRes = await req.json().catch((e) => {
|
||||
alert(errorMsg(-2))
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
if (!urlRes) return
|
||||
location.href = urlRes.url
|
||||
}
|
||||
send(e: any)
|
||||
{
|
||||
const form = new FormData(e.target);
|
||||
this.callApi(form);
|
||||
}
|
||||
async callApi(form: FormData)
|
||||
{
|
||||
this.setState({
|
||||
loading: true
|
||||
});
|
||||
function errorMsg(code: number | string)
|
||||
{
|
||||
return '로그인에 실패했어요. 입력한 내용을 확인하신 후 다시 시도해 주세요. (' + code + ')';
|
||||
}
|
||||
const req = await apiFetch('/api/web/oauth/get_url', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
}).catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-1));
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
alert(errorMsg('HTTP-' + req.status));
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
const urlRes = await req.json().catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-2));
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
if (!urlRes) return;
|
||||
location.href = urlRes.url;
|
||||
}
|
||||
}
|
||||
|
@ -1,76 +1,85 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "reactstrap"
|
||||
import { APIUser } from "../../../../api-interfaces"
|
||||
import { apiFetch } from "../../../api-fetch"
|
||||
import { Title } from "../../common/title"
|
||||
import { UserLink } from "../../userLink"
|
||||
import * as React from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { APIUser } from '../../../../api-interfaces';
|
||||
import { apiFetch } from '../../../api-fetch';
|
||||
import { Title } from '../../common/title';
|
||||
import { UserLink } from '../../userLink';
|
||||
|
||||
interface State {
|
||||
maxId: string | undefined
|
||||
accounts: APIUser[]
|
||||
loading: boolean
|
||||
maxId: string | undefined;
|
||||
accounts: APIUser[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export class PageMyFollowers extends React.Component<{}, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {
|
||||
accounts: [],
|
||||
maxId: undefined,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
export class PageMyFollowers extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: any)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
accounts: [],
|
||||
maxId: undefined,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<Title>Quesdon@Planet을 사용 중인 팔로워 목록 - 마이페이지</Title>
|
||||
<h1>Quesdon@Planet을 사용 중인 팔로워 목록</h1>
|
||||
<ul>
|
||||
{this.state.accounts.map((user) => <li><UserLink {...user} /></li>)}
|
||||
</ul>
|
||||
<Button disabled={this.state.loading || !this.state.maxId}
|
||||
onClick={this.readMore.bind(this)}>
|
||||
{this.state.loading ? "불러오는 중" : this.state.maxId ? "더 보기" : "이게 끝이에요 0_0"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
render()
|
||||
{
|
||||
return <div>
|
||||
<Title>Quesdon@Planet을 사용 중인 팔로워 목록 - 마이페이지</Title>
|
||||
<h1>Quesdon@Planet을 사용 중인 팔로워 목록</h1>
|
||||
<ul>
|
||||
{this.state.accounts.map((user) => <li><UserLink {...user} /></li>)}
|
||||
</ul>
|
||||
<Button disabled={this.state.loading || !this.state.maxId}
|
||||
onClick={this.readMore.bind(this)}>
|
||||
{this.state.loading ? '불러오는 중' : this.state.maxId ? '더 보기' : '이게 끝이에요 0_0'}
|
||||
</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.readMore()
|
||||
}
|
||||
componentDidMount()
|
||||
{
|
||||
this.readMore();
|
||||
}
|
||||
|
||||
async readMore() {
|
||||
function errorMsg(code: number | string) {
|
||||
return "불러오기에 실패했어요. 다시 시도해 주세요. (" + code + ")"
|
||||
}
|
||||
this.setState({loading: true})
|
||||
const param = this.state.maxId ? "?max_id=" + this.state.maxId : ""
|
||||
const req = await apiFetch("/api/web/accounts/followers" + param)
|
||||
.catch((e) => {
|
||||
alert(errorMsg(-1))
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
alert(errorMsg("HTTP-" + req.status))
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
const res = await req.json().catch((e) => {
|
||||
alert(errorMsg(-2))
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
if (!res) return
|
||||
this.setState({
|
||||
accounts: this.state.accounts.concat(res.accounts),
|
||||
maxId: res.max_id,
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
async readMore()
|
||||
{
|
||||
function errorMsg(code: number | string)
|
||||
{
|
||||
return '불러오기에 실패했어요. 다시 시도해 주세요. (' + code + ')';
|
||||
}
|
||||
this.setState({loading: true});
|
||||
const param = this.state.maxId ? '?max_id=' + this.state.maxId : '';
|
||||
const req = await apiFetch('/api/web/accounts/followers' + param)
|
||||
.catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-1));
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
alert(errorMsg('HTTP-' + req.status));
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await req.json().catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-2));
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
if (!res) return;
|
||||
this.setState({
|
||||
accounts: this.state.accounts.concat(res.accounts),
|
||||
maxId: res.max_id,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,33 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { APIUser } from "../../../../api-interfaces"
|
||||
import { apiFetch } from "../../../api-fetch"
|
||||
import { me } from "../../../initial-state"
|
||||
import { Title } from "../../common/title"
|
||||
import { QuestionRemaining } from "../../question-remaining"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { APIUser } from '../../../../api-interfaces';
|
||||
import { apiFetch } from '../../../api-fetch';
|
||||
import { me } from '../../../initial-state';
|
||||
import { Title } from '../../common/title';
|
||||
import { QuestionRemaining } from '../../question-remaining';
|
||||
|
||||
export class PageMyIndex extends React.Component {
|
||||
render() {
|
||||
if (!me) return null
|
||||
return <div>
|
||||
<Title>마이페이지</Title>
|
||||
<h1>마이페이지</h1>
|
||||
<p>반가워요, {me.name}님!</p>
|
||||
<ul>
|
||||
<li><Link to={`/@${me.acct}`}>프로필 페이지</Link></li>
|
||||
<li><Link to="/my/questions">아직 답변하지 않은 질문<QuestionRemaining/></Link></li>
|
||||
{!me.isTwitter && <li><Link to="/my/followers">Quesdon@Planet을 사용중인 팔로워 목록</Link></li>}
|
||||
<li><Link to="/my/settings">설정</Link></li>
|
||||
<li><a href="javascript://" onClick={this.logoutConfirm.bind(this)}>로그아웃</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
logoutConfirm() {
|
||||
if (!confirm("정말 로그아웃 하실 건가요?")) return
|
||||
apiFetch("/api/web/logout")
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
location.pathname = "/"
|
||||
})
|
||||
}
|
||||
export class PageMyIndex extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
if (!me) return null;
|
||||
return <div>
|
||||
<Title>마이페이지</Title>
|
||||
<h1>마이페이지</h1>
|
||||
<p>반가워요, {me.name}님!</p>
|
||||
<ul>
|
||||
<li><Link to={`/@${me.acct}`}>프로필 페이지</Link></li>
|
||||
<li><Link to="/my/questions">아직 답변하지 않은 질문<QuestionRemaining/></Link></li>
|
||||
{!me.isTwitter && <li><Link to="/my/followers">Quesdon@Planet을 사용중인 팔로워 목록</Link></li>}
|
||||
<li><Link to="/my/settings">설정</Link></li>
|
||||
<li><a href="javascript://" onClick={this.logoutConfirm.bind(this)}>로그아웃</a></li>
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
logoutConfirm()
|
||||
{
|
||||
if (!confirm('정말 로그아웃 하실 건가요?')) return;
|
||||
apiFetch('/api/web/logout')
|
||||
.then((r) => r.json())
|
||||
.then((r) =>
|
||||
{
|
||||
location.pathname = '/';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,94 +1,103 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Button } from "reactstrap"
|
||||
import { APIQuestion, APIUser } from "../../../../api-interfaces"
|
||||
import { apiFetch } from "../../../api-fetch"
|
||||
import josa from "../../../josa"
|
||||
import { Title } from "../../common/title"
|
||||
import { Loading } from "../../loading"
|
||||
import { Question } from "../../question"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from 'reactstrap';
|
||||
import { APIQuestion, APIUser } from '../../../../api-interfaces';
|
||||
import { apiFetch } from '../../../api-fetch';
|
||||
import josa from '../../../josa';
|
||||
import { Title } from '../../common/title';
|
||||
import { Loading } from '../../loading';
|
||||
import { Question } from '../../question';
|
||||
|
||||
interface State {
|
||||
questions: APIQuestion[]
|
||||
loading: boolean
|
||||
loadFailed?: number
|
||||
questions: APIQuestion[];
|
||||
loading: boolean;
|
||||
loadFailed?: number;
|
||||
}
|
||||
export class PageMyQuestions extends React.Component<{}, State> {
|
||||
constructor(props: {}) {
|
||||
super(props)
|
||||
this.state = {
|
||||
questions: [],
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
loadFailed,
|
||||
questions,
|
||||
} = this.state
|
||||
return <div>
|
||||
<Title>질문 목록 - 마이페이지</Title>
|
||||
<h1>아직 답변하지 않은 질문 목록</h1>
|
||||
<Link to="/my">마이페이지로 돌아가기</Link>
|
||||
<div className="mt-3">
|
||||
{loading
|
||||
? <Loading/>
|
||||
: loadFailed
|
||||
? <span>
|
||||
불러오기에 실패했어요.({ loadFailed < 0 ? loadFailed : "HTTP-" + loadFailed })。
|
||||
<a href="javascript://" onClick={this.load.bind(this)}>새로고침</a>
|
||||
</span>
|
||||
: questions.map((q) => <Question {...q} hideAnswerUser key={q._id}/>)
|
||||
}
|
||||
</div>
|
||||
<Button href={this.getShareUrl()} color="secondary" target="_blank">
|
||||
export class PageMyQuestions extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: {})
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
questions: [],
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
render()
|
||||
{
|
||||
const {
|
||||
loading,
|
||||
loadFailed,
|
||||
questions
|
||||
} = this.state;
|
||||
return <div>
|
||||
<Title>질문 목록 - 마이페이지</Title>
|
||||
<h1>아직 답변하지 않은 질문 목록</h1>
|
||||
<Link to="/my">마이페이지로 돌아가기</Link>
|
||||
<div className="mt-3">
|
||||
{loading
|
||||
? <Loading/>
|
||||
: loadFailed
|
||||
? <span>
|
||||
불러오기에 실패했어요.({ loadFailed < 0 ? loadFailed : 'HTTP-' + loadFailed })。
|
||||
<a href="javascript://" onClick={this.load.bind(this)}>새로고침</a>
|
||||
</span>
|
||||
: questions.map((q) => <Question {...q} hideAnswerUser key={q._id}/>)
|
||||
}
|
||||
</div>
|
||||
<Button href={this.getShareUrl()} color="secondary" target="_blank">
|
||||
Mastodon에 질문상자 페이지를 공유
|
||||
<wbr />
|
||||
<wbr />
|
||||
(새 창으로 열릴 거에요)
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.load()
|
||||
}
|
||||
componentDidMount()
|
||||
{
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.setState({
|
||||
loading: true,
|
||||
loadFailed: undefined,
|
||||
})
|
||||
const req = await apiFetch("/api/web/questions").catch((e) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -1,
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: req.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
async load()
|
||||
{
|
||||
this.setState({
|
||||
loading: true,
|
||||
loadFailed: undefined
|
||||
});
|
||||
const req = await apiFetch('/api/web/questions').catch((e) =>
|
||||
{
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -1
|
||||
});
|
||||
return;
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: req.status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = await req.json().catch((e) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -2,
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!questions) return
|
||||
this.setState({questions, loading: false})
|
||||
}
|
||||
getShareUrl() {
|
||||
const user = (window as any).USER as APIUser
|
||||
const qbox = user.questionBoxName || "질문 상자"
|
||||
const text = `저의 ${josa(qbox, "이에요", "예요")}! #quesdon ${location.origin}/@${user.acct}`
|
||||
return `https://${user.hostName}/${user.isTwitter ? "intent/tweet" : "share"}?text=${encodeURIComponent(text)}`
|
||||
}
|
||||
const questions = await req.json().catch((e) =>
|
||||
{
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadFailed: -2
|
||||
});
|
||||
return;
|
||||
});
|
||||
if (!questions) return;
|
||||
this.setState({questions, loading: false});
|
||||
}
|
||||
getShareUrl()
|
||||
{
|
||||
const user = (window as any).USER as APIUser;
|
||||
const qbox = user.questionBoxName || '질문 상자';
|
||||
const text = `저의 ${josa(qbox, '이에요', '예요')}! #quesdon ${location.origin}/@${user.acct}`;
|
||||
return `https://${user.hostName}/${user.isTwitter ? 'intent/tweet' : 'share'}?text=${encodeURIComponent(text)}`;
|
||||
}
|
||||
}
|
||||
|
@ -1,191 +1,214 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Button, FormGroup, FormText, Input, InputGroup, InputGroupAddon } from "reactstrap"
|
||||
import { apiFetch } from "../../../api-fetch"
|
||||
import { me } from "../../../initial-state"
|
||||
import { Checkbox } from "../../common/checkbox"
|
||||
import { Title } from "../../common/title"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, FormGroup, FormText, Input, InputGroup, InputGroupAddon } from 'reactstrap';
|
||||
import { apiFetch } from '../../../api-fetch';
|
||||
import { me } from '../../../initial-state';
|
||||
import { Checkbox } from '../../common/checkbox';
|
||||
import { Title } from '../../common/title';
|
||||
|
||||
interface State {
|
||||
descriptionMax: number
|
||||
questionBoxNameMax: number
|
||||
descriptionCount: number
|
||||
questionBoxNameCount: number
|
||||
saving: boolean
|
||||
descriptionMax: number;
|
||||
questionBoxNameMax: number;
|
||||
descriptionCount: number;
|
||||
questionBoxNameCount: number;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
export class PageMySettings extends React.Component<{}, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
if (!me) return
|
||||
this.state = {
|
||||
descriptionMax: 200,
|
||||
questionBoxNameMax: 10,
|
||||
descriptionCount: (me.description || "").length,
|
||||
questionBoxNameCount: (me.questionBoxName || "질문 상자").length,
|
||||
saving: false,
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (!me) return null
|
||||
return <div>
|
||||
<Title>설정</Title>
|
||||
<h1>설정</h1>
|
||||
<Link to="/my">마이페이지로 돌아가기</Link>
|
||||
<form action="javascript://" onSubmit={this.onSubmit.bind(this)}>
|
||||
<FormGroup>
|
||||
<label>간단한 설명</label>
|
||||
<Input type="textarea" name="description"
|
||||
placeholder="이루어져라, 우리들의 꿈!"
|
||||
onInput={this.inputDescription.bind(this)}
|
||||
defaultValue={me.description}/>
|
||||
<FormText>앞으로 {this.descriptionRemaining()}자, 줄바꿈은 표시되지 않아요</FormText>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>'질문 상자'의 이름</label>
|
||||
<InputGroup>
|
||||
<InputGroupAddon addonType="prepend">누구누구 님의 </InputGroupAddon>
|
||||
<Input type="text" name="questionBoxName" placeholder="질문 상자"
|
||||
onInput={this.inputQuestionBoxName.bind(this)}
|
||||
defaultValue={me.questionBoxName || "질문 상자"}/>
|
||||
</InputGroup>
|
||||
<FormText>앞으로 {this.questionBoxNameRemaining()}자, 줄바꿈은 표시되지 않아요</FormText>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<Checkbox name="allAnon" value="1" checked={me.allAnon}>질문을 익명으로만 받기</Checkbox>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<Checkbox name="stopNewQuestion" value="1" checked={me.stopNewQuestion}>더 이상 질문을 안 받기</Checkbox>
|
||||
</FormGroup>
|
||||
<Button type="submit" color="primary" disabled={this.sendableForm()}>
|
||||
저장{this.state.saving && "중이에요..."}
|
||||
</Button>
|
||||
</form>
|
||||
<h2 className="mt-3 mb-2">푸시 알림</h2>
|
||||
{me.pushbulletEnabled
|
||||
? <Button color="warning" onClick={this.pushbulletDisconnect.bind(this)}>Pushbullet과 연결 해제</Button>
|
||||
: <Button href="/api/web/accounts/pushbullet/redirect" color="success">
|
||||
export class PageMySettings extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: any)
|
||||
{
|
||||
super(props);
|
||||
if (!me) return;
|
||||
this.state = {
|
||||
descriptionMax: 200,
|
||||
questionBoxNameMax: 10,
|
||||
descriptionCount: (me.description || '').length,
|
||||
questionBoxNameCount: (me.questionBoxName || '질문 상자').length,
|
||||
saving: false
|
||||
};
|
||||
}
|
||||
render()
|
||||
{
|
||||
if (!me) return null;
|
||||
return <div>
|
||||
<Title>설정</Title>
|
||||
<h1>설정</h1>
|
||||
<Link to="/my">마이페이지로 돌아가기</Link>
|
||||
<form action="javascript://" onSubmit={this.onSubmit.bind(this)}>
|
||||
<FormGroup>
|
||||
<label>간단한 설명</label>
|
||||
<Input type="textarea" name="description"
|
||||
placeholder="이루어져라, 우리들의 꿈!"
|
||||
onInput={this.inputDescription.bind(this)}
|
||||
defaultValue={me.description}/>
|
||||
<FormText>앞으로 {this.descriptionRemaining()}자, 줄바꿈은 표시되지 않아요</FormText>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>'질문 상자'의 이름</label>
|
||||
<InputGroup>
|
||||
<InputGroupAddon addonType="prepend">누구누구 님의 </InputGroupAddon>
|
||||
<Input type="text" name="questionBoxName" placeholder="질문 상자"
|
||||
onInput={this.inputQuestionBoxName.bind(this)}
|
||||
defaultValue={me.questionBoxName || '질문 상자'}/>
|
||||
</InputGroup>
|
||||
<FormText>앞으로 {this.questionBoxNameRemaining()}자, 줄바꿈은 표시되지 않아요</FormText>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<Checkbox name="allAnon" value="1" checked={me.allAnon}>질문을 익명으로만 받기</Checkbox>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<Checkbox name="stopNewQuestion" value="1" checked={me.stopNewQuestion}>더 이상 질문을 안 받기</Checkbox>
|
||||
</FormGroup>
|
||||
<Button type="submit" color="primary" disabled={this.sendableForm()}>
|
||||
저장{this.state.saving && '중이에요...'}
|
||||
</Button>
|
||||
</form>
|
||||
<h2 className="mt-3 mb-2">푸시 알림</h2>
|
||||
{me.pushbulletEnabled
|
||||
? <Button color="warning" onClick={this.pushbulletDisconnect.bind(this)}>Pushbullet과 연결 해제</Button>
|
||||
: <Button href="/api/web/accounts/pushbullet/redirect" color="success">
|
||||
Pushbullet과 연결해서 새로운 질문이 들어왔을 때 알림 받기
|
||||
</Button>
|
||||
}
|
||||
<h2 className="mt-3 mb-2">짱 위험한 곳</h2>
|
||||
<Button color="danger" onClick={this.allDeleteQuestions.bind(this)}>받았던 질문들을(이미 답변한거까지 포함해서!) 싹 다 날려버리기!!!</Button>
|
||||
</div>
|
||||
}
|
||||
</Button>
|
||||
}
|
||||
<h2 className="mt-3 mb-2">짱 위험한 곳</h2>
|
||||
<Button color="danger" onClick={this.allDeleteQuestions.bind(this)}>받았던 질문들을(이미 답변한거까지 포함해서!) 싹 다 날려버리기!!!</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
sendableForm() {
|
||||
return this.questionBoxNameRemaining() < 0 || this.descriptionRemaining() < 0 || this.state.saving
|
||||
}
|
||||
sendableForm()
|
||||
{
|
||||
return this.questionBoxNameRemaining() < 0 || this.descriptionRemaining() < 0 || this.state.saving;
|
||||
}
|
||||
|
||||
descriptionRemaining() {
|
||||
return this.state.descriptionMax - this.state.descriptionCount
|
||||
}
|
||||
descriptionRemaining()
|
||||
{
|
||||
return this.state.descriptionMax - this.state.descriptionCount;
|
||||
}
|
||||
|
||||
questionBoxNameRemaining() {
|
||||
return this.state.questionBoxNameMax - this.state.questionBoxNameCount
|
||||
}
|
||||
questionBoxNameRemaining()
|
||||
{
|
||||
return this.state.questionBoxNameMax - this.state.questionBoxNameCount;
|
||||
}
|
||||
|
||||
inputDescription(e: any) {
|
||||
this.setState({
|
||||
descriptionCount: e.target.value.length,
|
||||
})
|
||||
}
|
||||
inputDescription(e: any)
|
||||
{
|
||||
this.setState({
|
||||
descriptionCount: e.target.value.length
|
||||
});
|
||||
}
|
||||
|
||||
inputQuestionBoxName(e: any) {
|
||||
this.setState({
|
||||
questionBoxNameCount: e.target.value.length,
|
||||
})
|
||||
}
|
||||
inputQuestionBoxName(e: any)
|
||||
{
|
||||
this.setState({
|
||||
questionBoxNameCount: e.target.value.length
|
||||
});
|
||||
}
|
||||
|
||||
async pushbulletDisconnect() {
|
||||
function errorMsg(code: number | string) {
|
||||
return "통신에 실패했어요. 다시 시도해 주세요. (" + code + ")"
|
||||
}
|
||||
const req = await apiFetch("/api/web/accounts/pushbullet/disconnect", {
|
||||
method: "POST",
|
||||
}).catch((e) => {
|
||||
alert(errorMsg(-1))
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
alert(errorMsg("HTTP-" + req.status))
|
||||
return
|
||||
}
|
||||
async pushbulletDisconnect()
|
||||
{
|
||||
function errorMsg(code: number | string)
|
||||
{
|
||||
return '통신에 실패했어요. 다시 시도해 주세요. (' + code + ')';
|
||||
}
|
||||
const req = await apiFetch('/api/web/accounts/pushbullet/disconnect', {
|
||||
method: 'POST'
|
||||
}).catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-1));
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
alert(errorMsg('HTTP-' + req.status));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await req.json().catch((e) => {
|
||||
alert(errorMsg(-2))
|
||||
})
|
||||
if (!res) return
|
||||
const res = await req.json().catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-2));
|
||||
});
|
||||
if (!res) return;
|
||||
|
||||
alert("연결을 해제했어요.")
|
||||
location.reload()
|
||||
}
|
||||
alert('연결을 해제했어요.');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async allDeleteQuestions() {
|
||||
function errorMsg(code: number | string) {
|
||||
return "통신에 실패했어요. 다시 시도해 주세요. (" + code + ")"
|
||||
}
|
||||
if (!me) return
|
||||
const rand = Math.floor(Math.random() * 9) + 1
|
||||
if (prompt(`사용자님(@${me.acctDisplay})의 들어온 질문들을 (답변한 것까지 포함해서!) 몽땅 삭제할 거에요.
|
||||
async allDeleteQuestions()
|
||||
{
|
||||
function errorMsg(code: number | string)
|
||||
{
|
||||
return '통신에 실패했어요. 다시 시도해 주세요. (' + code + ')';
|
||||
}
|
||||
if (!me) return;
|
||||
const rand = Math.floor(Math.random() * 9) + 1;
|
||||
if (prompt(`사용자님(@${me.acctDisplay})의 들어온 질문들을 (답변한 것까지 포함해서!) 몽땅 삭제할 거에요.
|
||||
|
||||
확인을 위해 (${rand})을 아래쪽 칸에 입력해주세요.(숫자만 나와요)`, "") !== rand.toString()) return
|
||||
const req = await apiFetch("/api/web/questions/all_delete", {
|
||||
method: "POST",
|
||||
}).catch((e) => {
|
||||
alert(errorMsg(-1))
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
alert(errorMsg("HTTP-" + req.status))
|
||||
return
|
||||
}
|
||||
확인을 위해 (${rand})을 아래쪽 칸에 입력해주세요.(숫자만 나와요)`, '') !== rand.toString()) return;
|
||||
const req = await apiFetch('/api/web/questions/all_delete', {
|
||||
method: 'POST'
|
||||
}).catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-1));
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
alert(errorMsg('HTTP-' + req.status));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await req.json().catch((e) => {
|
||||
alert(errorMsg(-2))
|
||||
return
|
||||
})
|
||||
if (!res) return
|
||||
const res = await req.json().catch((e) =>
|
||||
{
|
||||
alert(errorMsg(-2));
|
||||
return;
|
||||
});
|
||||
if (!res) return;
|
||||
|
||||
alert("모두 삭제했어요.")
|
||||
location.reload()
|
||||
}
|
||||
alert('모두 삭제했어요.');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async onSubmit(e: any) {
|
||||
function errorMsg(code: number | string) {
|
||||
return "통신에 실패했어요. 다시 시도해 주세요. (" + code + ")"
|
||||
}
|
||||
this.setState({saving: true})
|
||||
async onSubmit(e: any)
|
||||
{
|
||||
function errorMsg(code: number | string)
|
||||
{
|
||||
return '통신에 실패했어요. 다시 시도해 주세요. (' + code + ')';
|
||||
}
|
||||
this.setState({saving: true});
|
||||
|
||||
const form = new FormData(e.target)
|
||||
const req = await apiFetch("/api/web/accounts/update", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}).catch(() => {
|
||||
alert(errorMsg(-1))
|
||||
this.setState({
|
||||
saving: false,
|
||||
})
|
||||
})
|
||||
if (!req) return
|
||||
if (!req.ok) {
|
||||
alert(errorMsg("HTTP-" + req.status))
|
||||
this.setState({
|
||||
saving: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
const form = new FormData(e.target);
|
||||
const req = await apiFetch('/api/web/accounts/update', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
}).catch(() =>
|
||||
{
|
||||
alert(errorMsg(-1));
|
||||
this.setState({
|
||||
saving: false
|
||||
});
|
||||
});
|
||||
if (!req) return;
|
||||
if (!req.ok)
|
||||
{
|
||||
alert(errorMsg('HTTP-' + req.status));
|
||||
this.setState({
|
||||
saving: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = req.json().catch(() => {
|
||||
alert(errorMsg(-2))
|
||||
this.setState({
|
||||
saving: false,
|
||||
})
|
||||
})
|
||||
if (!res) return
|
||||
const res = req.json().catch(() =>
|
||||
{
|
||||
alert(errorMsg(-2));
|
||||
this.setState({
|
||||
saving: false
|
||||
});
|
||||
});
|
||||
if (!res) return;
|
||||
|
||||
alert("저장했어요!")
|
||||
location.reload()
|
||||
}
|
||||
alert('저장했어요!');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,14 +1,16 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Title } from "../common/title"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Title } from '../common/title';
|
||||
|
||||
export class PageNotFound extends React.Component {
|
||||
render() {
|
||||
return <div>
|
||||
<Title>Not Found</Title>
|
||||
<h1>Not Found</h1>
|
||||
<p>페이지를 찾을 수 없어요.</p>
|
||||
<p><Link to="/">메인으로 돌아가기</Link></p>
|
||||
</div>
|
||||
}
|
||||
export class PageNotFound extends React.Component
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <div>
|
||||
<Title>Not Found</Title>
|
||||
<h1>Not Found</h1>
|
||||
<p>페이지를 찾을 수 없어요.</p>
|
||||
<p><Link to="/">메인으로 돌아가기</Link></p>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
@ -1,120 +1,127 @@
|
||||
import * as React from "react"
|
||||
import { Badge, Button, Input, Jumbotron } from "reactstrap"
|
||||
import { APIQuestion, APIUser } from "../../../../api-interfaces"
|
||||
import { QUESTION_TEXT_MAX_LENGTH } from "../../../../common/const"
|
||||
import { apiFetch } from "../../../api-fetch"
|
||||
import { me } from "../../../initial-state"
|
||||
import { Checkbox } from "../../common/checkbox"
|
||||
import { Title } from "../../common/title"
|
||||
import { Loading } from "../../loading"
|
||||
import { Question } from "../../question"
|
||||
import * as React from 'react';
|
||||
import { Badge, Button, Input, Jumbotron } from 'reactstrap';
|
||||
import { APIQuestion, APIUser } from '../../../../api-interfaces';
|
||||
import { QUESTION_TEXT_MAX_LENGTH } from '../../../../common/const';
|
||||
import { apiFetch } from '../../../api-fetch';
|
||||
import { me } from '../../../initial-state';
|
||||
import { Checkbox } from '../../common/checkbox';
|
||||
import { Title } from '../../common/title';
|
||||
import { Loading } from '../../loading';
|
||||
import { Question } from '../../question';
|
||||
|
||||
interface Props {
|
||||
userId: string
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
user: APIUser | undefined
|
||||
questions: APIQuestion[] | undefined
|
||||
questionLength: number
|
||||
questionNow: boolean
|
||||
user: APIUser | undefined;
|
||||
questions: APIQuestion[] | undefined;
|
||||
questionLength: number;
|
||||
questionNow: boolean;
|
||||
}
|
||||
|
||||
export class PageUserIndex extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
user: undefined,
|
||||
questions: undefined,
|
||||
questionLength: 0,
|
||||
questionNow: false,
|
||||
}
|
||||
}
|
||||
export class PageUserIndex extends React.Component<Props, State>
|
||||
{
|
||||
constructor(props: Props)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
user: undefined,
|
||||
questions: undefined,
|
||||
questionLength: 0,
|
||||
questionNow: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user } = this.state
|
||||
if (!user) return <Loading/>
|
||||
return <div>
|
||||
<Title>{user.name}님의 {user.questionBoxName || "질문함"}</Title>
|
||||
<Jumbotron><div style={{textAlign: "center"}}>
|
||||
<img src={user.avatarUrl} style={{maxWidth: "8em", height: "8em"}}/>
|
||||
<h1>{user.name}</h1>
|
||||
<p>
|
||||
님의 {user.questionBoxName || "질문함"}
|
||||
<a href={user.url || `https://${user.hostName}/@${user.acct.split("@")[0]}`}
|
||||
rel="nofollow">
|
||||
render()
|
||||
{
|
||||
const { user } = this.state;
|
||||
if (!user) return <Loading/>;
|
||||
return <div>
|
||||
<Title>{user.name}님의 {user.questionBoxName || '질문함'}</Title>
|
||||
<Jumbotron><div style={{textAlign: 'center'}}>
|
||||
<img src={user.avatarUrl} style={{maxWidth: '8em', height: '8em'}}/>
|
||||
<h1>{user.name}</h1>
|
||||
<p>
|
||||
님의 {user.questionBoxName || '질문함'}
|
||||
<a href={user.url || `https://${user.hostName}/@${user.acct.split('@')[0]}`}
|
||||
rel="nofollow">
|
||||
Mastodon 프로필
|
||||
</a>
|
||||
</p>
|
||||
<p>{user.description}</p>
|
||||
{ user.stopNewQuestion ? <p>지금은 더 이상 질문을 안 받고 있어요.</p> :
|
||||
<form action="javascript://" onSubmit={this.questionSubmit.bind(this)}>
|
||||
<Input type="textarea" name="question"
|
||||
placeholder="질문 내용을 입력해 주세요:"
|
||||
onInput={this.questionInput.bind(this)}
|
||||
/>
|
||||
<div className="d-flex mt-1">
|
||||
{me && !user.allAnon && <div className="p-1">
|
||||
<Checkbox name="noAnon" value="true">작성자 공개</Checkbox>
|
||||
</div>}
|
||||
<div className="ml-auto">
|
||||
<span className={"mr-3 " +
|
||||
(this.state.questionLength > QUESTION_TEXT_MAX_LENGTH ? "text-danger" : "")
|
||||
}>
|
||||
{QUESTION_TEXT_MAX_LENGTH - this.state.questionLength}
|
||||
</span>
|
||||
<Button color="primary" className="col-xs-2"
|
||||
disabled={
|
||||
!this.state.questionLength
|
||||
</a>
|
||||
</p>
|
||||
<p>{user.description}</p>
|
||||
{ user.stopNewQuestion ? <p>지금은 더 이상 질문을 안 받고 있어요.</p> :
|
||||
<form action="javascript://" onSubmit={this.questionSubmit.bind(this)}>
|
||||
<Input type="textarea" name="question"
|
||||
placeholder="질문 내용을 입력해 주세요:"
|
||||
onInput={this.questionInput.bind(this)}
|
||||
/>
|
||||
<div className="d-flex mt-1">
|
||||
{me && !user.allAnon && <div className="p-1">
|
||||
<Checkbox name="noAnon" value="true">작성자 공개</Checkbox>
|
||||
</div>}
|
||||
<div className="ml-auto">
|
||||
<span className={'mr-3 ' +
|
||||
(this.state.questionLength > QUESTION_TEXT_MAX_LENGTH ? 'text-danger' : '')
|
||||
}>
|
||||
{QUESTION_TEXT_MAX_LENGTH - this.state.questionLength}
|
||||
</span>
|
||||
<Button color="primary" className="col-xs-2"
|
||||
disabled={
|
||||
!this.state.questionLength
|
||||
|| this.state.questionLength > QUESTION_TEXT_MAX_LENGTH
|
||||
|| this.state.questionNow
|
||||
}>
|
||||
질문{this.state.questionNow ? "중..." : "하기"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div></Jumbotron>
|
||||
<h2>답변 {this.state.questions && <Badge pill>{this.state.questions.length}</Badge>}</h2>
|
||||
{this.state.questions
|
||||
? <div>
|
||||
{this.state.questions.map((question) =>
|
||||
<Question {...question} hideAnswerUser key={question._id}/>,
|
||||
)}
|
||||
</div>
|
||||
: <Loading />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
질문{this.state.questionNow ? '중...' : '하기'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div></Jumbotron>
|
||||
<h2>답변 {this.state.questions && <Badge pill>{this.state.questions.length}</Badge>}</h2>
|
||||
{this.state.questions
|
||||
? <div>
|
||||
{this.state.questions.map((question) =>
|
||||
<Question {...question} hideAnswerUser key={question._id}/>
|
||||
)}
|
||||
</div>
|
||||
: <Loading />
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
apiFetch("/api/web/accounts/" + this.props.userId)
|
||||
.then((r) => r.json())
|
||||
.then((user) => this.setState({user}))
|
||||
apiFetch("/api/web/accounts/" + this.props.userId + "/answers")
|
||||
.then((r) => r.json())
|
||||
.then((questions) => this.setState({questions}))
|
||||
}
|
||||
componentDidMount()
|
||||
{
|
||||
apiFetch('/api/web/accounts/' + this.props.userId)
|
||||
.then((r) => r.json())
|
||||
.then((user) => this.setState({user}));
|
||||
apiFetch('/api/web/accounts/' + this.props.userId + '/answers')
|
||||
.then((r) => r.json())
|
||||
.then((questions) => this.setState({questions}));
|
||||
}
|
||||
|
||||
questionSubmit(e: any) {
|
||||
if (!this.state.user) return
|
||||
this.setState({questionNow: true})
|
||||
const form = new FormData(e.target)
|
||||
apiFetch("/api/web/accounts/" + this.state.user.acct + "/question", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}).then((r) => r.json()).then((r) => {
|
||||
this.setState({questionNow: false})
|
||||
alert("질문을 보냈어요!")
|
||||
location.reload()
|
||||
})
|
||||
}
|
||||
questionSubmit(e: any)
|
||||
{
|
||||
if (!this.state.user) return;
|
||||
this.setState({questionNow: true});
|
||||
const form = new FormData(e.target);
|
||||
apiFetch('/api/web/accounts/' + this.state.user.acct + '/question', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
}).then((r) => r.json()).then((r) =>
|
||||
{
|
||||
this.setState({questionNow: false});
|
||||
alert('질문을 보냈어요!');
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
questionInput(e: any) {
|
||||
const count = e.target.value.length
|
||||
this.setState({
|
||||
questionLength: count,
|
||||
})
|
||||
}
|
||||
questionInput(e: any)
|
||||
{
|
||||
const count = e.target.value.length;
|
||||
this.setState({
|
||||
questionLength: count
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,39 +1,43 @@
|
||||
import * as React from "react"
|
||||
import { APIQuestion } from "../../../../api-interfaces"
|
||||
import { apiFetch } from "../../../api-fetch"
|
||||
import { Title } from "../../common/title"
|
||||
import { Loading } from "../../loading"
|
||||
import { Question } from "../../question"
|
||||
import * as React from 'react';
|
||||
import { APIQuestion } from '../../../../api-interfaces';
|
||||
import { apiFetch } from '../../../api-fetch';
|
||||
import { Title } from '../../common/title';
|
||||
import { Loading } from '../../loading';
|
||||
import { Question } from '../../question';
|
||||
|
||||
interface Props {
|
||||
match: {
|
||||
params: {[key: string]: string},
|
||||
}
|
||||
params: {[key: string]: string};
|
||||
};
|
||||
}
|
||||
|
||||
interface State {
|
||||
question: APIQuestion | undefined
|
||||
question: APIQuestion | undefined;
|
||||
}
|
||||
|
||||
export class PageUserQuestion extends React.Component<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {
|
||||
question: undefined,
|
||||
}
|
||||
}
|
||||
export class PageUserQuestion extends React.Component<Props, State>
|
||||
{
|
||||
constructor(props: any)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
question: undefined
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
apiFetch("/api/web/questions/" + this.props.match.params.question_id)
|
||||
.then((r) => r.json())
|
||||
.then((question) => this.setState({question}))
|
||||
}
|
||||
componentDidMount()
|
||||
{
|
||||
apiFetch('/api/web/questions/' + this.props.match.params.question_id)
|
||||
.then((r) => r.json())
|
||||
.then((question) => this.setState({question}));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.question) return <Loading/>
|
||||
return <div>
|
||||
<Title>{this.state.question.user.name}님이 보낸 질문: "{this.state.question.question}"</Title>
|
||||
<Question {...this.state.question} noNsfwGuard/>
|
||||
</div>
|
||||
}
|
||||
render()
|
||||
{
|
||||
if (!this.state.question) return <Loading/>;
|
||||
return <div>
|
||||
<Title>{this.state.question.user.name}님이 보낸 질문: "{this.state.question.question}"</Title>
|
||||
<Question {...this.state.question} noNsfwGuard/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
@ -1,43 +1,50 @@
|
||||
import * as React from "react"
|
||||
import Badge from "reactstrap/lib/Badge"
|
||||
import { apiFetch } from "../api-fetch"
|
||||
import * as React from 'react';
|
||||
import Badge from 'reactstrap/lib/Badge';
|
||||
import { apiFetch } from '../api-fetch';
|
||||
|
||||
interface State {
|
||||
count: number
|
||||
timer?: number | undefined
|
||||
count: number;
|
||||
timer?: number | undefined;
|
||||
}
|
||||
|
||||
export class QuestionRemaining extends React.Component<{}, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
export class QuestionRemaining extends React.Component<{}, State>
|
||||
{
|
||||
constructor(props: any)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.count) return null
|
||||
return <Badge className="ml-2" pill color="secondary">{this.state.count}</Badge>
|
||||
}
|
||||
render()
|
||||
{
|
||||
if (!this.state.count) return null;
|
||||
return <Badge className="ml-2" pill color="secondary">{this.state.count}</Badge>;
|
||||
}
|
||||
|
||||
updateCount() {
|
||||
apiFetch("/api/web/questions/count")
|
||||
.then((r) => r.json())
|
||||
.then((r) => this.setState({count: r.count}))
|
||||
}
|
||||
updateCount()
|
||||
{
|
||||
apiFetch('/api/web/questions/count')
|
||||
.then((r) => r.json())
|
||||
.then((r) => this.setState({count: r.count}));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateCount()
|
||||
this.setState({
|
||||
timer: window.setInterval(() => {
|
||||
this.updateCount()
|
||||
}, 1 * 60 * 1000),
|
||||
})
|
||||
}
|
||||
componentDidMount()
|
||||
{
|
||||
this.updateCount();
|
||||
this.setState({
|
||||
timer: window.setInterval(() =>
|
||||
{
|
||||
this.updateCount();
|
||||
}, 1 * 60 * 1000)
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (!this.state.timer) return
|
||||
clearInterval(this.state.timer)
|
||||
this.setState({timer: undefined})
|
||||
}
|
||||
componentWillUnmount()
|
||||
{
|
||||
if (!this.state.timer) return;
|
||||
clearInterval(this.state.timer);
|
||||
this.setState({timer: undefined});
|
||||
}
|
||||
}
|
||||
|
@ -1,119 +1,132 @@
|
||||
import * as moment from "moment"
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Button, Card, CardBody, CardSubtitle, CardText, CardTitle, FormGroup, Input } from "reactstrap"
|
||||
import { APIQuestion } from "../../api-interfaces"
|
||||
import { apiFetch } from "../api-fetch"
|
||||
import { Checkbox } from "./common/checkbox"
|
||||
import { UserLink } from "./userLink"
|
||||
import moment from 'moment';
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Card, CardBody, CardSubtitle, CardText, CardTitle, FormGroup, Input } from 'reactstrap';
|
||||
import { APIQuestion } from '../../api-interfaces';
|
||||
import { apiFetch } from '../api-fetch';
|
||||
import { Checkbox } from './common/checkbox';
|
||||
import { UserLink } from './userLink';
|
||||
|
||||
interface Props extends APIQuestion {
|
||||
hideAnswerUser?: boolean | undefined
|
||||
noNsfwGuard?: boolean | undefined
|
||||
hideAnswerUser?: boolean | undefined;
|
||||
noNsfwGuard?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isNotEmpty: boolean
|
||||
nsfwGuard: boolean
|
||||
isNotEmpty: boolean;
|
||||
nsfwGuard: boolean;
|
||||
}
|
||||
|
||||
export class Question extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isNotEmpty: false,
|
||||
nsfwGuard: this.props.isNSFW && !this.props.noNsfwGuard,
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return <Card className="mb-3">
|
||||
<CardBody className={this.state.nsfwGuard ? "nsfw-blur" : ""}>
|
||||
<CardTitle tag="h4">{this.props.question}</CardTitle>
|
||||
<CardSubtitle className="mb-2">
|
||||
{this.renderAnswerUser()}
|
||||
{this.props.answeredAt && <Link
|
||||
to={`/@${this.props.user.acct}/questions/${this.props._id}`}
|
||||
className="text-muted mr-2">
|
||||
{moment(this.props.answeredAt).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</Link>}
|
||||
{this.renderQuestionUser()}
|
||||
</CardSubtitle>
|
||||
{this.props.answeredAt ? this.renderAnswer() : this.renderAnswerForm()}
|
||||
</CardBody>
|
||||
{ this.state.nsfwGuard && <div className="nsfw-guard" onClick={this.nsfwGuardClick.bind(this)}>
|
||||
<div>
|
||||
<div>열람 주의</div>
|
||||
{ !this.props.hideAnswerUser && <div>답변자: @{this.props.user.acctDisplay}</div>}
|
||||
<div>눌러서 표시</div>
|
||||
</div>
|
||||
</div> }
|
||||
</Card>
|
||||
}
|
||||
export class Question extends React.Component<Props, State>
|
||||
{
|
||||
constructor(props: Props)
|
||||
{
|
||||
super(props);
|
||||
this.state = {
|
||||
isNotEmpty: false,
|
||||
nsfwGuard: this.props.isNSFW && !this.props.noNsfwGuard
|
||||
};
|
||||
}
|
||||
render()
|
||||
{
|
||||
return <Card className="mb-3">
|
||||
<CardBody className={this.state.nsfwGuard ? 'nsfw-blur' : ''}>
|
||||
<CardTitle tag="h4">{this.props.question}</CardTitle>
|
||||
<CardSubtitle className="mb-2">
|
||||
{this.renderAnswerUser()}
|
||||
{this.props.answeredAt && <Link
|
||||
to={`/@${this.props.user.acct}/questions/${this.props._id}`}
|
||||
className="text-muted mr-2">
|
||||
{moment(this.props.answeredAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Link>}
|
||||
{this.renderQuestionUser()}
|
||||
</CardSubtitle>
|
||||
{this.props.answeredAt ? this.renderAnswer() : this.renderAnswerForm()}
|
||||
</CardBody>
|
||||
{ this.state.nsfwGuard && <div className="nsfw-guard" onClick={this.nsfwGuardClick.bind(this)}>
|
||||
<div>
|
||||
<div>열람 주의</div>
|
||||
{ !this.props.hideAnswerUser && <div>답변자: @{this.props.user.acctDisplay}</div>}
|
||||
<div>눌러서 표시</div>
|
||||
</div>
|
||||
</div> }
|
||||
</Card>;
|
||||
}
|
||||
|
||||
renderAnswerUser() {
|
||||
if (this.props.hideAnswerUser) return null
|
||||
return <span className="mr-2">
|
||||
renderAnswerUser()
|
||||
{
|
||||
if (this.props.hideAnswerUser) return null;
|
||||
return <span className="mr-2">
|
||||
답변자:
|
||||
<UserLink {...this.props.user}/>
|
||||
</span>
|
||||
}
|
||||
<UserLink {...this.props.user}/>
|
||||
</span>;
|
||||
}
|
||||
|
||||
renderQuestionUser() {
|
||||
if (!this.props.questionUser) return null
|
||||
return <span className="mr-2">
|
||||
renderQuestionUser()
|
||||
{
|
||||
if (!this.props.questionUser) return null;
|
||||
return <span className="mr-2">
|
||||
질문자:
|
||||
<UserLink {...this.props.questionUser}/>
|
||||
</span>
|
||||
}
|
||||
<UserLink {...this.props.questionUser}/>
|
||||
</span>;
|
||||
}
|
||||
|
||||
renderAnswer() {
|
||||
return <CardText className="question-text">{this.props.answer}</CardText>
|
||||
}
|
||||
renderAnswer()
|
||||
{
|
||||
return <CardText className="question-text">{this.props.answer}</CardText>;
|
||||
}
|
||||
|
||||
renderAnswerForm() {
|
||||
return <form action="javascript://" onSubmit={this.onSubmit.bind(this)}>
|
||||
<FormGroup>
|
||||
<Input type="textarea" name="answer" placeholder="답변 내용을 입력해 주세요:" onInput={this.onInput.bind(this)}/>
|
||||
</FormGroup>
|
||||
<Button type="submit" color="primary" disabled={!this.state.isNotEmpty}>답변</Button>
|
||||
<span className="ml-3">공개 범위: </span>
|
||||
<Input type="select" name="visibility" style={{width: "inherit", display: "inline-block"}}>
|
||||
<option value="public">공개</option>
|
||||
<option value="unlisted">타임라인에 비표시</option>
|
||||
<option value="private">비공개</option>
|
||||
<option value="no">안 올릴 건데요!</option>
|
||||
</Input>
|
||||
<Checkbox name="isNSFW" value="true" className="ml-2">NSFW</Checkbox>
|
||||
<Button type="button" color="danger" style={{float: "right"}} onClick={this.onDelete.bind(this)}>삭제</Button>
|
||||
</form>
|
||||
}
|
||||
renderAnswerForm()
|
||||
{
|
||||
return <form action="javascript://" onSubmit={this.onSubmit.bind(this)}>
|
||||
<FormGroup>
|
||||
<Input type="textarea" name="answer" placeholder="답변 내용을 입력해 주세요:" onInput={this.onInput.bind(this)}/>
|
||||
</FormGroup>
|
||||
<Button type="submit" color="primary" disabled={!this.state.isNotEmpty}>답변</Button>
|
||||
<span className="ml-3">공개 범위: </span>
|
||||
<Input type="select" name="visibility" style={{width: 'inherit', display: 'inline-block'}}>
|
||||
<option value="public">공개</option>
|
||||
<option value="unlisted">타임라인에 비표시</option>
|
||||
<option value="private">비공개</option>
|
||||
<option value="no">안 올릴 건데요!</option>
|
||||
</Input>
|
||||
<Checkbox name="isNSFW" value="true" className="ml-2">NSFW</Checkbox>
|
||||
<Button type="button" color="danger" style={{float: 'right'}} onClick={this.onDelete.bind(this)}>삭제</Button>
|
||||
</form>;
|
||||
}
|
||||
|
||||
onInput(e: any) {
|
||||
this.setState({isNotEmpty: e.target.value !== ""})
|
||||
}
|
||||
onInput(e: any)
|
||||
{
|
||||
this.setState({isNotEmpty: e.target.value !== ''});
|
||||
}
|
||||
|
||||
onSubmit(e: any) {
|
||||
const form = new FormData(e.target)
|
||||
apiFetch("/api/web/questions/" + this.props._id + "/answer", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}).then((r) => r.json()).then((r) => {
|
||||
alert("답변했어요!")
|
||||
location.reload()
|
||||
})
|
||||
}
|
||||
onSubmit(e: any)
|
||||
{
|
||||
const form = new FormData(e.target);
|
||||
apiFetch('/api/web/questions/' + this.props._id + '/answer', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
}).then((r) => r.json()).then((r) =>
|
||||
{
|
||||
alert('답변했어요!');
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(e: any) {
|
||||
if (!confirm("질문을 삭제하려고요?\n삭제한 질문은 다시 되돌릴 수 없어요.\n정말로 삭제하실 건가요?")) return
|
||||
apiFetch("/api/web/questions/" + this.props._id + "/delete", {
|
||||
method: "POST",
|
||||
}).then((r) => r.json()).then((r) => {
|
||||
alert("삭제했어요.")
|
||||
location.reload()
|
||||
})
|
||||
}
|
||||
onDelete(e: any)
|
||||
{
|
||||
if (!confirm('질문을 삭제하려고요?\n삭제한 질문은 다시 되돌릴 수 없어요.\n정말로 삭제하실 건가요?')) return;
|
||||
apiFetch('/api/web/questions/' + this.props._id + '/delete', {
|
||||
method: 'POST'
|
||||
}).then((r) => r.json()).then((r) =>
|
||||
{
|
||||
alert('삭제했어요.');
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
nsfwGuardClick() {
|
||||
this.setState({nsfwGuard: false})
|
||||
}
|
||||
nsfwGuardClick()
|
||||
{
|
||||
this.setState({nsfwGuard: false});
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { APIUser } from "../../api-interfaces"
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { APIUser } from '../../api-interfaces';
|
||||
|
||||
// tslint:disable-next-line:no-empty-interface
|
||||
interface Props extends APIUser {
|
||||
}
|
||||
|
||||
export class UserLink extends React.Component<Props> {
|
||||
render() {
|
||||
return <Link to={`/@${this.props.acct}`}>
|
||||
{this.props.name}
|
||||
<span className="text-muted"> @{this.props.acctDisplay}</span>
|
||||
</Link>
|
||||
}
|
||||
export class UserLink extends React.Component<Props>
|
||||
{
|
||||
render()
|
||||
{
|
||||
return <Link to={`/@${this.props.acct}`}>
|
||||
{this.props.name}
|
||||
<span className="text-muted"> @{this.props.acctDisplay}</span>
|
||||
</Link>;
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { APIUser } from "../api-interfaces"
|
||||
import { APIUser } from '../api-interfaces';
|
||||
|
||||
const w = window as any
|
||||
const w = window as any;
|
||||
|
||||
export const me: APIUser | undefined = w.USER
|
||||
export const csrfToken: string = w.CSRF_TOKEN
|
||||
export const me: APIUser | undefined = w.USER;
|
||||
export const csrfToken: string = w.CSRF_TOKEN;
|
||||
|
||||
export const usingDarkTheme: boolean = !!localStorage.getItem("using-dark-theme")
|
||||
export const usingDarkTheme = !!localStorage.getItem('using-dark-theme');
|
||||
|
||||
export const upstreamUrl = "https://github.com/byulmaru/quesdon"
|
||||
export const gitVersion = w.GIT_VERSION
|
||||
export const upstreamUrl = 'https://github.com/byulmaru/quesdon';
|
||||
export const gitVersion = w.GIT_VERSION;
|
||||
|
@ -1,3 +1,4 @@
|
||||
export default function josa(str: string, yesjong: string, nojong: string): string {
|
||||
return (str.charCodeAt(str.length - 1) - 0xac00) % 28 > 0 ? str + yesjong : str + nojong
|
||||
export default function josa(str: string, yesjong: string, nojong: string): string
|
||||
{
|
||||
return (str.charCodeAt(str.length - 1) - 0xac00) % 28 > 0 ? str + yesjong : str + nojong;
|
||||
}
|
||||
|
@ -1,13 +1,17 @@
|
||||
export default [
|
||||
"planet.moe",
|
||||
"twingyeo.kr",
|
||||
"qdon.space",
|
||||
"mastodon.social",
|
||||
"niu.moe",
|
||||
"mstdn.jp",
|
||||
"friends.nico",
|
||||
"pawoo.net",
|
||||
"music.pawoo.net",
|
||||
"imastodon.net",
|
||||
"mstdn.maud.io",
|
||||
]
|
||||
'planet.moe',
|
||||
'madost.one',
|
||||
'twingyeo.kr',
|
||||
'qdon.space',
|
||||
'fedimas.com',
|
||||
'msky.naru.cafe',
|
||||
'jmm.kr',
|
||||
'mastodon.social',
|
||||
'niu.moe',
|
||||
'mstdn.jp',
|
||||
'friends.nico',
|
||||
'pawoo.net',
|
||||
'music.pawoo.net',
|
||||
'imastodon.net',
|
||||
'mstdn.maud.io'
|
||||
];
|
||||
|
@ -1 +1 @@
|
||||
export const QUESTION_TEXT_MAX_LENGTH = 200
|
||||
export const QUESTION_TEXT_MAX_LENGTH = 200;
|
||||
|
@ -1,8 +1,8 @@
|
||||
import * as Router from "koa-router"
|
||||
import webRouter from "./web"
|
||||
import Router from 'koa-router';
|
||||
import webRouter from './web';
|
||||
|
||||
const router = new Router()
|
||||
const router = new Router();
|
||||
|
||||
router.use("/web", webRouter.routes())
|
||||
router.use('/web', webRouter.routes());
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
@ -1,191 +1,298 @@
|
||||
import * as Koa from "koa"
|
||||
import * as Router from "koa-router"
|
||||
import * as mongoose from "mongoose"
|
||||
import fetch from "node-fetch"
|
||||
import * as parseLinkHeader from "parse-link-header"
|
||||
import { Link, Links } from "parse-link-header"
|
||||
import { QUESTION_TEXT_MAX_LENGTH } from "../../../common/const"
|
||||
import { BASE_URL, NOTICE_ACCESS_TOKEN, PUSHBULLET_CLIENT_ID, PUSHBULLET_CLIENT_SECRET } from "../../config"
|
||||
import { Question, User } from "../../db/index"
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import fetch from 'node-fetch';
|
||||
import parseLinkHeader, { Link, Links } from 'parse-link-header';
|
||||
import { Account } from 'megalodon';
|
||||
import { User as MisskeyUser } from '../../utils/misskey_entities/user';
|
||||
import { Following } from '../../utils/misskey_entities/following';
|
||||
import { oneLineTrim, stripIndents } from 'common-tags';
|
||||
import { QUESTION_TEXT_MAX_LENGTH } from '../../../common/const';
|
||||
import { BASE_URL, NOTICE_ACCESS_TOKEN, PUSHBULLET_CLIENT_ID, PUSHBULLET_CLIENT_SECRET } from '../../config';
|
||||
import { Question, User } from '../../db/index';
|
||||
import detectInstance from '../../utils/detectInstance';
|
||||
|
||||
const router = new Router()
|
||||
const router = new Router();
|
||||
|
||||
router.get("/verify_credentials", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
ctx.body = user
|
||||
})
|
||||
router.get('/verify_credentials', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
router.get("/followers", async (ctx) => {
|
||||
if (null == /^\d+$/.exec(ctx.query.max_id || "0")) return ctx.throw("max_id is num only", 400)
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
if (user.hostName === "twitter.com") {
|
||||
return {max_id: undefined, accounts: []}
|
||||
}
|
||||
const instanceUrl = "https://" + user!.acct.split("@")[1]
|
||||
const myInfo = await fetch(instanceUrl + "/api/v1/accounts/verify_credentials", {
|
||||
headers: {
|
||||
Authorization: "Bearer " + user!.accessToken,
|
||||
},
|
||||
}).then((r) => r.json())
|
||||
const param = ctx.query.max_id ? "&max_id=" + ctx.query.max_id : ""
|
||||
const followersRes = await fetch(
|
||||
`${instanceUrl}/api/v1/accounts/${myInfo.id}/followers?limit=80${param}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer " + user!.accessToken,
|
||||
},
|
||||
},
|
||||
)
|
||||
var followers: any[] = await followersRes.json()
|
||||
followers = followers
|
||||
.map((follower) => follower.acct as string)
|
||||
.map((acct) => acct.includes("@") ? acct : (acct + "@" + user!.acct.split("@")[1]))
|
||||
.map((acct) => acct.toLowerCase())
|
||||
const followersObject = await User.find({acctLower: {$in: followers}})
|
||||
const max_id = ((parseLinkHeader(followersRes.headers.get("Link")!) || {} as Links).next || {} as Link).max_id
|
||||
ctx.body = {
|
||||
accounts: followersObject,
|
||||
max_id,
|
||||
}
|
||||
})
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
router.post("/update", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
user.description = ctx.request.body.fields.description
|
||||
user.questionBoxName = ctx.request.body.fields.questionBoxName
|
||||
user.allAnon = !!ctx.request.body.fields.allAnon
|
||||
user.stopNewQuestion = !!ctx.request.body.fields.stopNewQuestion
|
||||
await user.save()
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
ctx.body = user;
|
||||
});
|
||||
|
||||
router.get("/id/:id", async (ctx) => {
|
||||
const user = await User.findById(ctx.params.id)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
ctx.body = user
|
||||
})
|
||||
router.get('/followers', async (ctx: Koa.ParameterizedContext): Promise<never|void|{}> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
router.get("/pushbullet/redirect", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
ctx.redirect("https://www.pushbullet.com/authorize"
|
||||
+ "?client_id=" + PUSHBULLET_CLIENT_ID
|
||||
+ "&redirect_uri=" + encodeURIComponent(BASE_URL + "/api/web/accounts/pushbullet/callback")
|
||||
+ "&response_type=code"
|
||||
+ "&scope=everything",
|
||||
)
|
||||
})
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
router.get("/pushbullet/callback", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
const res = await fetch("https://api.pushbullet.com/oauth2/token", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_id: PUSHBULLET_CLIENT_ID,
|
||||
client_secret: PUSHBULLET_CLIENT_SECRET,
|
||||
code: ctx.query.code,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then((r) => r.json())
|
||||
if (res.error) {
|
||||
return ctx.throw(500, "pushbullet error: " + res.error.message)
|
||||
}
|
||||
user.pushbulletAccessToken = res.access_token
|
||||
await user.save()
|
||||
ctx.redirect("/my/settings")
|
||||
})
|
||||
// twitter
|
||||
if (user.hostName === 'twitter.com')
|
||||
return ctx.body = { max_id: undefined, accounts: [] };
|
||||
|
||||
router.post("/pushbullet/disconnect", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
user.pushbulletAccessToken = null
|
||||
await user.save()
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
const instanceUrl = 'https://' + user.acct.split('@')[1];
|
||||
const instanceType = await detectInstance(instanceUrl);
|
||||
|
||||
router.get("/:acct", async (ctx) => {
|
||||
const user = await User.findOne({acctLower: ctx.params.acct.toLowerCase()})
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
ctx.body = user
|
||||
})
|
||||
if (instanceType === 'misskey')
|
||||
{
|
||||
// misskey
|
||||
const fetchOptions =
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
};
|
||||
|
||||
router.post("/:acct/question", async (ctx) => {
|
||||
const questionString = ctx.request.body.fields.question
|
||||
if (questionString.length < 1) return ctx.throw("please input question", 400)
|
||||
if (questionString.length > QUESTION_TEXT_MAX_LENGTH) return ctx.throw("too long", 400)
|
||||
const user = await User.findOne({acctLower: ctx.params.acct.toLowerCase()})
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
if (user.stopNewQuestion) return ctx.throw(400, "this user has stopped new question submit")
|
||||
const question = new Question()
|
||||
question.question = questionString
|
||||
question.user = user
|
||||
if (ctx.request.body.fields.noAnon) {
|
||||
if (user.allAnon) return ctx.throw("all anon", 400)
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const questionUser = await User.findById(ctx.session!.user)
|
||||
if (!questionUser) return ctx.throw("not found", 404)
|
||||
question.questionUser = questionUser
|
||||
}
|
||||
await question.save()
|
||||
ctx.body = {status: "ok"}
|
||||
if (user.pushbulletAccessToken) {
|
||||
fetch("https://api.pushbullet.com/v2/pushes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "link",
|
||||
body: "새로운 질문이에요!\nQ. " + question.question,
|
||||
url: BASE_URL + "/my/questions",
|
||||
}),
|
||||
headers: {
|
||||
"Access-Token": user.pushbulletAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (NOTICE_ACCESS_TOKEN) {
|
||||
fetch("https://planet.moe/api/v1/statuses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
status: "@" + user.acct + " Quesdon@Planet - 새로운 질문이에요!\nQ. " + question.question
|
||||
+ "\n" + BASE_URL + "/my/questions",
|
||||
visibility: "direct",
|
||||
}),
|
||||
headers: {
|
||||
"Authorization": "Bearer " + NOTICE_ACCESS_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
const myInfo: MisskeyUser = await fetch(`${instanceUrl}/api/i`,
|
||||
Object.assign({}, fetchOptions,
|
||||
{
|
||||
body: JSON.stringify( { i: user.accessToken })
|
||||
})).then(r => r.json());
|
||||
const body: { i: string; userId: string; limit: number; untilId?: string } =
|
||||
{
|
||||
i: user.accessToken,
|
||||
userId: myInfo.id,
|
||||
limit: 80
|
||||
};
|
||||
if (ctx.query.max_id)
|
||||
body.untilId = ctx.query.max_id;
|
||||
const followersRaw: Following[] = await fetch(`${instanceUrl}/api/users/followers`,
|
||||
Object.assign({}, fetchOptions, { body: body })).then(r => r.json());
|
||||
const followers = followersRaw
|
||||
.map(follower => `${follower.follower?.username}@${follower.follower?.host ?? user.acct.split('@')[1]}`.toLowerCase());
|
||||
const followersObject = await User.find({acctLower: {$in: followers}});
|
||||
const max_id = followersRaw[followersRaw.length - 1]?.id ?? '';
|
||||
return ctx.body =
|
||||
{
|
||||
accounts: followersObject,
|
||||
max_id
|
||||
};
|
||||
}
|
||||
|
||||
// mastodon
|
||||
const myInfo = await fetch(`${instanceUrl}/api/v1/accounts/verify_credentials`,
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + user.accessToken }
|
||||
}).then((r) => r.json());
|
||||
const param = ctx.query.max_id ? '&max_id=' + ctx.query.max_id : '';
|
||||
const followersRes = await fetch(`${instanceUrl}/api/v1/accounts/${myInfo.id}/followers?limit=80${param}`,
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + user.accessToken }
|
||||
});
|
||||
const followersRaw: Account[] = await followersRes.json();
|
||||
const followers = followersRaw
|
||||
.map((follower) => follower.acct)
|
||||
.map((acct) => acct.includes('@') ? acct : (acct + '@' + user.acct.split('@')[1]))
|
||||
.map((acct) => acct.toLowerCase()); // create a string[] of followers in 'lowercase@host.name' form
|
||||
|
||||
const getAnswers = async (ctx: Koa.Context) => {
|
||||
const user = await User.findOne({acctLower: ctx.params.acct.toLowerCase()})
|
||||
if (!user) return ctx.throw("not found", 404)
|
||||
const questions = await Question.find({
|
||||
user,
|
||||
answeredAt: {$ne: null},
|
||||
isDeleted: {$ne: true},
|
||||
}).sort("-answeredAt")
|
||||
ctx.body = questions.map((question) => {
|
||||
question.user = user
|
||||
return question
|
||||
})
|
||||
}
|
||||
const followersObject = await User.find({acctLower: {$in: followers}});
|
||||
const max_id = ((parseLinkHeader(followersRes.headers.get('Link') ?? '') || {} as Links).next || {} as Link).max_id;
|
||||
return ctx.body =
|
||||
{
|
||||
accounts: followersObject,
|
||||
max_id
|
||||
};
|
||||
});
|
||||
|
||||
router.get("/:acct/questions", getAnswers)
|
||||
router.get("/:acct/answers", getAnswers)
|
||||
router.post('/update', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
export default router
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
user.description = ctx.request.body.description;
|
||||
user.questionBoxName = ctx.request.body.questionBoxName;
|
||||
user.allAnon = !!ctx.request.body.allAnon;
|
||||
user.stopNewQuestion = !!ctx.request.body.stopNewQuestion;
|
||||
await user.save();
|
||||
ctx.body = {status: 'ok'};
|
||||
});
|
||||
|
||||
router.get('/id/:id', async (ctx): Promise<never|void> =>
|
||||
{
|
||||
const user = await User.findById(ctx.params.id);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
ctx.body = user;
|
||||
});
|
||||
|
||||
router.get('/pushbullet/redirect', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
ctx.redirect(oneLineTrim`https://www.pushbullet.com/authorize
|
||||
?client_id=${PUSHBULLET_CLIENT_ID}
|
||||
&redirect_uri=${encodeURIComponent(BASE_URL + '/api/web/accounts/pushbullet/callback')}
|
||||
&response_type=code
|
||||
&scope=everything`
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/pushbullet/callback', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
const res = await fetch('https://api.pushbullet.com/oauth2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
{
|
||||
client_id: PUSHBULLET_CLIENT_ID,
|
||||
client_secret: PUSHBULLET_CLIENT_SECRET,
|
||||
code: ctx.query.code,
|
||||
grant_type: 'authorization_code'
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then((r) => r.json());
|
||||
if (res.error)
|
||||
return ctx.throw(500, 'pushbullet error: ' + res.error.message);
|
||||
|
||||
user.pushbulletAccessToken = res.access_token;
|
||||
await user.save();
|
||||
|
||||
ctx.redirect('/my/settings');
|
||||
});
|
||||
|
||||
router.post('/pushbullet/disconnect', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
user.pushbulletAccessToken = null;
|
||||
await user.save();
|
||||
|
||||
ctx.body = {status: 'ok'};
|
||||
});
|
||||
|
||||
router.get('/:acct', async (ctx): Promise<never|void> =>
|
||||
{
|
||||
const user = await User.findOne({acctLower: ctx.params.acct.toLowerCase()});
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
ctx.body = user;
|
||||
});
|
||||
|
||||
router.post('/:acct/question', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
const questionString = ctx.request.body.question;
|
||||
|
||||
if (questionString.length < 1)
|
||||
return ctx.throw('please input question', 400);
|
||||
if (questionString.length > QUESTION_TEXT_MAX_LENGTH)
|
||||
return ctx.throw('too long', 400);
|
||||
|
||||
const user = await User.findOne({acctLower: ctx.params.acct.toLowerCase()});
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
if (user.stopNewQuestion)
|
||||
return ctx.throw(400, 'this user has stopped new question submit');
|
||||
|
||||
const question = new Question();
|
||||
question.question = questionString;
|
||||
question.user = user;
|
||||
if (ctx.request.body.noAnon)
|
||||
{
|
||||
if (user.allAnon)
|
||||
return ctx.throw('all anon', 400);
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const questionUser = await User.findById(ctx.session.user);
|
||||
if (!questionUser)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
question.questionUser = questionUser;
|
||||
}
|
||||
await question.save();
|
||||
|
||||
ctx.body = {status: 'ok'};
|
||||
|
||||
if (user.pushbulletAccessToken)
|
||||
{
|
||||
fetch('https://api.pushbullet.com/v2/pushes',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
{
|
||||
type: 'link',
|
||||
body: stripIndents`새로운 질문이에요!
|
||||
Q. ${question.question}`,
|
||||
url: BASE_URL + '/my/questions'
|
||||
}),
|
||||
headers:
|
||||
{
|
||||
'Access-Token': user.pushbulletAccessToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
if (NOTICE_ACCESS_TOKEN)
|
||||
{
|
||||
fetch('https://planet.moe/api/v1/statuses',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
{
|
||||
status: stripIndents`@${user.acct} Quesdon@Planet - 새로운 질문이에요!
|
||||
Q. ${question.question}
|
||||
${BASE_URL}/my/questions`,
|
||||
visibility: 'direct'
|
||||
}),
|
||||
headers:
|
||||
{
|
||||
'Authorization': 'Bearer ' + NOTICE_ACCESS_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const getAnswers = async (ctx: Koa.ParameterizedContext): Promise<void> =>
|
||||
{
|
||||
const user = await User.findOne({acctLower: ctx.params.acct.toLowerCase()});
|
||||
if (!user)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
const questions = await Question.find(
|
||||
{
|
||||
user,
|
||||
answeredAt: {$ne: null},
|
||||
isDeleted: {$ne: true}
|
||||
}).sort('-answeredAt');
|
||||
|
||||
ctx.body = questions.map((question) =>
|
||||
{
|
||||
question.user = user;
|
||||
return question;
|
||||
});
|
||||
};
|
||||
|
||||
router.get('/:acct/questions', getAnswers);
|
||||
router.get('/:acct/answers', getAnswers);
|
||||
|
||||
export default router;
|
||||
|
@ -1,24 +1,28 @@
|
||||
import * as Router from "koa-router"
|
||||
import accountsRouter from "./accounts"
|
||||
import oauthRouter from "./oauth"
|
||||
import questionsRouter from "./questions"
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import accountsRouter from './accounts';
|
||||
import oauthRouter from './oauth';
|
||||
import questionsRouter from './questions';
|
||||
|
||||
const router = new Router()
|
||||
const router = new Router();
|
||||
|
||||
router.use(async (ctx, next) => {
|
||||
if (ctx.request.method !== "GET") {
|
||||
if (ctx.session!.csrfToken !== ctx.request.headers["x-csrf-token"]) return ctx.throw("invalid csrf token", 403)
|
||||
}
|
||||
await next()
|
||||
})
|
||||
router.use(async (ctx: Koa.ParameterizedContext, next): Promise<void> =>
|
||||
{
|
||||
if (ctx.request.method !== 'GET')
|
||||
if (ctx.session.csrfToken !== ctx.request.headers['x-csrf-token'])
|
||||
return ctx.throw('invalid csrf token', 403);
|
||||
|
||||
router.use("/oauth", oauthRouter.routes())
|
||||
router.use("/accounts", accountsRouter.routes())
|
||||
router.use("/questions", questionsRouter.routes())
|
||||
await next();
|
||||
});
|
||||
|
||||
router.get("/logout", async (ctx) => {
|
||||
ctx.session!.user = undefined
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
router.use('/oauth', oauthRouter.routes());
|
||||
router.use('/accounts', accountsRouter.routes());
|
||||
router.use('/questions', questionsRouter.routes());
|
||||
|
||||
export default router
|
||||
router.get('/logout', (ctx: Koa.ParameterizedContext) =>
|
||||
{
|
||||
ctx.session.user = undefined;
|
||||
ctx.body = { status: 'ok' };
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@ -1,187 +1,306 @@
|
||||
import * as Router from "koa-router"
|
||||
import fetch from "node-fetch"
|
||||
import rndstr from "rndstr"
|
||||
import { URL } from "url"
|
||||
import { BASE_URL } from "../../config"
|
||||
import { MastodonApp, User } from "../../db/index"
|
||||
import QueryStringUtils from "../../utils/queryString"
|
||||
import { requestOAuth } from "../../utils/requestOAuth"
|
||||
import twitterClient from "../../utils/twitterClient"
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import fetch from 'node-fetch';
|
||||
import rndstr from 'rndstr';
|
||||
import crypto from 'crypto';
|
||||
import { URL } from 'url';
|
||||
import { BASE_URL } from '../../config';
|
||||
import { MastodonApp, User } from '../../db/index';
|
||||
import QueryStringUtils from '../../utils/queryString';
|
||||
import { requestOAuth } from '../../utils/requestOAuth';
|
||||
import twitterClient from '../../utils/twitterClient';
|
||||
import detectInstance from '../../utils/detectInstance';
|
||||
import { App } from '../../utils/misskey_entities/app';
|
||||
import { User as MisskeyUser } from '../../utils/misskey_entities/user';
|
||||
|
||||
const router = new Router()
|
||||
const router = new Router();
|
||||
|
||||
router.post("/get_url", async (ctx) => {
|
||||
const hostName = ctx.request.body.fields.instance
|
||||
.replace(/.*@/, "").toLowerCase()
|
||||
if (hostName.includes("/")) return ctx.reject(400, "not use slash in hostname")
|
||||
const redirectUri = BASE_URL + "/api/web/oauth/redirect"
|
||||
var url = ""
|
||||
if (hostName !== "twitter.com") { // Mastodon
|
||||
var app = await MastodonApp.findOne({hostName, appBaseUrl: BASE_URL, redirectUri})
|
||||
if (!app) {
|
||||
const res = await fetch("https://" + hostName + "/api/v1/apps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_name: "Quesdon",
|
||||
redirect_uris: redirectUri,
|
||||
scopes: "read write",
|
||||
website: BASE_URL,
|
||||
}),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
}).then((r) => r.json())
|
||||
app = new MastodonApp()
|
||||
app.clientId = res.client_id
|
||||
app.clientSecret = res.client_secret
|
||||
app.hostName = hostName
|
||||
app.appBaseUrl = BASE_URL
|
||||
app.redirectUri = redirectUri
|
||||
await app.save()
|
||||
}
|
||||
ctx.session!.loginState = rndstr() + "_" + app.id
|
||||
const params: {[key: string]: string} = {
|
||||
client_id: app.clientId,
|
||||
scope: "read+write",
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
state: ctx.session!.loginState,
|
||||
}
|
||||
url = `https://${app.hostName}/oauth/authorize?${Object.entries(params).map((v) => v.join("=")).join("&")}`
|
||||
} else { // Twitter
|
||||
ctx.session!.loginState = "twitter"
|
||||
const { TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET } = process.env
|
||||
if (TWITTER_CONSUMER_KEY == null || TWITTER_CONSUMER_SECRET == null) {
|
||||
ctx.throw(500, "twitter not supported in this server.")
|
||||
}
|
||||
const requestTokenRes = await requestOAuth(twitterClient, {
|
||||
url: "https://api.twitter.com/oauth/request_token",
|
||||
method: "POST",
|
||||
data: {},
|
||||
}).then((r) => r.text()).then((r) => QueryStringUtils.decode(r))
|
||||
const requestToken = {
|
||||
token: requestTokenRes.oauth_token,
|
||||
secret: requestTokenRes.oauth_token_secret,
|
||||
}
|
||||
ctx.session!.twitterOAuth = requestToken
|
||||
url = `https://twitter.com/oauth/authenticate?oauth_token=${requestToken.token}`
|
||||
}
|
||||
ctx.body = {
|
||||
url,
|
||||
}
|
||||
})
|
||||
router.post('/get_url', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
const hostName = ctx.request.body.instance.replace(/.*@/, '').toLowerCase();
|
||||
if (hostName.includes('/'))
|
||||
return ctx.throw(400, 'not use slash in hostname');
|
||||
|
||||
router.get("/redirect", async (ctx) => {
|
||||
var profile: {
|
||||
id: string
|
||||
name: string
|
||||
screenName: string
|
||||
avatarUrl: string
|
||||
accessToken: string
|
||||
hostName: string
|
||||
url: string
|
||||
acct: string,
|
||||
}
|
||||
if (ctx.session!.loginState !== "twitter") {
|
||||
if (ctx.query.state !== ctx.session!.loginState) {
|
||||
ctx.redirect("/login?error=invalid_state")
|
||||
return
|
||||
}
|
||||
const app = await MastodonApp.findById(ctx.session!.loginState.split("_")[1])
|
||||
if (app == null) {
|
||||
ctx.redirect("/login?error=app_notfound")
|
||||
return
|
||||
}
|
||||
const res = await fetch("https://" + app.hostName + "/oauth/token", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: app.redirectUri,
|
||||
client_id: app.clientId,
|
||||
client_secret: app.clientSecret,
|
||||
code: ctx.query.code,
|
||||
state: ctx.query.state,
|
||||
}),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
}).then((r) => r.json())
|
||||
const myProfile = await fetch("https://" + app.hostName + "/api/v1/accounts/verify_credentials", {
|
||||
headers: {Authorization: "Bearer " + res.access_token},
|
||||
}).then((r) => r.json())
|
||||
profile = {
|
||||
id: myProfile.id,
|
||||
name: myProfile.display_name || myProfile.username,
|
||||
screenName: myProfile.username,
|
||||
hostName: app.hostName,
|
||||
avatarUrl: myProfile.avatar_static,
|
||||
accessToken: res.access_token,
|
||||
url: myProfile.url,
|
||||
acct: myProfile.username + "@" + app.hostName,
|
||||
}
|
||||
} else { // twitter
|
||||
const requestToken: {
|
||||
token: string
|
||||
secret: string,
|
||||
} | undefined = ctx.session!.twitterOAuth
|
||||
if (!requestToken) return ctx.redirect("/login?error=no_request_token")
|
||||
if (requestToken.token !== ctx.query.oauth_token) return ctx.redirect("/login?error=invalid_request_token")
|
||||
var accessToken
|
||||
try {
|
||||
const accessTokenRes = await requestOAuth(twitterClient, {
|
||||
url: "https://api.twitter.com/oauth/access_token",
|
||||
method: "POST",
|
||||
data: {oauth_verifier: ctx.query.oauth_verifier},
|
||||
}, {
|
||||
key: requestToken.token,
|
||||
secret: requestToken.secret,
|
||||
}).then((r) => r.text()).then((r) => QueryStringUtils.decode(r))
|
||||
accessToken = {
|
||||
key: accessTokenRes.oauth_token,
|
||||
secret: accessTokenRes.oauth_token_secret,
|
||||
}
|
||||
} catch (e) {
|
||||
return ctx.redirect("/login?error=failed_access_token_fetch")
|
||||
}
|
||||
var a
|
||||
try {
|
||||
a = await requestOAuth(twitterClient, {
|
||||
url: "https://api.twitter.com/1.1/account/verify_credentials.json",
|
||||
method: "GET",
|
||||
data: {},
|
||||
}, accessToken).then((r) => r.json())
|
||||
} catch (e) {
|
||||
return ctx.redirect("/login?error=failed_user_profile_fetch")
|
||||
}
|
||||
profile = {
|
||||
id: a.id_str,
|
||||
name: a.name,
|
||||
screenName: a.screen_name,
|
||||
hostName: "twitter.com",
|
||||
avatarUrl: a.profile_image_url_https.replace("_normal.", "_400x400."),
|
||||
accessToken: accessToken.key + ":" + accessToken.secret,
|
||||
url: "https://twitter.com/" + a.screen_name,
|
||||
acct: a.screen_name + ":" + a.id_str + "@twitter.com",
|
||||
}
|
||||
}
|
||||
if (!profile) return
|
||||
const acct = profile.acct
|
||||
var user
|
||||
if (profile.hostName !== "twitter.com") { // Mastodon
|
||||
user = await User.findOne({acctLower: acct.toLowerCase()})
|
||||
} else {
|
||||
user = await User.findOne({upstreamId: profile.id, hostName: profile.hostName})
|
||||
}
|
||||
if (user == null) {
|
||||
user = new User()
|
||||
}
|
||||
user.acct = acct
|
||||
user.acctLower = acct.toLowerCase()
|
||||
user.name = profile.name
|
||||
user.avatarUrl = profile.avatarUrl
|
||||
user.accessToken = profile.accessToken
|
||||
user.hostName = profile.hostName
|
||||
user.url = profile.url
|
||||
user.upstreamId = profile.id
|
||||
await user.save()
|
||||
ctx.session!.user = user.id
|
||||
ctx.redirect("/my")
|
||||
})
|
||||
const redirectUri = BASE_URL + '/api/web/oauth/redirect';
|
||||
let url = '';
|
||||
|
||||
export default router
|
||||
if (hostName === 'twitter.com')
|
||||
{
|
||||
ctx.session.loginState = 'twitter';
|
||||
const { TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET } = process.env;
|
||||
if (TWITTER_CONSUMER_KEY === null || TWITTER_CONSUMER_SECRET === null)
|
||||
ctx.throw(500, 'twitter not supported in this server.');
|
||||
const requestTokenRes = await requestOAuth(twitterClient,
|
||||
{
|
||||
url: 'https://api.twitter.com/oauth/request_token',
|
||||
method: 'POST',
|
||||
data: {}
|
||||
}).then((r) => r.text()).then((r) => QueryStringUtils.decode(r));
|
||||
const requestToken =
|
||||
{
|
||||
token: requestTokenRes.oauth_token,
|
||||
secret: requestTokenRes.oauth_token_secret
|
||||
};
|
||||
ctx.session.twitterOAuth = requestToken;
|
||||
url = `https://twitter.com/oauth/authenticate?oauth_token=${requestToken.token}`;
|
||||
}
|
||||
else
|
||||
{
|
||||
const instanceType = await detectInstance(`https://${hostName}`);
|
||||
if (instanceType === 'misskey')
|
||||
{
|
||||
let app = await MastodonApp.findOne( { hostName, appBaseUrl: BASE_URL, redirectUri } );
|
||||
if (!app) // if it's the first time user from this instance is using quesdon
|
||||
{
|
||||
const res: App = await fetch(`https://${hostName}/api/app/create`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
{
|
||||
name: 'Quesdon',
|
||||
description: BASE_URL,
|
||||
permission: ['read:following', 'write:notes'],
|
||||
callbackUrl: redirectUri
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(r => r.json());
|
||||
|
||||
app = new MastodonApp();
|
||||
app.clientId = res.id,
|
||||
app.clientSecret = res.secret as string;
|
||||
app.hostName = hostName;
|
||||
app.appBaseUrl = BASE_URL;
|
||||
app.redirectUri = redirectUri;
|
||||
|
||||
await app.save();
|
||||
}
|
||||
ctx.session.loginState = `misskey_${app.id}`;
|
||||
const res = await fetch(`https://${hostName}/api/auth/session/generate`, // get authentication url from misskey instance
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify( { appSecret: app.clientSecret } )
|
||||
}).then(r => r.json());
|
||||
url = res.url;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mastodon
|
||||
let app = await MastodonApp.findOne( { hostName, appBaseUrl: BASE_URL, redirectUri } );
|
||||
if (!app)
|
||||
{
|
||||
const res = await fetch('https://' + hostName + '/api/v1/apps',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
{
|
||||
client_name: 'Quesdon',
|
||||
redirect_uris: redirectUri,
|
||||
scopes: 'read write',
|
||||
website: BASE_URL
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then((r) => r.json());
|
||||
|
||||
app = new MastodonApp();
|
||||
app.clientId = res.client_id;
|
||||
app.clientSecret = res.client_secret;
|
||||
app.hostName = hostName;
|
||||
app.appBaseUrl = BASE_URL;
|
||||
app.redirectUri = redirectUri;
|
||||
|
||||
await app.save();
|
||||
}
|
||||
ctx.session.loginState = `${rndstr()}_${app.id}`;
|
||||
const params: {[key: string]: string} =
|
||||
{
|
||||
client_id: app.clientId,
|
||||
scope: 'read+write',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state: ctx.session.loginState
|
||||
};
|
||||
url = `https://${app.hostName}/oauth/authorize?${Object.entries(params).map((v) => v.join('=')).join('&')}`;
|
||||
}
|
||||
}
|
||||
ctx.body = { url };
|
||||
});
|
||||
|
||||
router.get('/redirect', async (ctx: Koa.ParameterizedContext) =>
|
||||
{
|
||||
let profile:
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
screenName: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
hostName: string;
|
||||
url: string;
|
||||
acct: string;
|
||||
};
|
||||
|
||||
if ((ctx.session.loginState as string).startsWith('misskey'))
|
||||
{
|
||||
// misskey
|
||||
const app = await MastodonApp.findById(ctx.session.loginState.split('_')[1]);
|
||||
if (app === null)
|
||||
return ctx.redirect('/login?error=app_notfound');
|
||||
|
||||
const res: { accessToken: string; user: MisskeyUser } = await fetch(`https://${app.hostName}/api/auth/session/userkey`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
appSecret: app.clientSecret,
|
||||
token: ctx.query.token
|
||||
})
|
||||
}).then(r => r.json());
|
||||
|
||||
profile =
|
||||
{
|
||||
id: res.user.id,
|
||||
name: res.user.name ?? res.user.username,
|
||||
screenName: res.user.username,
|
||||
hostName: app.hostName,
|
||||
avatarUrl: res.user.avatarUrl as string,
|
||||
accessToken: crypto.createHash('sha256').update(res.accessToken + app.clientSecret).digest('hex'),
|
||||
url: `https://${res.user.host}/@${res.user.username}`,
|
||||
acct: `${res.user.username}@${app.hostName}`
|
||||
};
|
||||
}
|
||||
else if (ctx.session.loginState !== 'twitter')
|
||||
{
|
||||
// Mastodon
|
||||
if (ctx.query.state !== ctx.session.loginState)
|
||||
return ctx.redirect('/login?error=invalid_state');
|
||||
|
||||
const app = await MastodonApp.findById(ctx.session.loginState.split('_')[1]);
|
||||
if (app === null)
|
||||
return ctx.redirect('/login?error=app_notfound');
|
||||
|
||||
const res = await fetch('https://' + app.hostName + '/oauth/token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: app.redirectUri,
|
||||
client_id: app.clientId,
|
||||
client_secret: app.clientSecret,
|
||||
code: ctx.query.code,
|
||||
state: ctx.query.state
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then((r) => r.json());
|
||||
|
||||
const myProfile = await fetch('https://' + app.hostName + '/api/v1/accounts/verify_credentials',
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + res.access_token }
|
||||
}).then((r) => r.json());
|
||||
|
||||
profile =
|
||||
{
|
||||
id: myProfile.id,
|
||||
name: myProfile.display_name || myProfile.username,
|
||||
screenName: myProfile.username,
|
||||
hostName: app.hostName,
|
||||
avatarUrl: myProfile.avatar_static,
|
||||
accessToken: res.access_token,
|
||||
url: myProfile.url,
|
||||
acct: myProfile.username + '@' + app.hostName
|
||||
};
|
||||
}
|
||||
else
|
||||
{ // twitter
|
||||
const requestToken:
|
||||
{
|
||||
token: string;
|
||||
secret: string;
|
||||
} | undefined = ctx.session.twitterOAuth;
|
||||
|
||||
if (!requestToken)
|
||||
return ctx.redirect('/login?error=no_request_token');
|
||||
if (requestToken.token !== ctx.query.oauth_token)
|
||||
return ctx.redirect('/login?error=invalid_request_token');
|
||||
|
||||
let accessToken;
|
||||
try
|
||||
{
|
||||
const accessTokenRes = await requestOAuth(twitterClient,
|
||||
{
|
||||
url: 'https://api.twitter.com/oauth/access_token',
|
||||
method: 'POST',
|
||||
data: { oauth_verifier: ctx.query.oauth_verifier }
|
||||
},
|
||||
{
|
||||
key: requestToken.token,
|
||||
secret: requestToken.secret
|
||||
}).then((r) => r.text()).then((r) => QueryStringUtils.decode(r));
|
||||
accessToken =
|
||||
{
|
||||
key: accessTokenRes.oauth_token,
|
||||
secret: accessTokenRes.oauth_token_secret
|
||||
};
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
return ctx.redirect('/login?error=failed_access_token_fetch');
|
||||
}
|
||||
|
||||
let resp;
|
||||
try
|
||||
{
|
||||
resp = await requestOAuth(twitterClient,
|
||||
{
|
||||
url: 'https://api.twitter.com/1.1/account/verify_credentials.json',
|
||||
method: 'GET',
|
||||
data: {}
|
||||
}, accessToken).then((r) => r.json());
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
return ctx.redirect('/login?error=failed_user_profile_fetch');
|
||||
}
|
||||
|
||||
profile =
|
||||
{
|
||||
id: resp.id_str,
|
||||
name: resp.name,
|
||||
screenName: resp.screen_name,
|
||||
hostName: 'twitter.com',
|
||||
avatarUrl: resp.profile_image_url_https.replace('_normal.', '_400x400.'),
|
||||
accessToken: accessToken.key + ':' + accessToken.secret,
|
||||
url: 'https://twitter.com/' + resp.screen_name,
|
||||
acct: resp.screen_name + ':' + resp.id_str + '@twitter.com'
|
||||
};
|
||||
}
|
||||
if (!profile) return;
|
||||
|
||||
const acct = profile.acct;
|
||||
let user;
|
||||
|
||||
if (profile.hostName !== 'twitter.com') // Mastodon and misskey
|
||||
user = await User.findOne({acctLower: acct.toLowerCase()});
|
||||
else
|
||||
user = await User.findOne({upstreamId: profile.id, hostName: profile.hostName});
|
||||
|
||||
if (user === null)
|
||||
user = new User();
|
||||
|
||||
user.acct = acct;
|
||||
user.acctLower = acct.toLowerCase();
|
||||
user.name = profile.name;
|
||||
user.avatarUrl = profile.avatarUrl;
|
||||
user.accessToken = profile.accessToken;
|
||||
user.hostName = profile.hostName;
|
||||
user.url = profile.url;
|
||||
user.upstreamId = profile.id;
|
||||
await user.save();
|
||||
|
||||
ctx.session.user = user.id;
|
||||
|
||||
ctx.redirect('/my');
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@ -1,169 +1,277 @@
|
||||
import * as Router from "koa-router"
|
||||
import * as mongoose from "mongoose"
|
||||
import fetch from "node-fetch"
|
||||
import { BASE_URL } from "../../config"
|
||||
import { IMastodonApp, IUser, Question, QuestionLike, User } from "../../db/index"
|
||||
import { cutText } from "../../utils/cutText"
|
||||
import { requestOAuth } from "../../utils/requestOAuth"
|
||||
import twitterClient from "../../utils/twitterClient"
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import mongoose from 'mongoose';
|
||||
import fetch from 'node-fetch';
|
||||
import { stripIndents } from 'common-tags';
|
||||
import { BASE_URL } from '../../config';
|
||||
import { IMastodonApp, IUser, Question, QuestionLike, User } from '../../db/index';
|
||||
import { cutText } from '../../utils/cutText';
|
||||
import { requestOAuth } from '../../utils/requestOAuth';
|
||||
import twitterClient from '../../utils/twitterClient';
|
||||
import detectInstance from '../../utils/detectInstance';
|
||||
|
||||
const router = new Router()
|
||||
const router = new Router();
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const questions = await Question.find({
|
||||
user: mongoose.Types.ObjectId(ctx.session!.user),
|
||||
answeredAt: null,
|
||||
isDeleted: {$ne: true},
|
||||
})
|
||||
ctx.body = JSON.stringify(questions)
|
||||
})
|
||||
router.get('/', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
router.get("/count", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const count = await Question.find({
|
||||
user: mongoose.Types.ObjectId(ctx.session!.user),
|
||||
answeredAt: null,
|
||||
isDeleted: {$ne: true},
|
||||
}).count()
|
||||
ctx.body = {count}
|
||||
})
|
||||
const questions = await Question.find(
|
||||
{
|
||||
user: mongoose.Types.ObjectId(ctx.session.user),
|
||||
answeredAt: null,
|
||||
isDeleted: {$ne: true}
|
||||
});
|
||||
|
||||
router.get("/latest", async (ctx) => {
|
||||
const questions = await Question.find({
|
||||
answeredAt: {$ne: null},
|
||||
isDeleted: {$ne: true},
|
||||
}).limit(20).sort("-answeredAt")
|
||||
ctx.body = questions
|
||||
})
|
||||
ctx.body = JSON.stringify(questions);
|
||||
});
|
||||
|
||||
router.post("/:id/answer", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const question = await Question.findById(ctx.params.id)
|
||||
if (!question) return ctx.throw("not found", 404)
|
||||
if (question.isDeleted) return ctx.throw("not found", 404)
|
||||
// tslint:disable-next-line:triple-equals
|
||||
if (question.user._id != ctx.session!.user) return ctx.throw("not found", 404)
|
||||
if (question.answeredAt) return ctx.throw("alread answered", 400)
|
||||
question.answer = ctx.request.body.fields.answer
|
||||
if (question.answer!.length < 1) return ctx.throw("please input answer", 400)
|
||||
question.answeredAt = new Date()
|
||||
if (ctx.request.body.fields.isNSFW) question.isNSFW = true
|
||||
await question.save()
|
||||
ctx.body = {status: "ok"}
|
||||
const user = await User.findById(ctx.session!.user)
|
||||
if (!["public", "unlisted", "private"].includes(ctx.request.body.fields.visibility)) return
|
||||
if (!user) return
|
||||
const isTwitter = user.hostName === "twitter.com"
|
||||
const answerCharMax = isTwitter ? (110 - question.question.length) : 200
|
||||
const answerUrl = BASE_URL + "/@" + user!.acct + "/questions/" + question.id
|
||||
if (!isTwitter) { // Mastodon
|
||||
const body = {
|
||||
spoiler_text: "Q. " + question.question + " #quesdon",
|
||||
status: [
|
||||
"A. ",
|
||||
(question.answer!.length > 200
|
||||
? question.answer!.substring(0, 200) + "..."
|
||||
: question.answer),
|
||||
"\n#quesdon ",
|
||||
answerUrl,
|
||||
].join(""),
|
||||
visibility: ctx.request.body.fields.visibility,
|
||||
}
|
||||
if (question.questionUser) {
|
||||
var questionUserAcct = "@" + question.questionUser.acct
|
||||
if (question.questionUser.hostName === "twitter.com") {
|
||||
questionUserAcct = "https://twitter.com/" + question.questionUser.acct.replace(/:.+/, "")
|
||||
}
|
||||
body.status = "질문자: " + questionUserAcct + "\n" + body.status
|
||||
}
|
||||
if (question.isNSFW) {
|
||||
body.status = "Q. " + question.question + "\n" + body.status
|
||||
body.spoiler_text = "⚠ 이 질문은 답변자가 NSFW하다고 했어요. #quesdon"
|
||||
}
|
||||
fetch("https://" + user!.acct.split("@")[1] + "/api/v1/statuses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Authorization": "Bearer " + user!.accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const strQ = cutText(question.question, 60)
|
||||
const strA = cutText(question.answer!, 120 - strQ.length)
|
||||
const [key, secret] = user.accessToken.split(":")
|
||||
const body = "Q. " + strQ + "\nA. " + strA + "\n#quesdon " + answerUrl
|
||||
requestOAuth(twitterClient, {
|
||||
url: "https://api.twitter.com/1.1/statuses/update.json",
|
||||
method: "POST",
|
||||
data: {status: body},
|
||||
}, {
|
||||
key, secret,
|
||||
})
|
||||
}
|
||||
})
|
||||
router.get('/count', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
router.post("/:id/delete", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const question = await Question.findById(ctx.params.id)
|
||||
if (!question) return ctx.throw("not found", 404)
|
||||
// tslint:disable-next-line:triple-equals
|
||||
if (question.user._id != ctx.session!.user) return ctx.throw("not found", 404)
|
||||
question.isDeleted = true
|
||||
await question.save()
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
const count = await Question.find(
|
||||
{
|
||||
user: mongoose.Types.ObjectId(ctx.session.user),
|
||||
answeredAt: null,
|
||||
isDeleted: {$ne: true}
|
||||
}).count();
|
||||
|
||||
router.post("/:id/like", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const question = await Question.findById(ctx.params.id)
|
||||
if (!question) return ctx.throw("not found", 404)
|
||||
if (!question.answeredAt) return ctx.throw("not found", 404)
|
||||
if (await QuestionLike.findOne({question})) return ctx.throw("already liked", 400)
|
||||
const like = new QuestionLike()
|
||||
like.question = question
|
||||
like.user = mongoose.Types.ObjectId(ctx.session!.user)
|
||||
await like.save()
|
||||
question.likesCount = await QuestionLike.find({question}).count()
|
||||
await question.save()
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
ctx.body = { count };
|
||||
});
|
||||
|
||||
router.post("/:id/unlike", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
const question = await Question.findById(ctx.params.id)
|
||||
const user = mongoose.Types.ObjectId(ctx.session!.user)
|
||||
if (!question) return ctx.throw("not found", 404)
|
||||
if (!question.answeredAt) return ctx.throw("not found", 404)
|
||||
const like = await QuestionLike.findOne({question, user})
|
||||
if (!like) return ctx.throw("not liked", 400)
|
||||
await like.remove()
|
||||
question.likesCount = await QuestionLike.find({question}).count()
|
||||
await question.save()
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
router.get('/latest', async (ctx) =>
|
||||
{
|
||||
const questions = await Question.find(
|
||||
{
|
||||
answeredAt: {$ne: null},
|
||||
isDeleted: {$ne: true}
|
||||
}).limit(20).sort('-answeredAt');
|
||||
|
||||
router.get("/:id", async (ctx) => {
|
||||
const question = await Question.findById(ctx.params.id)
|
||||
if (!question) return ctx.throw("not found", 404)
|
||||
if (!question.answeredAt) return ctx.throw("not found", 404)
|
||||
if (question.isDeleted) return ctx.throw("not found", 404)
|
||||
ctx.body = question
|
||||
})
|
||||
ctx.body = questions;
|
||||
});
|
||||
|
||||
router.post("/all_delete", async (ctx) => {
|
||||
if (!ctx.session!.user) return ctx.throw("please login", 403)
|
||||
await Question.update({
|
||||
user: mongoose.Types.ObjectId(ctx.session!.user),
|
||||
}, {
|
||||
$set: {
|
||||
isDeleted: true,
|
||||
},
|
||||
}, {
|
||||
multi: true,
|
||||
})
|
||||
ctx.body = {status: "ok"}
|
||||
})
|
||||
router.post('/:id/answer', async (ctx: Koa.ParameterizedContext): Promise<void|never> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
export default router
|
||||
const question = await Question.findById(ctx.params.id);
|
||||
if (!question)
|
||||
return ctx.throw('not found', 404);
|
||||
if (question.isDeleted)
|
||||
return ctx.throw('not found', 404);
|
||||
if (question.user._id != ctx.session.user) // eslint-disable-line eqeqeq
|
||||
return ctx.throw('not found', 404);
|
||||
if (question.answeredAt)
|
||||
return ctx.throw('already answered', 400);
|
||||
|
||||
question.answer = ctx.request.body.answer as string;
|
||||
if (question.answer.length < 1)
|
||||
return ctx.throw('please input answer', 400);
|
||||
|
||||
question.answeredAt = new Date();
|
||||
if (ctx.request.body.isNSFW)
|
||||
question.isNSFW = true;
|
||||
await question.save();
|
||||
|
||||
ctx.body = { status: 'ok' };
|
||||
|
||||
const user = await User.findById(ctx.session.user);
|
||||
if (!['public', 'unlisted', 'private'].includes(ctx.request.body.visibility))
|
||||
return;
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
const isTwitter = user.hostName === 'twitter.com';
|
||||
const answerCharMax = isTwitter ? (110 - question.question.length) : 200;
|
||||
const answerUrl = `${BASE_URL}/@${user.acct}/questions/${question.id}`;
|
||||
|
||||
if (isTwitter)
|
||||
{
|
||||
const strQ = cutText(question.question, 60);
|
||||
const strA = cutText(question.answer, 120 - strQ.length);
|
||||
const [key, secret] = user.accessToken.split(':');
|
||||
const body = `Q. ${strQ}
|
||||
A. ${strA}
|
||||
#quesdon ${answerUrl}`;
|
||||
await requestOAuth(twitterClient,
|
||||
{
|
||||
url: 'https://api.twitter.com/1.1/statuses/update.json',
|
||||
method: 'POST',
|
||||
data: { status: body }
|
||||
}, { key, secret });
|
||||
return;
|
||||
}
|
||||
|
||||
// misskey
|
||||
const instanceUrl = 'https://' + user.acct.split('@')[1];
|
||||
const instanceType = await detectInstance(instanceUrl);
|
||||
|
||||
const status =
|
||||
{
|
||||
title: `Q. ${question.question} #quesdon`,
|
||||
text: stripIndents`
|
||||
A. ${question.answer.length > answerCharMax ? `${question.answer.substring(0, answerCharMax)}...` : question.answer}
|
||||
#quesdon ${answerUrl}`
|
||||
};
|
||||
if (question.questionUser)
|
||||
{
|
||||
let questionUserAcct = `@${question.questionUser.acct}`;
|
||||
if (question.questionUser.hostName === 'twitter.com')
|
||||
questionUserAcct = `https://twitter.com/${question.questionUser.acct.replace(/:.+/, '')}`;
|
||||
status.text = stripIndents`
|
||||
질문자: ${questionUserAcct}
|
||||
${status.text}`;
|
||||
}
|
||||
if (question.isNSFW)
|
||||
{
|
||||
status.text = stripIndents`
|
||||
Q. ${question.question}
|
||||
${status.text}`;
|
||||
status.title = '⚠ 이 질문은 답변자가 NSFW하다고 했어요. #quesdon';
|
||||
}
|
||||
|
||||
if (instanceType === 'misskey')
|
||||
{
|
||||
let visibility;
|
||||
switch(ctx.request.body.visibility)
|
||||
{
|
||||
case 'public': visibility = 'public'; break;
|
||||
case 'unlisted': visibility = 'home'; break;
|
||||
case 'private': visibility = 'followers'; break;
|
||||
default: visibility = 'home'; break;
|
||||
}
|
||||
|
||||
const body =
|
||||
{
|
||||
i: user.accessToken,
|
||||
visibility: visibility,
|
||||
cw: status.title,
|
||||
text: status.text
|
||||
};
|
||||
|
||||
await fetch(`${instanceUrl}/api/notes/create`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mastodon
|
||||
const body =
|
||||
{
|
||||
spoiler_text: status.title,
|
||||
status: status.text,
|
||||
visibility: ctx.request.body.visibility
|
||||
};
|
||||
|
||||
await fetch(instanceUrl + '/api/v1/statuses',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers:
|
||||
{
|
||||
'Authorization': 'Bearer ' + user.accessToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
return;
|
||||
});
|
||||
|
||||
router.post('/:id/delete', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const question = await Question.findById(ctx.params.id);
|
||||
if (!question)
|
||||
return ctx.throw('not found', 404);
|
||||
if (question.user._id != ctx.session.user) // eslint-disable-line eqeqeq
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
question.isDeleted = true;
|
||||
await question.save();
|
||||
|
||||
ctx.body = { status: 'ok' };
|
||||
});
|
||||
|
||||
router.post('/:id/like', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const question = await Question.findById(ctx.params.id);
|
||||
if (!question)
|
||||
return ctx.throw('not found', 404);
|
||||
if (!question.answeredAt)
|
||||
return ctx.throw('not found', 404);
|
||||
if (await QuestionLike.findOne({question}))
|
||||
return ctx.throw('already liked', 400);
|
||||
|
||||
const like = new QuestionLike();
|
||||
like.question = question;
|
||||
like.user = mongoose.Types.ObjectId(ctx.session.user);
|
||||
await like.save();
|
||||
question.likesCount = await QuestionLike.find({question}).count();
|
||||
|
||||
await question.save();
|
||||
|
||||
ctx.body = { status: 'ok' };
|
||||
});
|
||||
|
||||
router.post('/:id/unlike', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
const question = await Question.findById(ctx.params.id);
|
||||
const user = mongoose.Types.ObjectId(ctx.session.user);
|
||||
if (!question)
|
||||
return ctx.throw('not found', 404);
|
||||
if (!question.answeredAt)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
const like = await QuestionLike.findOne({question, user});
|
||||
if (!like)
|
||||
return ctx.throw('not liked', 400);
|
||||
await like.remove();
|
||||
question.likesCount = await QuestionLike.find({question}).count();
|
||||
|
||||
await question.save();
|
||||
ctx.body = { status: 'ok' };
|
||||
});
|
||||
|
||||
router.get('/:id', async (ctx): Promise<never|void> =>
|
||||
{
|
||||
const question = await Question.findById(ctx.params.id);
|
||||
if (!question)
|
||||
return ctx.throw('not found', 404);
|
||||
if (!question.answeredAt)
|
||||
return ctx.throw('not found', 404);
|
||||
if (question.isDeleted)
|
||||
return ctx.throw('not found', 404);
|
||||
|
||||
ctx.body = question;
|
||||
});
|
||||
|
||||
router.post('/all_delete', async (ctx: Koa.ParameterizedContext): Promise<never|void> =>
|
||||
{
|
||||
if (!ctx.session.user)
|
||||
return ctx.throw('please login', 403);
|
||||
|
||||
await Question.update(
|
||||
{ user: mongoose.Types.ObjectId(ctx.session.user) },
|
||||
{
|
||||
$set: { isDeleted: true }
|
||||
},
|
||||
{ multi: true });
|
||||
|
||||
ctx.body = { status: 'ok' };
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { execSync } from "child_process"
|
||||
import dotenv = require("dotenv")
|
||||
dotenv.config()
|
||||
import { execSync } from 'child_process';
|
||||
import dotenv = require('dotenv')
|
||||
dotenv.config();
|
||||
|
||||
export const PORT = parseInt(process.env.BACK_PORT || "3000", 10)
|
||||
export const PORT = parseInt(process.env.BACK_PORT || '3000', 10);
|
||||
|
||||
export const HOST = process.env.VIRTUAL_HOST || "localhost:" + PORT
|
||||
export const HTTPS_ENABLED = !!process.env.HTTPS_ENABLED
|
||||
export const BASE_URL = (HTTPS_ENABLED ? "https" : "http") + "://" + HOST
|
||||
export const HOST = process.env.VIRTUAL_HOST || 'localhost:' + PORT;
|
||||
export const HTTPS_ENABLED = !!process.env.HTTPS_ENABLED;
|
||||
export const BASE_URL = (HTTPS_ENABLED ? 'https' : 'http') + '://' + HOST;
|
||||
|
||||
export const MONGODB_URL = process.env.MONGODB_URL || "mongodb://localhost/quesdon"
|
||||
export const MONGODB_URL = process.env.MONGODB_URL || 'mongodb://localhost/quesdon';
|
||||
// export const REDIS_URL = process.env.REDIS_URL || "redis://localhost"
|
||||
|
||||
export const SECRET_KEY = process.env.SECRET_KEY || "shibuyarin16544"
|
||||
export const SECRET_KEY = process.env.SECRET_KEY || 'shibuyarin16544';
|
||||
|
||||
export var GIT_COMMIT = execSync("git rev-parse HEAD").toString().trim()
|
||||
export const GIT_COMMIT = execSync('git rev-parse HEAD').toString().trim();
|
||||
|
||||
export const PUSHBULLET_CLIENT_ID = process.env.PUSHBULLET_CLIENT_ID
|
||||
export const PUSHBULLET_CLIENT_SECRET = process.env.PUSHBULLET_CLIENT_SECRET
|
||||
export const PUSHBULLET_CLIENT_ID = process.env.PUSHBULLET_CLIENT_ID;
|
||||
export const PUSHBULLET_CLIENT_SECRET = process.env.PUSHBULLET_CLIENT_SECRET;
|
||||
|
||||
export const NOTICE_ACCESS_TOKEN = process.env.NOTICE_ACCESS_TOKEN
|
||||
export const NOTICE_ACCESS_TOKEN = process.env.NOTICE_ACCESS_TOKEN;
|
||||
|
@ -1,21 +1,21 @@
|
||||
import * as mongoose from "mongoose"
|
||||
import * as mongoose from 'mongoose';
|
||||
|
||||
const schema = new mongoose.Schema({
|
||||
hostName: {type: String, required: true},
|
||||
clientId: {type: String, required: true},
|
||||
clientSecret: {type: String, required: true},
|
||||
appBaseUrl: {type: String, required: true},
|
||||
redirectUri: {type: String, required: true},
|
||||
}, {
|
||||
timestamps: true,
|
||||
})
|
||||
const schema = new mongoose.Schema(
|
||||
{
|
||||
hostName: {type: String, required: true},
|
||||
clientId: {type: String, required: true},
|
||||
clientSecret: {type: String, required: true},
|
||||
appBaseUrl: {type: String, required: true},
|
||||
redirectUri: {type: String, required: true}
|
||||
}, { timestamps: true });
|
||||
|
||||
export interface IMastodonApp extends mongoose.Document {
|
||||
hostName: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
appBaseUrl: string
|
||||
redirectUri: string
|
||||
export interface IMastodonApp extends mongoose.Document
|
||||
{
|
||||
hostName: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
appBaseUrl: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export default mongoose.model("mastodon_apps", schema) as mongoose.Model<IMastodonApp>
|
||||
export default mongoose.model<IMastodonApp>('mastodon_apps', schema);
|
||||
|
@ -1,19 +1,20 @@
|
||||
import * as mongoose from "mongoose"
|
||||
import { MONGODB_URL } from "../config"
|
||||
import mongoose from 'mongoose';
|
||||
import { MONGODB_URL } from '../config';
|
||||
|
||||
mongoose.connect(MONGODB_URL).catch((e) => {
|
||||
console.error("MongoDB Error: " + e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
mongoose.connect(MONGODB_URL).catch((e) =>
|
||||
{
|
||||
console.error('MongoDB Error: ' + e.message); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
import MastodonApp, {IMastodonApp} from "./apps"
|
||||
import QuestionLike, {IQuestionLike} from "./question_likes"
|
||||
import Question, {IQuestion} from "./questions"
|
||||
import User, {IUser} from "./users"
|
||||
import MastodonApp, {IMastodonApp} from './apps';
|
||||
import QuestionLike, {IQuestionLike} from './question_likes';
|
||||
import Question, {IQuestion} from './questions';
|
||||
import User, {IUser} from './users';
|
||||
|
||||
export {
|
||||
MastodonApp, IMastodonApp,
|
||||
User, IUser,
|
||||
Question, IQuestion,
|
||||
QuestionLike, IQuestionLike,
|
||||
}
|
||||
MastodonApp, IMastodonApp,
|
||||
User, IUser,
|
||||
Question, IQuestion,
|
||||
QuestionLike, IQuestionLike
|
||||
};
|
||||
|
5
src/server/db/mongoose-autopopulate.d.ts
vendored
Normal file
5
src/server/db/mongoose-autopopulate.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module 'mongoose-autopopulate'
|
||||
{
|
||||
import { Schema } from 'mongoose';
|
||||
export default function autopopulate(schema: Schema): void;
|
||||
}
|
@ -1,16 +1,16 @@
|
||||
import * as mongoose from "mongoose"
|
||||
import { IQuestion, IUser } from "./index"
|
||||
import * as mongoose from 'mongoose';
|
||||
import { IQuestion, IUser } from './index';
|
||||
|
||||
const schema = new mongoose.Schema({
|
||||
user: {type: mongoose.Schema.Types.ObjectId, required: true, ref: "users"},
|
||||
question: {type: mongoose.Schema.Types.ObjectId, required: true, ref: "questions"},
|
||||
}, {
|
||||
timestamps: true,
|
||||
})
|
||||
const schema = new mongoose.Schema(
|
||||
{
|
||||
user: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'users' },
|
||||
question: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'questions' }
|
||||
}, { timestamps: true });
|
||||
|
||||
export interface IQuestionLike extends mongoose.Document {
|
||||
user: IUser | mongoose.Types.ObjectId
|
||||
question: IQuestion | mongoose.Types.ObjectId
|
||||
export interface IQuestionLike extends mongoose.Document
|
||||
{
|
||||
user: IUser | mongoose.Types.ObjectId;
|
||||
question: IQuestion | mongoose.Types.ObjectId;
|
||||
}
|
||||
|
||||
export default mongoose.model("question_likes", schema) as mongoose.Model<IQuestionLike>
|
||||
export default mongoose.model<IQuestionLike>('question_likes', schema);
|
||||
|
@ -1,31 +1,30 @@
|
||||
import * as mongoose from "mongoose"
|
||||
import { IUser } from "./index"
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const autopopulate = require("mongoose-autopopulate") // @types/がないのでしかたない
|
||||
import * as mongoose from 'mongoose';
|
||||
import { IUser } from './index';
|
||||
import autopopulate from 'mongoose-autopopulate';
|
||||
|
||||
const schema = new mongoose.Schema({
|
||||
user: {type: mongoose.Schema.Types.ObjectId, required: true, ref: "users", autopopulate: true},
|
||||
question: {type: String, required: true},
|
||||
answer: String,
|
||||
answeredAt: Date,
|
||||
isDeleted: {type: Boolean, default: false},
|
||||
likesCount: {type: Number, default: 0},
|
||||
isNSFW: {type: Boolean, default: false},
|
||||
questionUser: {type: mongoose.Schema.Types.ObjectId, ref: "users", autopopulate: true},
|
||||
}, {
|
||||
timestamps: true,
|
||||
})
|
||||
schema.plugin(autopopulate)
|
||||
const schema = new mongoose.Schema(
|
||||
{
|
||||
user: {type: mongoose.Schema.Types.ObjectId, required: true, ref: 'users', autopopulate: true},
|
||||
question: {type: String, required: true},
|
||||
answer: String,
|
||||
answeredAt: Date,
|
||||
isDeleted: {type: Boolean, default: false},
|
||||
likesCount: {type: Number, default: 0},
|
||||
isNSFW: {type: Boolean, default: false},
|
||||
questionUser: {type: mongoose.Schema.Types.ObjectId, ref: 'users', autopopulate: true}
|
||||
}, { timestamps: true });
|
||||
schema.plugin(autopopulate);
|
||||
|
||||
export interface IQuestion extends mongoose.Document {
|
||||
user: IUser
|
||||
question: string
|
||||
answer: string | null
|
||||
answeredAt: Date | null
|
||||
isDeleted: boolean
|
||||
likesCount: number
|
||||
isNSFW: boolean
|
||||
questionUser: IUser
|
||||
export interface IQuestion extends mongoose.Document
|
||||
{
|
||||
user: IUser;
|
||||
question: string;
|
||||
answer: string | null;
|
||||
answeredAt: Date | null;
|
||||
isDeleted: boolean;
|
||||
likesCount: number;
|
||||
isNSFW: boolean;
|
||||
questionUser: IUser;
|
||||
}
|
||||
|
||||
export default mongoose.model("questions", schema) as mongoose.Model<IQuestion>
|
||||
export default mongoose.model<IQuestion>('questions', schema);
|
||||
|
@ -1,54 +1,54 @@
|
||||
import * as mongoose from "mongoose"
|
||||
import setTransformer from "../utils/setTransformer"
|
||||
import { IMastodonApp } from "./"
|
||||
import mongoose from 'mongoose';
|
||||
import setTransformer from '../utils/setTransformer';
|
||||
import { IMastodonApp } from './';
|
||||
|
||||
const schema = new mongoose.Schema({
|
||||
acct: {type: String, required: true},
|
||||
acctLower: {type: String, required: true, unique: true},
|
||||
app: {type: mongoose.Schema.Types.ObjectId, ref: "mastodon_apps"},
|
||||
name: {type: String, required: true},
|
||||
avatarUrl: {type: String, required: true},
|
||||
accessToken: {type: String, required: true},
|
||||
url: {type: String},
|
||||
description: {type: String, default: ""},
|
||||
questionBoxName: {type: String, default: "질문 상자"},
|
||||
pushbulletAccessToken: {type: String},
|
||||
allAnon: {type: Boolean, default: false},
|
||||
upstreamId: {type: String},
|
||||
hostName: {type: String},
|
||||
stopNewQuestion: {type: Boolean},
|
||||
}, {
|
||||
timestamps: true,
|
||||
})
|
||||
const schema = new mongoose.Schema(
|
||||
{
|
||||
acct: { type: String, required: true },
|
||||
acctLower: { type: String, required: true, unique: true },
|
||||
app: { type: mongoose.Schema.Types.ObjectId, ref: 'mastodon_apps' },
|
||||
name: { type: String, required: true },
|
||||
avatarUrl: { type: String, required: true },
|
||||
accessToken: { type: String, required: true },
|
||||
url: { type: String },
|
||||
description: { type: String, default: '' },
|
||||
questionBoxName: { type: String, default: '질문 상자' },
|
||||
pushbulletAccessToken: { type: String },
|
||||
allAnon: { type: Boolean, default: false },
|
||||
upstreamId: { type: String },
|
||||
hostName: { type: String },
|
||||
stopNewQuestion: { type: Boolean }
|
||||
}, { timestamps: true });
|
||||
|
||||
setTransformer(schema, (doc: IUser, ret: any) => {
|
||||
ret.hostName = ret.acctLower.split("@").reverse()[0]
|
||||
delete ret.app
|
||||
delete ret.accessToken
|
||||
delete ret.acctLower
|
||||
delete ret.pushbulletAccessToken
|
||||
delete ret.upstreamId
|
||||
ret.isTwitter = ret.hostName === "twitter.com"
|
||||
ret.pushbulletEnabled = !!doc.pushbulletAccessToken
|
||||
ret.acctDisplay = ret.acct.replace(/:[0-9]+@/, "@")
|
||||
return ret
|
||||
})
|
||||
setTransformer(schema, (doc: IUser, ret: any) => // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
{
|
||||
ret.hostName = ret.acctLower.split('@').reverse()[0];
|
||||
delete ret.app;
|
||||
delete ret.accessToken;
|
||||
delete ret.acctLower;
|
||||
delete ret.pushbulletAccessToken;
|
||||
delete ret.upstreamId;
|
||||
ret.isTwitter = ret.hostName === 'twitter.com';
|
||||
ret.pushbulletEnabled = !!doc.pushbulletAccessToken;
|
||||
ret.acctDisplay = ret.acct.replace(/:[0-9]+@/, '@');
|
||||
return ret;
|
||||
});
|
||||
|
||||
export interface IUser extends mongoose.Document {
|
||||
acct: string
|
||||
acctLower: string
|
||||
app: IMastodonApp | mongoose.Types.ObjectId
|
||||
name: string
|
||||
avatarUrl: string
|
||||
accessToken: string
|
||||
url: string | null
|
||||
description: string
|
||||
questionBoxName: string
|
||||
pushbulletAccessToken: string | null
|
||||
allAnon: boolean
|
||||
upstreamId: string | null
|
||||
hostName: string | null
|
||||
stopNewQuestion: boolean | null
|
||||
acct: string;
|
||||
acctLower: string;
|
||||
app: IMastodonApp | mongoose.Types.ObjectId;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
url: string | null;
|
||||
description: string;
|
||||
questionBoxName: string;
|
||||
pushbulletAccessToken: string | null;
|
||||
allAnon: boolean;
|
||||
upstreamId: string | null;
|
||||
hostName: string | null;
|
||||
stopNewQuestion: boolean | null;
|
||||
}
|
||||
|
||||
export default mongoose.model("users", schema) as mongoose.Model<IUser>
|
||||
export default mongoose.model<IUser>('users', schema);
|
||||
|
@ -1,55 +1,47 @@
|
||||
import * as Koa from "koa"
|
||||
import * as koaBody from "koa-body"
|
||||
import * as mount from "koa-mount"
|
||||
import * as Pug from "koa-pug"
|
||||
import * as Router from "koa-router"
|
||||
import * as session from "koa-session"
|
||||
import * as path from "path"
|
||||
import rndstr from "rndstr"
|
||||
import apiRouter from "./api"
|
||||
import { GIT_COMMIT, PORT, SECRET_KEY } from "./config"
|
||||
import { User } from "./db/index"
|
||||
import Koa from 'koa';
|
||||
import koaBody from 'koa-body';
|
||||
import mount from 'koa-mount';
|
||||
import Pug from 'koa-pug';
|
||||
import Router from 'koa-router';
|
||||
import session from 'koa-session';
|
||||
import koaStaticCache from 'koa-static-cache';
|
||||
import path from 'path';
|
||||
import rndstr from 'rndstr';
|
||||
import apiRouter from './api';
|
||||
import { GIT_COMMIT, PORT, SECRET_KEY } from './config';
|
||||
import { User } from './db/index';
|
||||
|
||||
const app = new Koa()
|
||||
const app = new Koa();
|
||||
|
||||
app.keys = [SECRET_KEY]
|
||||
app.keys = [SECRET_KEY];
|
||||
new Pug( { viewPath: path.resolve(__dirname, '../../views'), app: app } );
|
||||
app.use(koaBody( { multipart: true } ));
|
||||
app.use(session({}, app));
|
||||
|
||||
new Pug({
|
||||
viewPath: path.join(__dirname, "../../views"),
|
||||
}).use(app)
|
||||
app.use(koaBody({
|
||||
multipart: true,
|
||||
}))
|
||||
app.use(session({
|
||||
}, app))
|
||||
app.use(mount('/assets', koaStaticCache(__dirname + '/../client')));
|
||||
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
app.use(mount("/assets", require("koa-static-cache")(__dirname + "/../client")))
|
||||
const router = new Router();
|
||||
|
||||
const router = new Router()
|
||||
router.use('/api', apiRouter.routes());
|
||||
|
||||
router.use("/api", apiRouter.routes())
|
||||
router.get('/*', async (ctx: Koa.ParameterizedContext) =>
|
||||
{
|
||||
let user;
|
||||
if (ctx.session.user)
|
||||
{
|
||||
user = await User.findById(ctx.session.user);
|
||||
user = JSON.stringify(user).replace(/[\u0080-\uFFFF]/g, (chr) => '\\u' + ('0000' + chr.charCodeAt(0).toString(16)).substr(-4) );
|
||||
user = new Buffer(user, 'binary').toString('base64');
|
||||
}
|
||||
if (!ctx.session.csrfToken)
|
||||
ctx.session.csrfToken = rndstr();
|
||||
return ctx.render('index',
|
||||
{
|
||||
GIT_COMMIT,
|
||||
user,
|
||||
csrfToken: ctx.session.csrfToken
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/*", async (ctx) => {
|
||||
var user
|
||||
if (ctx.session!.user) {
|
||||
user = await User.findById(ctx.session!.user)
|
||||
user = JSON.stringify(user).replace(/[\u0080-\uFFFF]/g, (chr) => {
|
||||
return "\\u" + ("0000" + chr.charCodeAt(0).toString(16)).substr(-4)
|
||||
})
|
||||
user = new Buffer(user, "binary").toString("base64")
|
||||
}
|
||||
if (!ctx.session!.csrfToken) {
|
||||
ctx.session!.csrfToken = rndstr()
|
||||
}
|
||||
ctx.render("index.pug", {
|
||||
GIT_COMMIT,
|
||||
user,
|
||||
csrfToken: ctx.session!.csrfToken,
|
||||
})
|
||||
})
|
||||
|
||||
app.use(router.routes())
|
||||
app.listen(PORT, () => {
|
||||
console.log("listen for http://localhost:" + PORT)
|
||||
})
|
||||
app.use(router.routes());
|
||||
app.listen(PORT, () => console.log('listening for http://localhost:' + PORT) ); // eslint-disable-line no-console
|
||||
|
@ -1,7 +1,7 @@
|
||||
export function cutText(s: string, l: number) {
|
||||
if (s.length <= l) {
|
||||
return s
|
||||
} else {
|
||||
return s.slice(0, l - 1) + "…"
|
||||
}
|
||||
export function cutText(s: string, l: number): string
|
||||
{
|
||||
if (s.length <= l)
|
||||
return s;
|
||||
else
|
||||
return s.slice(0, l - 1) + '…';
|
||||
}
|
||||
|
36
src/server/utils/detectInstance.ts
Normal file
36
src/server/utils/detectInstance.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
type nodeinfoMeta =
|
||||
{
|
||||
links: nodeinfoVersionList[];
|
||||
};
|
||||
|
||||
type nodeinfoVersionList =
|
||||
{
|
||||
rel: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
type nodeinfo =
|
||||
{
|
||||
version: string;
|
||||
software: { name: string; version: string };
|
||||
// irrelevent properties skipped
|
||||
}
|
||||
|
||||
export default async function detectInstance(url: string): Promise<string|undefined>
|
||||
{
|
||||
const parsedURL = new URL(url);
|
||||
if(parsedURL.hostname === 'twitter.com')
|
||||
return 'twitter';
|
||||
|
||||
// fediverse
|
||||
const nodeinfoMeta: nodeinfoMeta = await fetch(`${parsedURL.origin}/.well-known/nodeinfo`).then(r => r.json());
|
||||
const nodeinfoLink = nodeinfoMeta.links.find(elem => elem.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
|
||||
|
||||
// TODO: add support for 1.0 as a fallback? All latest versions of major AP softwares seem to support 2.0 tho
|
||||
if(!nodeinfoLink)
|
||||
return undefined;
|
||||
const nodeinfo: nodeinfo = await fetch(`${nodeinfoLink.href}`).then(r => r.json());
|
||||
return nodeinfo.software.name;
|
||||
}
|
8
src/server/utils/misskey_entities/app.ts
Normal file
8
src/server/utils/misskey_entities/app.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type App =
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
callbackUrl: string | null;
|
||||
permission?: Array<string>;
|
||||
secret?: string;
|
||||
}
|
9
src/server/utils/misskey_entities/blocking.ts
Normal file
9
src/server/utils/misskey_entities/blocking.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { User } from './user';
|
||||
|
||||
export type Blocking =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
blockeeId: string;
|
||||
blockee: User;
|
||||
}
|
12
src/server/utils/misskey_entities/drivefile.ts
Normal file
12
src/server/utils/misskey_entities/drivefile.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type DriveFile =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
type: string;
|
||||
md5: string;
|
||||
size: number;
|
||||
url: string | null;
|
||||
folderId: string | null;
|
||||
isSensitive: boolean;
|
||||
}
|
10
src/server/utils/misskey_entities/drivefolder.ts
Normal file
10
src/server/utils/misskey_entities/drivefolder.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type DriveFolder =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
foldersCount?: number;
|
||||
filesCount?: number;
|
||||
parentId: string | null;
|
||||
parent?: DriveFolder | null;
|
||||
}
|
9
src/server/utils/misskey_entities/error.ts
Normal file
9
src/server/utils/misskey_entities/error.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type Error =
|
||||
{
|
||||
error:
|
||||
{
|
||||
code: string;
|
||||
message: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
11
src/server/utils/misskey_entities/following.ts
Normal file
11
src/server/utils/misskey_entities/following.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { User } from './user';
|
||||
|
||||
export type Following =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
followeeId: string;
|
||||
followee?: User;
|
||||
followerId: string;
|
||||
follower?: User;
|
||||
}
|
10
src/server/utils/misskey_entities/hashtag.ts
Normal file
10
src/server/utils/misskey_entities/hashtag.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type Hashtag =
|
||||
{
|
||||
tag: string;
|
||||
mentionedUsersCount: number;
|
||||
mentionedLocalUsersCount: number;
|
||||
mentionedRemoteUsersCount: number;
|
||||
attachedUsersCount: number;
|
||||
attahedLocalUsersCount: number;
|
||||
attachedRemoteUsersCount: number;
|
||||
}
|
20
src/server/utils/misskey_entities/messagingmessage.ts
Normal file
20
src/server/utils/misskey_entities/messagingmessage.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { User } from './user';
|
||||
import { UserGroup } from './usergroup';
|
||||
import { DriveFile } from './drivefile';
|
||||
|
||||
export type MessagingMessage =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
user?: User;
|
||||
text: string | null;
|
||||
fileId?: string | null;
|
||||
file?: DriveFile | null;
|
||||
recipientId: string | null;
|
||||
recipient?: User | null;
|
||||
groupId: string | null;
|
||||
group?: UserGroup | null;
|
||||
isRead?: boolean;
|
||||
reads?: Array<string>;
|
||||
}
|
9
src/server/utils/misskey_entities/muting.ts
Normal file
9
src/server/utils/misskey_entities/muting.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { User } from './user';
|
||||
|
||||
export type Muting =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
muteeId: string;
|
||||
mutee: User;
|
||||
}
|
26
src/server/utils/misskey_entities/note.ts
Normal file
26
src/server/utils/misskey_entities/note.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { User } from './user';
|
||||
import { DriveFile } from './drivefile';
|
||||
|
||||
export type Note =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
text: string | null;
|
||||
cw?: string | null;
|
||||
userId: string;
|
||||
user: User;
|
||||
replyId?: string | null;
|
||||
renoteId?: string | null;
|
||||
reply?: Note | null;
|
||||
renote?: Note | null;
|
||||
viaMobile?: boolean;
|
||||
isHidden?: boolean;
|
||||
visibility: string;
|
||||
mentions?: Array<string>;
|
||||
visibleUserIds?: Array<string>;
|
||||
fileIds?: Array<string>;
|
||||
files?: Array<DriveFile>;
|
||||
tags?: Array<string>;
|
||||
poll?: object | null; // FIXME: poll
|
||||
geo?: object | null; // FIXME: geo
|
||||
}
|
7
src/server/utils/misskey_entities/notefavorite.ts
Normal file
7
src/server/utils/misskey_entities/notefavorite.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type NoteFavorite =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
note: string;
|
||||
noteId: string;
|
||||
}
|
9
src/server/utils/misskey_entities/notereaction.ts
Normal file
9
src/server/utils/misskey_entities/notereaction.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { User } from './user';
|
||||
|
||||
export type NoteReaction =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: User;
|
||||
type: string;
|
||||
}
|
22
src/server/utils/misskey_entities/notification.ts
Normal file
22
src/server/utils/misskey_entities/notification.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { User } from './user';
|
||||
|
||||
enum NotificationType // TODO: bring this out
|
||||
{
|
||||
follow = 'follow',
|
||||
receiveFollowRequest = 'receiveFollowRequest',
|
||||
mention = 'mention',
|
||||
reply = 'reply',
|
||||
renote = 'renote',
|
||||
quote = 'quote',
|
||||
reaction = 'reaction',
|
||||
pollVote = 'pollVote'
|
||||
}
|
||||
|
||||
export type Notification =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
type: NotificationType;
|
||||
userId?: string | null;
|
||||
user?: User | null;
|
||||
}
|
15
src/server/utils/misskey_entities/page.ts
Normal file
15
src/server/utils/misskey_entities/page.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { User } from './user';
|
||||
|
||||
export type Page =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
name: string;
|
||||
summary: string | null;
|
||||
content: Array<void>;
|
||||
variables: Array<void>;
|
||||
userId: string;
|
||||
user: User;
|
||||
}
|
32
src/server/utils/misskey_entities/user.ts
Normal file
32
src/server/utils/misskey_entities/user.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Note } from './note';
|
||||
|
||||
export type User =
|
||||
{
|
||||
id: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
url?: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarColor: any | null; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
bannerUrl?: string | null;
|
||||
bannerColor?: any | null; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
emojis: any | null; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
host: string | null;
|
||||
description?: string | null;
|
||||
birthday?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string | null;
|
||||
location?: string | null;
|
||||
followersCount?: number;
|
||||
followingCount?: number;
|
||||
notesCount?: number;
|
||||
isBot?: boolean;
|
||||
pinnedNoteIds?: Array<string>;
|
||||
pinnedNotes?: Array<Note>;
|
||||
isCat?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isModerator?: boolean;
|
||||
isLocked?: boolean;
|
||||
hasUnreadSpecifiedNotes?: boolean;
|
||||
hasUnreadMentions?: boolean;
|
||||
}
|
8
src/server/utils/misskey_entities/usergroup.ts
Normal file
8
src/server/utils/misskey_entities/usergroup.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type UserGroup =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
userIds?: Array<string>;
|
||||
}
|
7
src/server/utils/misskey_entities/userlist.ts
Normal file
7
src/server/utils/misskey_entities/userlist.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type UserList =
|
||||
{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
userIds?: Array<string>;
|
||||
}
|
@ -1,23 +1,26 @@
|
||||
import * as OAuth from "oauth-1.0a"
|
||||
import * as OAuth from 'oauth-1.0a';
|
||||
|
||||
export default new class QueryStringUtils {
|
||||
decode(query: string) {
|
||||
const res: {[key: string]: string} = {}
|
||||
query.split("&").map((q) => {
|
||||
const splitedQuery = q.split("=")
|
||||
if (splitedQuery.length < 2) {
|
||||
return
|
||||
}
|
||||
const name = splitedQuery[0]
|
||||
const value = splitedQuery.slice(1).join("=")
|
||||
res[name] = decodeURIComponent(value)
|
||||
})
|
||||
return res
|
||||
}
|
||||
export default new class QueryStringUtils
|
||||
{
|
||||
decode(query: string): { [key: string]: string }
|
||||
{
|
||||
const res: {[key: string]: string} = {};
|
||||
query.split('&').map((q) =>
|
||||
{
|
||||
const splitedQuery = q.split('=');
|
||||
if (splitedQuery.length < 2)
|
||||
return;
|
||||
const name = splitedQuery[0];
|
||||
const value = splitedQuery.slice(1).join('=');
|
||||
res[name] = decodeURIComponent(value);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
encode(params: {[key: string]: any}) {
|
||||
return Object.keys(params).map((key) => {
|
||||
return encodeURIComponent(key) + "=" + OAuth.prototype.percentEncode(params[key])
|
||||
}).join("&")
|
||||
}
|
||||
}()
|
||||
encode(params: {[key: string]: any}): string // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
{
|
||||
return Object.keys(params).map(
|
||||
(key) => encodeURIComponent(key) + '=' + OAuth.prototype.percentEncode(params[key])
|
||||
).join('&');
|
||||
}
|
||||
}();
|
||||
|
@ -1,28 +1,37 @@
|
||||
import fetch, { Response } from "node-fetch"
|
||||
import * as OAuth from "oauth-1.0a"
|
||||
import QueryStringUtils from "./queryString"
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import * as OAuth from 'oauth-1.0a';
|
||||
import QueryStringUtils from './queryString';
|
||||
|
||||
export function requestOAuth(oauth: OAuth, options: OAuth.RequestOptions, token?: OAuth.Token) {
|
||||
const opt = {
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
body: QueryStringUtils.encode(options.data),
|
||||
headers: {
|
||||
...oauth.toHeader(oauth.authorize(options, token)),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
if (options.method === "GET") {
|
||||
opt.url += "?" + opt.body
|
||||
delete opt.body
|
||||
delete opt.headers["Content-Type"]
|
||||
}
|
||||
return fetch(opt.url, opt).then((r) => {
|
||||
if (!r.ok) {
|
||||
return r.text().then((text) => {
|
||||
throw new Error("API Error: " + text)
|
||||
}) as Promise<Response>
|
||||
}
|
||||
return Promise.resolve(r)
|
||||
})
|
||||
export function requestOAuth(oauth: OAuth, options: OAuth.RequestOptions, token?: OAuth.Token): Promise<Response>
|
||||
{
|
||||
const opt =
|
||||
{
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
body: QueryStringUtils.encode(options.data),
|
||||
headers:
|
||||
{
|
||||
...oauth.toHeader(oauth.authorize(options, token)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
};
|
||||
|
||||
if (options.method === 'GET')
|
||||
{
|
||||
opt.url += '?' + opt.body;
|
||||
delete opt.body;
|
||||
delete opt.headers['Content-Type'];
|
||||
}
|
||||
|
||||
return fetch(opt.url, opt).then((r) =>
|
||||
{
|
||||
if (!r.ok)
|
||||
{
|
||||
return r.text().then((text) =>
|
||||
{
|
||||
throw new Error('API Error: ' + text);
|
||||
});
|
||||
}
|
||||
return Promise.resolve(r);
|
||||
});
|
||||
}
|
||||
|
@ -1,11 +1,16 @@
|
||||
import * as mongoose from "mongoose"
|
||||
export default (schema: mongoose.Schema, transformer: (doc: any, ret: any) => any) => {
|
||||
if ((schema as any).options.toObject == null) {
|
||||
(schema as any).options.toObject = {}
|
||||
}
|
||||
if ((schema as any).options.toJSON == null) {
|
||||
(schema as any).options.toJSON = {}
|
||||
}
|
||||
(schema as any).options.toObject.transform = transformer;
|
||||
(schema as any).options.toJSON.transform = transformer
|
||||
}
|
||||
/* eslint @typescript-eslint/no-explicit-any: 0 */
|
||||
/* eslint @typescript-eslint/explicit-function-return-type: 0 */
|
||||
/* eslint eqeqeq: 0 */
|
||||
|
||||
import * as mongoose from 'mongoose';
|
||||
export default (schema: mongoose.Schema, transformer: (doc: any, ret: any) => any) =>
|
||||
{
|
||||
if ((schema as any).options.toObject == null)
|
||||
(schema as any).options.toObject = {};
|
||||
|
||||
if ((schema as any).options.toJSON == null)
|
||||
(schema as any).options.toJSON = {};
|
||||
|
||||
(schema as any).options.toObject.transform = transformer;
|
||||
(schema as any).options.toJSON.transform = transformer;
|
||||
};
|
||||
|
@ -1,16 +1,21 @@
|
||||
import * as crypto from "crypto"
|
||||
import * as OAuth from "oauth-1.0a"
|
||||
import * as crypto from 'crypto';
|
||||
import OAuth from 'oauth-1.0a';
|
||||
|
||||
const twitterClient = new OAuth({
|
||||
consumer: {
|
||||
key: process.env.TWITTER_CONSUMER_KEY!,
|
||||
secret: process.env.TWITTER_CONSUMER_SECRET!,
|
||||
},
|
||||
signature_method: "HMAC-SHA1",
|
||||
hash_function(baseString, key) {
|
||||
return crypto.createHmac("sha1", key).update(baseString).digest("base64")
|
||||
},
|
||||
realm: "",
|
||||
})
|
||||
const twitterClient = new OAuth(
|
||||
{
|
||||
consumer:
|
||||
{
|
||||
key: process.env.TWITTER_CONSUMER_KEY ?? '',
|
||||
secret: process.env.TWITTER_CONSUMER_SECRET ?? ''
|
||||
},
|
||||
signature_method: 'HMAC-SHA1',
|
||||
|
||||
export default twitterClient
|
||||
hash_function(baseString, key): string
|
||||
{
|
||||
return crypto.createHmac('sha1', key).update(baseString).digest('base64');
|
||||
},
|
||||
|
||||
realm: ''
|
||||
});
|
||||
|
||||
export default twitterClient;
|
||||
|
@ -19,7 +19,7 @@
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true /* Enable all strict type-checking options. */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
@ -51,9 +51,26 @@
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"strictPropertyInitialization": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": [
|
||||
"src/*.ts",
|
||||
"src/server/**/*.ts",
|
||||
"src/common/**/*.ts"
|
||||
"src/common/**/*.ts",
|
||||
"src/client/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"src/quesdon-client/**/*.ts",
|
||||
"src/quesdon-client/**/*.tsx"
|
||||
]
|
||||
}
|
14
tslint.json
14
tslint.json
@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"tslint:recommended"
|
||||
],
|
||||
"rules": {
|
||||
"semicolon": [true, "never"],
|
||||
"interface-name": false,
|
||||
"member-access": false,
|
||||
"curly": false,
|
||||
"no-console": false,
|
||||
"no-var-keyword": false,
|
||||
"object-literal-sort-keys": false
|
||||
}
|
||||
}
|
@ -1,24 +1,24 @@
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
meta(name="viewport",content="width=device-width")
|
||||
if user
|
||||
script window.USER=JSON.parse(atob("!{user}"))
|
||||
else
|
||||
script window.USER=undefined
|
||||
script window.CSRF_TOKEN="#{csrfToken}"
|
||||
script window.GIT_VERSION="#{GIT_COMMIT}"
|
||||
script(src="https://cdn.polyfill.io/v2/polyfill.js?features=fetch")
|
||||
script(src="/assets/bundle.js?version="+GIT_COMMIT)
|
||||
body
|
||||
#root
|
||||
p ...어? 혹시 페이지가 나오지 않나요??
|
||||
p 불편을 끼쳐 드려 죄송해요. 새로고침을 해 보시고, 만약 그래도 이 화면만 계속 뜬다면 아래 동작 환경을 충족하는지 확인 후 Mastodon @planet@planet.moe 에 문의해 주세요.
|
||||
h2 동작 환경
|
||||
ul
|
||||
li iOS
|
||||
del 9.3.5
|
||||
| 10 이상 (추천: iOS 11.2.2 이상)
|
||||
li Chrome 63 이상
|
||||
li Firefox 57 이상
|
||||
p Edge, Internet Explorer는 지원 대상이 아니라서 오류가 발생할 수 있어요. Firefox나 Google Chrome을 추천드려요.
|
||||
head
|
||||
meta(name="viewport",content="width=device-width")
|
||||
if user
|
||||
script window.USER=JSON.parse(atob("!{user}"))
|
||||
else
|
||||
script window.USER=undefined
|
||||
script window.CSRF_TOKEN="#{csrfToken}"
|
||||
script window.GIT_VERSION="#{GIT_COMMIT}"
|
||||
script(src="https://cdn.polyfill.io/v2/polyfill.js?features=fetch")
|
||||
script(src="/assets/bundle.js?version="+GIT_COMMIT)
|
||||
body
|
||||
#root
|
||||
p ...어? 혹시 페이지가 나오지 않나요??
|
||||
p 불편을 끼쳐 드려 죄송해요. 새로고침을 해 보시고, 만약 그래도 이 화면만 계속 뜬다면 아래 동작 환경을 충족하는지 확인 후 Mastodon @planet@planet.moe 에 문의해 주세요.
|
||||
h2 동작 환경
|
||||
ul
|
||||
li iOS
|
||||
del 9.3.5
|
||||
| 10 이상 (추천: iOS 11.2.2 이상)
|
||||
li Chrome 63 이상
|
||||
li Firefox 57 이상
|
||||
p Edge, Internet Explorer는 지원 대상이 아니라서 오류가 발생할 수 있어요. Firefox나 Google Chrome을 추천드려요.
|
7587
yarn-error.log
Normal file
7587
yarn-error.log
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user