1
0
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:
robin* 2019-11-11 06:54:00 +00:00
commit a71a9f0bb2
76 changed files with 14382 additions and 5697 deletions

View File

@ -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
View 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
View 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
View 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
View 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.

View File

@ -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 템플릿 엔진 사용)

View File

@ -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
})()

View File

@ -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"

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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>
);
}
}

View File

@ -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>;
}
}

View File

@ -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>;
}
}

View File

@ -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>;
}
}

View File

@ -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>;
}
}

View File

@ -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>&nbsp;
<a href={upstreamUrl}> </a>&nbsp;
(<a href={`${upstreamUrl}/commits/${gitVersion}`}>{gitVersion.slice(0, 7)}</a>) /&nbsp;
<a href="https://github.com/rinsuki/quesdon"> (rinsuki/quesdon)</a>&nbsp;
</p>
<p>
<a href="https://github.com/rinsuki/quesdon"> (rinsuki/quesdon)</a>&nbsp;
</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();
}
}

View File

@ -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});
}
}

View File

@ -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>;
}
}

View File

@ -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>;
}
}

View File

@ -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
});
}
}

View File

@ -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;
}
}

View File

@ -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
});
}
}

View File

@ -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 = '/';
});
}
}

View File

@ -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)}`;
}
}

View File

@ -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();
}
}

View File

@ -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>;
}
}

View File

@ -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 || "질문함"}&nbsp;
<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 || '질문함'}&nbsp;
<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>&nbsp;{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>&nbsp;{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
});
}
}

View File

@ -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>;
}
}

View File

@ -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});
}
}

View File

@ -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">
:&nbsp;
<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">
:&nbsp;
<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});
}
}

View File

@ -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">&nbsp;@{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">&nbsp;@{this.props.acctDisplay}</span>
</Link>;
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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'
];

View File

@ -1 +1 @@
export const QUESTION_TEXT_MAX_LENGTH = 200
export const QUESTION_TEXT_MAX_LENGTH = 200;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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
};

View File

@ -0,0 +1,5 @@
declare module 'mongoose-autopopulate'
{
import { Schema } from 'mongoose';
export default function autopopulate(schema: Schema): void;
}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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) + '…';
}

View 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;
}

View File

@ -0,0 +1,8 @@
export type App =
{
id: string;
name: string;
callbackUrl: string | null;
permission?: Array<string>;
secret?: string;
}

View File

@ -0,0 +1,9 @@
import { User } from './user';
export type Blocking =
{
id: string;
createdAt: string;
blockeeId: string;
blockee: User;
}

View 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;
}

View File

@ -0,0 +1,10 @@
export type DriveFolder =
{
id: string;
createdAt: string;
name: string;
foldersCount?: number;
filesCount?: number;
parentId: string | null;
parent?: DriveFolder | null;
}

View File

@ -0,0 +1,9 @@
export type Error =
{
error:
{
code: string;
message: string;
id: string;
};
}

View File

@ -0,0 +1,11 @@
import { User } from './user';
export type Following =
{
id: string;
createdAt: string;
followeeId: string;
followee?: User;
followerId: string;
follower?: User;
}

View File

@ -0,0 +1,10 @@
export type Hashtag =
{
tag: string;
mentionedUsersCount: number;
mentionedLocalUsersCount: number;
mentionedRemoteUsersCount: number;
attachedUsersCount: number;
attahedLocalUsersCount: number;
attachedRemoteUsersCount: number;
}

View 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>;
}

View File

@ -0,0 +1,9 @@
import { User } from './user';
export type Muting =
{
id: string;
createdAt: string;
muteeId: string;
mutee: User;
}

View 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
}

View File

@ -0,0 +1,7 @@
export type NoteFavorite =
{
id: string;
createdAt: string;
note: string;
noteId: string;
}

View File

@ -0,0 +1,9 @@
import { User } from './user';
export type NoteReaction =
{
id: string;
createdAt: string;
user: User;
type: string;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -0,0 +1,8 @@
export type UserGroup =
{
id: string;
createdAt: string;
name: string;
ownerId: string;
userIds?: Array<string>;
}

View File

@ -0,0 +1,7 @@
export type UserList =
{
id: string;
createdAt: string;
name: string;
userIds?: Array<string>;
}

View File

@ -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('&');
}
}();

View File

@ -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);
});
}

View File

@ -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;
};

View File

@ -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;

View File

@ -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"
]
}

View File

@ -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
}
}

View File

@ -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
| &nbsp;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
| &nbsp;10 이상 (추천: iOS 11.2.2 이상)
li Chrome 63 이상
li Firefox 57 이상
p Edge, Internet Explorer는 지원 대상이 아니라서 오류가 발생할 수 있어요. Firefox나 Google Chrome을 추천드려요.

7587
yarn-error.log Normal file

File diff suppressed because it is too large Load Diff

7621
yarn.lock

File diff suppressed because it is too large Load Diff