1
0
mirror of https://github.com/byulmaru/quesdon synced 2024-11-27 14:28:04 +09:00
This commit is contained in:
rinsuki 2018-01-21 02:26:52 +09:00
parent 77ffc2770b
commit 2518105e2e
16 changed files with 286 additions and 81 deletions

3
.gitignore vendored
View File

@ -1,2 +1,3 @@
dist/
node_modules/
node_modules/
.env

3
package-lock.json generated
View File

@ -4972,6 +4972,9 @@
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true
},
"oauth-1.0a": {
"version": "github:rinsuki/oauth-1.0a#b6a8b9cfd4f9621c03a57379f4f04ce79e2f4248"
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@ -63,6 +63,7 @@
"mongoose": "^4.13.6",
"mongoose-autopopulate": "^0.6.0",
"node-fetch": "^1.7.3",
"oauth-1.0a": "github:rinsuki/oauth-1.0a",
"parse-link-header": "^1.0.1",
"rndstr": "^1.0.0"
}

View File

@ -19,6 +19,7 @@ export interface APIUser {
createdAt: string
name: string
acct: string
acctDisplay: string
avatarUrl: string
url: string
allAnon: boolean
@ -26,4 +27,5 @@ export interface APIUser {
description: string | undefined
hostName: string
pushbulletEnabled: boolean
isTwitter: boolean
}

View File

@ -26,7 +26,7 @@ export default class Header extends React.Component<{}, State> {
<Nav className="mr-auto" navbar>
<NavItem>
{me
? <NavLink to="/my">@{me.acct}<QuestionRemaining/></NavLink>
? <NavLink to="/my">@{me.acctDisplay}<QuestionRemaining/></NavLink>
: <NavLink to="/login"></NavLink>}
</NavItem>
</Nav>

View File

@ -101,7 +101,7 @@ export default class PageMySettings extends React.Component<{},State> {
allDeleteQuestions() {
if (!me) return
const rand = Math.floor(Math.random() * 9) + 1
if (prompt(`あなた(@${me.acct})あてに来た質問を「回答済みのものも含めて全て」削除します。
if (prompt(`あなた(@${me.acctDisplay})あてに来た質問を「回答済みのものも含めて全て」削除します。
${rand}()`, "") != rand.toString()) return
apiFetch("/api/web/questions/all_delete", {

View File

@ -38,7 +38,7 @@ export default class PageUserIndex extends React.Component<Props,State> {
const { user } = this.state
if (!user) return <Loading/>
return <div>
<Title>{user.name} @{user.acct} {user.questionBoxName}</Title>
<Title>{user.name} @{user.acctDisplay} {user.questionBoxName}</Title>
<Jumbotron><div style={{textAlign: "center"}}>
<img src={user.avatarUrl} style={{maxWidth: "8em", height: "8em"}}/>
<h1>{user.name}</h1>
@ -46,7 +46,7 @@ export default class PageUserIndex extends React.Component<Props,State> {
{user.questionBoxName || "質問箱"}&nbsp;
<a href={user.url || `https://${user.hostName}/@${user.acct.split("@")[0]}`}
rel="nofollow">
Mastodonのプロフィール
{user.isTwitter ? "Twitter" : "Mastodon"}
</a>
</p>
<p>{user.description}</p>

View File

@ -44,7 +44,7 @@ export default class Question extends React.Component<Props, State> {
{ this.state.nsfwGuard && <div className="nsfw-guard" onClick={this.nsfwGuardClick.bind(this)}>
<div>
<div></div>
{ !this.props.hideAnswerUser && <div>: @{this.props.user.acct}</div>}
{ !this.props.hideAnswerUser && <div>: @{this.props.user.acctDisplay}</div>}
<div>/</div>
</div>
</div> }

View File

@ -9,7 +9,7 @@ export default class UserLink extends React.Component<Props> {
render() {
return <Link to={`/@${this.props.acct}`}>
{this.props.name}
<span className="text-muted">&nbsp;@{this.props.acct}</span>
<span className="text-muted">&nbsp;@{this.props.acctDisplay}</span>
</Link>
}
}

View File

@ -3,80 +3,178 @@ import { MastodonApp, User } from "../../db/index"
import fetch from "node-fetch"
import { BASE_URL } from "../../config";
import rndstr from "rndstr"
import requestOAuth from "../../utils/requestOAuth";
import QueryStringUtils from "../../utils/queryString"
import twitterClient from "../../utils/twitterClient"
var router = new Router
router.post("/get_url", async ctx => {
const hostName = ctx.request.body.fields.instance
.replace(/.*@/, "")
.replace(/.*@/, "").toLowerCase()
if(~hostName.indexOf("/")) return ctx.reject(400, "not use slash in hostname")
const redirectUri = BASE_URL+"/api/web/oauth/redirect"
var app = await MastodonApp.findOne({hostName, appBaseUrl: BASE_URL, redirectUri})
if (!app) {
const res = await fetch("https://"+hostName+"/api/v1/apps", {
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
url = `https://${app.hostName}/oauth/authorize?client_id=${app.clientId}&scope=read+write&redirect_uri=${redirectUri}&response_type=code&state=${ctx.session!.loginState}`
}
} 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",
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()
data: {}
}).then(r => r.text()).then(r => QueryStringUtils.decode(r))
console.log(requestTokenRes)
var requestToken = {
token: requestTokenRes.oauth_token,
secret: requestTokenRes.oauth_token_secret
}
ctx.session!.twitterOAuth = requestToken
url = `https://twitter.com/oauth/authorize?oauth_token=${requestToken.token}`
}
ctx.session!.loginState = rndstr()+"_"+app.id
ctx.body = {
url: `https://${app.hostName}/oauth/authorize?client_id=${app.clientId}&scope=read+write&redirect_uri=${redirectUri}&response_type=code&state=${ctx.session!.loginState}`
url
}
})
router.get("/redirect", async ctx => {
console.log(ctx.session)
if (ctx.query.state != ctx.session!.loginState) {
ctx.redirect("/login?error=invalid_state")
return
var profile: {
id: string
name: string
screenName: string
avatarUrl: string
accessToken: string
hostName: string
url: string
acct: string
}
const app = await MastodonApp.findById(ctx.session!.loginState.split("_")[1])
if (app == null) {
ctx.redirect("/login?error=app_notfound")
return
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())
console.log(res)
const myProfile = await fetch("https://"+app.hostName+"/api/v1/accounts/verify_credentials", {
headers: {"Authorization": "Bearer "+res.access_token}
}).then(r => r.json())
console.log(myProfile)
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.display_name + "@" + 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})
}
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())
console.log(res)
const myProfile = await fetch("https://"+app.hostName+"/api/v1/accounts/verify_credentials", {
headers: {"Authorization": "Bearer "+res.access_token}
}).then(r => r.json())
console.log(myProfile)
const acct = myProfile.username + "@" + app.hostName
var user = await User.findOne({acctLower: acct.toLowerCase()})
if (user == null) {
user = new User
user.acct = acct
user.acctLower = acct.toLowerCase()
user.app = app
}
user.name = myProfile.display_name || myProfile.username
user.avatarUrl = myProfile.avatar_static
user.accessToken = res.access_token
user.url = myProfile.url
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")

View File

@ -3,6 +3,9 @@ import * as mongoose from "mongoose"
import { User, Question, IMastodonApp, QuestionLike, IUser } from "../../db/index";
import fetch from "node-fetch";
import { BASE_URL } from "../../config";
import twitterClient from "../../utils/twitterClient"
import requestOAuth from "../../utils/requestOAuth";
import cutText from "../../utils/cutText";
var router = new Router
@ -50,26 +53,45 @@ router.post("/:id/answer", async ctx => {
ctx.body = {status: "ok"}
const user = await User.findById(ctx.session!.user)
if (!~["public","unlisted","private"].indexOf(ctx.request.body.fields.visibility)) return
var body = {
spoiler_text: "Q. "+question.question + " #quesdon",
status: "A. " + (question.answer!.length > 200 ? question.answer!.substring(0,200) + "...(続きはリンク先で)" : question.answer) + "\n#quesdon "+BASE_URL+"/@"+user!.acct+"/questions/"+question.id,
visibility: ctx.request.body.fields.visibility
}
if (question.questionUser) {
body.status = "質問者: @"+question.questionUser.acct + "\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"
if (!user) return
const isTwitter = user.hostName == "twitter.com"
const answerCharMax = isTwitter ? (110-question.question.length) : 200
const answerUrl = "https://example.com"+"/@"+user!.acct+"/questions/"+question.id
if (!isTwitter) { // Mastodon
var body = {
spoiler_text: "Q. "+question.question + " #quesdon",
status: "A. " + (question.answer!.length > 200 ? question.answer!.substring(0,200) + "...(続きはリンク先で)" : question.answer) + "\n#quesdon "+BASE_URL+"/@"+user!.acct+"/questions/"+question.id,
visibility: ctx.request.body.fields.visibility
}
})
if (question.questionUser) {
body.status = "質問者: @"+question.questionUser.acct + "\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
console.log(body, body.length)
requestOAuth(twitterClient, {
url: "https://api.twitter.com/1.1/statuses/update.json",
method: "POST",
data: {status: body}
}, {
key, secret
})
}
})
router.post("/:id/delete", async ctx => {

View File

@ -5,7 +5,7 @@ import setTransformer from "../utils/setTransformer"
var schema = new mongoose.Schema({
acct: {type: String, required: true},
acctLower: {type: String, required: true, unique: true},
app: {type: mongoose.Schema.Types.ObjectId, required: true, ref: "mastodon_apps"},
app: {type: mongoose.Schema.Types.ObjectId, ref: "mastodon_apps"},
name: {type: String, required: true},
avatarUrl: {type: String, required: true},
accessToken: {type: String, required: true},
@ -14,6 +14,8 @@ var schema = new mongoose.Schema({
questionBoxName: {type: String, default: "質問箱"},
pushbulletAccessToken: {type: String},
allAnon: {type: Boolean, default: false},
upstreamId: {type: String},
hostName: {type: String},
}, {
timestamps: true
})
@ -24,7 +26,10 @@ setTransformer(schema, (doc: IUser, ret: any) => {
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
})
@ -40,6 +45,8 @@ export interface IUser extends mongoose.Document {
questionBoxName: string
pushbulletAccessToken: string | null
allAnon: boolean
upstreamId: string | null
hostName: string | null
}
export default mongoose.model("users", schema) as mongoose.Model<IUser>

View File

@ -0,0 +1,7 @@
export default function cutText(s: string, l: number) {
if (s.length <= l) {
return s
} else {
return s.slice(0, l-1)+"…"
}
}

View File

@ -0,0 +1,23 @@
import * as OAuth from "oauth-1.0a"
export default new class QueryStringUtils{
decode(query: string) {
var 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("&")
}
}()

View File

@ -0,0 +1,25 @@
import * as OAuth from "oauth-1.0a"
import fetch, { Response } from "node-fetch"
import QueryStringUtils from "./queryString";
export default function request(oauth: OAuth, options: OAuth.RequestOptions, token: OAuth.Token | undefined = undefined) {
var 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(r => {
throw "API Error: "+r
}) as Promise<Response>
}
return Promise.resolve(r)
})
}

View File

@ -0,0 +1,16 @@
import * as OAuth from "oauth-1.0a"
import * as crypto from "crypto"
const twitterClient = new OAuth({
consumer: {
key: process.env.TWITTER_CONSUMER_KEY!,
secret: process.env.TWITTER_CONSUMER_SECRET!,
},
signature_method: "HMAC-SHA1",
hash_function(base_string, key) {
return crypto.createHmac("sha1", key).update(base_string).digest("base64")
},
realm: ""
})
export default twitterClient