Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,13 @@ TWITCH_OAUTH_STATE_TTL_SECONDS=600
TWITCH_VERIFY_SUCCESS_REDIRECT=/auth
TWITCH_VERIFY_ERROR_REDIRECT=/auth
TWITCH_OAUTH_SCOPE=
TWITCH_CATEGORY_NAME=Pokemon Auto Chess
TWITCH_CATEGORY_NAME=Pokemon Auto Chess

YOUTUBE_CLIENT_ID=your_google_oauth_client_id
YOUTUBE_CLIENT_SECRET=your_google_oauth_client_secret
YOUTUBE_OAUTH_REDIRECT_URI=https://your-domain.com/auth/youtube/callback
YOUTUBE_OAUTH_STATE_SECRET=replace_with_a_long_random_secret_at_least_32_chars (node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))")
YOUTUBE_OAUTH_STATE_TTL_SECONDS=600
YOUTUBE_VERIFY_SUCCESS_REDIRECT=/auth
YOUTUBE_VERIFY_ERROR_REDIRECT=/auth
YOUTUBE_OAUTH_SCOPE=https://www.googleapis.com/auth/youtube.readonly
80 changes: 78 additions & 2 deletions app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import AfterGameRoom from "./rooms/after-game-room"
import CustomLobbyRoom from "./rooms/custom-lobby-room"
import GameRoom from "./rooms/game-room"
import PreparationRoom from "./rooms/preparation-room"
import { buyBoosterForUser, openBoosterForUser } from "./services/booster"
import {
addBotToDatabase,
approveBot,
Expand All @@ -48,7 +49,6 @@ import {
buyEmotionForUser,
changeSelectedEmotionForUser
} from "./services/collection"
import { buyBoosterForUser, openBoosterForUser } from "./services/booster"
import { getLeaderboard } from "./services/leaderboard"
import {
computeSynergyAverages,
Expand All @@ -68,6 +68,11 @@ import {
startTwitchAccountVerification,
unlinkTwitchAccount
} from "./services/twitch"
import {
completeYouTubeAccountVerification,
startYouTubeAccountVerification,
unlinkYouTubeAccount
} from "./services/youtube"
import { ISuggestionUser, Role } from "./types"
import { DungeonPMDO } from "./types/enum/Dungeon"
import { Emotion } from "./types/enum/Emotion"
Expand Down Expand Up @@ -537,6 +542,72 @@ export const server = defineServer({
}
})

app.post("/youtube/verify/start", async (req, res) => {
const userAuth = await authUser(req, res)
if (!userAuth) return

try {
const payload = await startYouTubeAccountVerification(userAuth.uid)
res.status(200).json(payload)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
logger.error("Error starting YouTube verification", { error: message })
if (
message.includes("not configured") ||
message.includes("REDIRECT") ||
message.includes("STATE_SECRET")
) {
res.status(503).json({ error: message })
return
}
res.status(400).json({ error: message })
}
})

app.get("/auth/youtube/callback", async (req, res) => {
const state = req.query.state?.toString()
const code = req.query.code?.toString()
const successRedirect =
process.env.YOUTUBE_VERIFY_SUCCESS_REDIRECT || "/auth"
const errorRedirect = process.env.YOUTUBE_VERIFY_ERROR_REDIRECT || "/auth"

if (!state || !code) {
return res.redirect(`${errorRedirect}?youtubeVerify=missing_params`)
}

try {
await completeYouTubeAccountVerification(code, state)
return res.redirect(`${successRedirect}?youtubeVerify=success`)
} catch (error) {
const message =
error instanceof Error
? encodeURIComponent(error.message)
: "verification_failed"
logger.error("Error completing YouTube verification", {
error: message
})
return res.redirect(`${errorRedirect}?youtubeVerify=${message}`)
}
})

app.post("/youtube/verify/unlink", async (req, res) => {
const userAuth = await authUser(req, res)
if (!userAuth) return

try {
await unlinkYouTubeAccount(userAuth.uid)
res.status(200).send()
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
if (message === "User not found") {
res.status(404).json({ error: message })
return
}
logger.error("Error unlinking YouTube verification", { error: message })
res.status(500).json({ error: "Error unlinking YouTube account" })
}
})

app.get("/game-history/:playerUid", async (req, res) => {
if (!isDevelopment) {
res.set("Cache-Control", "no-cache")
Expand Down Expand Up @@ -803,7 +874,12 @@ export const server = defineServer({
return
}

const result = await buyEmotionForUser(userAuth.uid, index, emotion, shiny)
const result = await buyEmotionForUser(
userAuth.uid,
index,
emotion,
shiny
)
if (!result) {
res.status(409).json({ error: "Not enough dust or invalid state" })
return
Expand Down
14 changes: 13 additions & 1 deletion app/models/colyseus-models/game-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export interface IGameUser {
anonymous: boolean
twitchLogin: string
twitchDisplayName: string
youtubeChannelId: string
youtubeHandle: string
youtubeChannelTitle: string
}
export class GameUser extends Schema implements IGameUser {
@type("string") uid: string
Expand All @@ -28,6 +31,9 @@ export class GameUser extends Schema implements IGameUser {
@type("boolean") anonymous: boolean
@type("string") twitchLogin: string
@type("string") twitchDisplayName: string
@type("string") youtubeChannelId: string
@type("string") youtubeHandle: string
@type("string") youtubeChannelTitle: string

constructor(
uid: string,
Expand All @@ -41,7 +47,10 @@ export class GameUser extends Schema implements IGameUser {
role: Role,
anonymous: boolean,
twitchLogin = "",
twitchDisplayName = ""
twitchDisplayName = "",
youtubeChannelId = "",
youtubeHandle = "",
youtubeChannelTitle = ""
) {
super()
this.uid = uid
Expand All @@ -56,5 +65,8 @@ export class GameUser extends Schema implements IGameUser {
this.anonymous = anonymous
this.twitchLogin = twitchLogin
this.twitchDisplayName = twitchDisplayName
this.youtubeChannelId = youtubeChannelId
this.youtubeHandle = youtubeHandle
this.youtubeChannelTitle = youtubeChannelTitle
}
}
20 changes: 20 additions & 0 deletions app/models/mongo-models/user-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ const userMetadataSchema = new Schema({
twitchVerificationRevokedAt: {
type: Date
},
youtubeChannelId: {
type: String
},
youtubeHandle: {
type: String,
trim: true
},
youtubeChannelTitle: {
type: String
},
youtubeVerifiedAt: {
type: Date
},
youtubeVerificationRevokedAt: {
type: Date
},
language: {
type: String,
default: "en"
Expand Down Expand Up @@ -137,6 +153,10 @@ userMetadataSchema.index(
userMetadataSchema.index({ titles: 1 })
userMetadataSchema.index({ twitchUserId: 1 }, { unique: true, sparse: true })
userMetadataSchema.index({ twitchLogin: 1 }, { unique: true, sparse: true })
userMetadataSchema.index(
{ youtubeChannelId: 1 },
{ unique: true, sparse: true }
)

export default model<IUserMetadataMongo>("UserMetadata", userMetadataSchema)

Expand Down
Binary file added app/public/src/assets/ui/youtube.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 38 additions & 1 deletion app/public/src/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import GameState from "../../rooms/states/game-state"
import LobbyState from "../../rooms/states/lobby-state"
import PreparationState from "../../rooms/states/preparation-state"
import { Emotion, Role, Title, Transfer } from "../../types"
import type { Booster } from "../../types/Booster"
import { CloseCodes } from "../../types/enum/CloseCodes"
import { EloRank } from "../../types/enum/EloRank.js"
import { BotDifficulty } from "../../types/enum/Game.js"
import { SpecialGameRule } from "../../types/enum/SpecialGameRule.js"
import type { Booster } from "../../types/Booster"
import { IUserMetadataJSON } from "../../types/interfaces/UserMetadata"
import { logger } from "../../utils/logger"
import { IBot } from "./models/bot-v2"
Expand Down Expand Up @@ -65,6 +65,11 @@ export type TwitchVerificationStartResponse = {
expiresAt: string
}

export type YouTubeVerificationStartResponse = {
authorizeUrl: string
expiresAt: string
}

export async function startTwitchVerification(): Promise<TwitchVerificationStartResponse> {
const token = await firebase.auth().currentUser?.getIdToken()
const res = await fetch("/twitch/verify/start", {
Expand Down Expand Up @@ -97,6 +102,38 @@ export async function unlinkTwitchVerification(): Promise<void> {
}
}

export async function startYouTubeVerification(): Promise<YouTubeVerificationStartResponse> {
const token = await firebase.auth().currentUser?.getIdToken()
const res = await fetch("/youtube/verify/start", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`
}
})

if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? res.statusText)
}

return res.json()
}

export async function unlinkYouTubeVerification(): Promise<void> {
const token = await firebase.auth().currentUser?.getIdToken()
const res = await fetch("/youtube/verify/unlink", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`
}
})

if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? res.statusText)
}
}

export const rooms: {
lobby: Room<{ state: LobbyState }> | undefined
preparation: Room<PreparationState> | undefined
Expand Down
47 changes: 34 additions & 13 deletions app/public/src/pages/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export default function Auth() {
navigator.maxTouchPoints > 0 &&
window.matchMedia("(orientation: portrait)").matches
const [modal, setModal] = React.useState<string | null>(null)
const [twitchCallbackMessage, setTwitchCallbackMessage] = React.useState<{
const [oauthCallbackMessage, setOauthCallbackMessage] = React.useState<{
platform: "Twitch" | "YouTube"
kind: "success" | "error"
body: string
} | null>(null)
Expand All @@ -33,24 +34,44 @@ export default function Auth() {
React.useEffect(() => {
const url = new URL(window.location.href)
const twitchVerify = url.searchParams.get("twitchVerify")
if (!twitchVerify) {
return
}
const youtubeVerify = url.searchParams.get("youtubeVerify")

if (twitchVerify === "success") {
setTwitchCallbackMessage({
setOauthCallbackMessage({
platform: "Twitch",
kind: "success",
body: "Your Twitch account has been linked successfully."
})
setShouldRefreshProfile(true)
} else {
setTwitchCallbackMessage({
} else if (twitchVerify) {
setOauthCallbackMessage({
platform: "Twitch",
kind: "error",
body: twitchVerify.replace(/\+/g, " ")
})
}

if (youtubeVerify === "success") {
setOauthCallbackMessage({
platform: "YouTube",
kind: "success",
body: "Your YouTube account has been linked successfully."
})
setShouldRefreshProfile(true)
} else if (youtubeVerify) {
setOauthCallbackMessage({
platform: "YouTube",
kind: "error",
body: youtubeVerify.replace(/\+/g, " ")
})
}

if (!twitchVerify && !youtubeVerify) {
return
}

url.searchParams.delete("twitchVerify")
url.searchParams.delete("youtubeVerify")
window.history.replaceState({}, "", url.toString())
}, [])

Expand Down Expand Up @@ -134,17 +155,17 @@ export default function Auth() {
body={<p style={{ padding: "1em" }}>{networkError}</p>}
/>
<Modal
show={twitchCallbackMessage != null}
show={oauthCallbackMessage != null}
onClose={() => {
setTwitchCallbackMessage(null)
setOauthCallbackMessage(null)
}}
className="is-dark basic-modal-body"
header={
twitchCallbackMessage?.kind === "success"
? "Twitch Linked"
: "Twitch Verification Error"
oauthCallbackMessage?.kind === "success"
? `${oauthCallbackMessage?.platform} Linked`
: `${oauthCallbackMessage?.platform} Verification Error`
}
body={<p style={{ padding: "1em" }}>{twitchCallbackMessage?.body}</p>}
body={<p style={{ padding: "1em" }}>{oauthCallbackMessage?.body}</p>}
/>
</div>
)
Expand Down
Loading
Loading