fix: github oauth on cloudflare page

This commit is contained in:
Ou 2024-10-14 17:18:57 +08:00
parent 8d8bc41691
commit 4607758dd0
16 changed files with 140 additions and 118 deletions

View File

@ -15,11 +15,12 @@
"build": "vite build", "build": "vite build",
"lint": "eslint", "lint": "eslint",
"favicon": "tsx ./scripts/favicon.ts", "favicon": "tsx ./scripts/favicon.ts",
"start": "PORT=4444 node --env-file .env.vars dist/output/server/index.mjs", "start": "PORT=4444 node --env-file .env.server dist/output/server/index.mjs",
"preview": "CF_PAGES=1 pnpm run build && wrangler pages dev", "preview": "CF_PAGES=1 pnpm run build && wrangler pages dev",
"deploy": "CF_PAGES=1 pnpm run build && wrangler pages deploy", "deploy": "CF_PAGES=1 pnpm run build && wrangler pages deploy",
"release": "bumpp", "release": "bumpp",
"prepare": "simple-git-hooks", "prepare": "simple-git-hooks",
"log": "wrangler pages deployment tail --project-name newsnow",
"test": "vitest -c vitest.config.ts" "test": "vitest -c vitest.config.ts"
}, },
"dependencies": { "dependencies": {
@ -34,6 +35,7 @@
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"consola": "^3.2.3", "consola": "^3.2.3",
"cookie-es": "^1.2.2",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"db0": "npm:@ourongxing/db0@0.1.6", "db0": "npm:@ourongxing/db0@0.1.6",
"defu": "^6.1.4", "defu": "^6.1.4",
@ -96,7 +98,8 @@
}, },
"resolutions": { "resolutions": {
"dayjs": "1.11.13", "dayjs": "1.11.13",
"db0": "npm:@ourongxing/db0@0.1.6" "db0": "npm:@ourongxing/db0@0.1.6",
"nitropack": "^2.9.7"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "npx lint-staged" "pre-commit": "npx lint-staged"

20
pnpm-lock.yaml generated
View File

@ -7,6 +7,7 @@ settings:
overrides: overrides:
dayjs: 1.11.13 dayjs: 1.11.13
db0: npm:@ourongxing/db0@0.1.6 db0: npm:@ourongxing/db0@0.1.6
nitropack: ^2.9.7
patchedDependencies: patchedDependencies:
dayjs: dayjs:
@ -50,6 +51,9 @@ importers:
consola: consola:
specifier: ^3.2.3 specifier: ^3.2.3
version: 3.2.3 version: 3.2.3
cookie-es:
specifier: ^1.2.2
version: 1.2.2
dayjs: dayjs:
specifier: 1.11.13 specifier: 1.11.13
version: 1.11.13(patch_hash=vxjypqxmsykboavgqknf3tdbfa) version: 1.11.13(patch_hash=vxjypqxmsykboavgqknf3tdbfa)
@ -2514,8 +2518,8 @@ packages:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
croner@8.1.1: croner@8.1.2:
resolution: {integrity: sha512-1VdUuRnQP4drdFkS8NKvDR1NBgevm8TOuflcaZEKsxw42CxonjW/2vkj1AKlinJb4ZLwBcuWF9GiPr7FQc6AQA==} resolution: {integrity: sha512-ypfPFcAXHuAZRCzo3vJL6ltENzniTjwe/qsLleH1V2/7SRDjgvRQyrLmumFTLmjFax4IuSxfGXEn79fozXcJog==}
engines: {node: '>=18.0'} engines: {node: '>=18.0'}
cross-spawn@7.0.3: cross-spawn@7.0.3:
@ -3997,8 +4001,8 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
package-json-from-dist@1.0.0: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
package-manager-detector@0.2.0: package-manager-detector@0.2.0:
resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==} resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==}
@ -7300,7 +7304,7 @@ snapshots:
crc-32: 1.2.2 crc-32: 1.2.2
readable-stream: 4.5.2 readable-stream: 4.5.2
croner@8.1.1: {} croner@8.1.2: {}
cross-spawn@7.0.3: cross-spawn@7.0.3:
dependencies: dependencies:
@ -8222,7 +8226,7 @@ snapshots:
jackspeak: 3.4.3 jackspeak: 3.4.3
minimatch: 9.0.5 minimatch: 9.0.5
minipass: 7.1.2 minipass: 7.1.2
package-json-from-dist: 1.0.0 package-json-from-dist: 1.0.1
path-scurry: 1.11.1 path-scurry: 1.11.1
glob@7.2.3: glob@7.2.3:
@ -8893,7 +8897,7 @@ snapshots:
citty: 0.1.6 citty: 0.1.6
consola: 3.2.3 consola: 3.2.3
cookie-es: 1.2.2 cookie-es: 1.2.2
croner: 8.1.1 croner: 8.1.2
crossws: 0.2.4 crossws: 0.2.4
db0: '@ourongxing/db0@0.1.6(@libsql/client@0.14.0)(libsql@0.4.6)' db0: '@ourongxing/db0@0.1.6(@libsql/client@0.14.0)(libsql@0.4.6)'
defu: 6.1.4 defu: 6.1.4
@ -9100,7 +9104,7 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
package-json-from-dist@1.0.0: {} package-json-from-dist@1.0.1: {}
package-manager-detector@0.2.0: {} package-manager-detector@0.2.0: {}

View File

@ -1,4 +1,4 @@
import type { Database } from "@ourongxing/db0" import type { Database } from "db0"
import type { UserInfo } from "#/types" import type { UserInfo } from "#/types"
export class UserTable { export class UserTable {
@ -31,6 +31,8 @@ export class UserTable {
} else if (u.email !== email && u.type !== type) { } else if (u.email !== email && u.type !== type) {
await this.db.prepare(`REPLACE INTO user (id, email, updated) VALUES (?, ?, ?)`).run(id, email, now) await this.db.prepare(`REPLACE INTO user (id, email, updated) VALUES (?, ?, ?)`).run(id, email, now)
logger.success(`update user ${id} email`) logger.success(`update user ${id} email`)
} else {
logger.info(`user ${id} already exists`)
} }
} }

View File

@ -2,10 +2,13 @@ import process from "node:process"
import { jwtVerify } from "jose" import { jwtVerify } from "jose"
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const token = getCookie(event, "jwt") if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) {
event.context.user = true
} else {
const token = getHeader(event, "Authorization")
if (token && process.env.JWT_SECRET) { if (token && process.env.JWT_SECRET) {
try { try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } } const { payload } = await jwtVerify(token.replace("Bearer ", ""), new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } }
if (payload?.id) { if (payload?.id) {
event.context.user = payload.id event.context.user = payload.id
} }
@ -13,4 +16,5 @@ export default defineEventHandler(async (event) => {
logger.error("JWT verification failed") logger.error("JWT verification failed")
} }
} }
}
}) })

View File

@ -1,3 +1,2 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(() => {
return event
}) })

View File

@ -1,75 +0,0 @@
import process from "node:process"
import { SignJWT } from "jose"
import { UserTable } from "#/database/user"
export default defineEventHandler(async (event) => {
["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].forEach((k) => {
if (!process.env[k]) throw new Error(`${k} is not defined`)
})
const db = useDatabase()
const userTable = db ? new UserTable(db) : undefined
if (!userTable) throw new Error("db is not defined")
await userTable.init()
const body = {
client_id: process.env.G_CLIENT_ID,
client_secret: process.env.G_CLIENT_SECRET,
code: getQuery(event).code,
}
/**
* access_token
* 使access_token
*/
const response: {
access_token: string
token_type: string
scope: string
} = await $fetch(
`https://github.com/login/oauth/access_token`,
{
method: "POST",
body,
headers: { accept: "application/json" },
},
)
const token = response.access_token
console.log(token)
const userInfo: {
id: number
name: string
avatar_url: string
} = await $fetch(`https://api.github.com/user`, {
headers: {
Authorization: `token ${token}`,
},
})
const emailinfo: {
email: string
primary: boolean
}[] = await $fetch("https://api.github.com/user/emails", {
headers: {
Accept: "application/vnd.github+json",
Authorization: `token ${token}`,
},
})
const userID = String(userInfo.id)
await userTable.addUser(userID, emailinfo.find(item => item.primary)?.email || "", "github")
const jwtToken = await new SignJWT({
id: userID,
type: "github",
})
.setExpirationTime("65d")
.setProtectedHeader({ alg: "HS256" })
.sign(new TextEncoder().encode(process.env.JWT_SECRET!))
// seconds
const maxAge = 60 * 24 * 60 * 60
setCookie(event, "jwt", jwtToken, { maxAge })
setCookie(event, "avatar", userInfo.avatar_url, { maxAge })
setCookie(event, "name", userInfo.name, { maxAge })
return sendRedirect(event, `/?login=github`)
})

View File

@ -0,0 +1,73 @@
import process from "node:process"
import { SignJWT } from "jose"
import { UserTable } from "#/database/user"
export default defineEventHandler(async (event) => {
if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) throw new Error("Missing environment variables")
const db = useDatabase()
const userTable = db ? new UserTable(db) : undefined
if (!userTable) throw new Error("db is not defined")
if (process.env.INIT_TABLE !== "false") await userTable.init()
const response: {
access_token: string
token_type: string
scope: string
} = await $fetch(
`https://github.com/login/oauth/access_token`,
{
method: "POST",
body: {
client_id: process.env.G_CLIENT_ID,
client_secret: process.env.G_CLIENT_SECRET,
code: getQuery(event).code,
},
headers: {
accept: "application/json",
},
},
)
const userInfo: {
id: number
name: string
avatar_url: string
email: string
notification_email: string
} = await $fetch(`https://api.github.com/user`, {
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `token ${response.access_token}`,
// 必须有 user-agent在 cloudflare worker 会报错
"User-Agent": "NewsNow App",
},
})
const userID = String(userInfo.id)
await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github")
const jwtToken = await new SignJWT({
id: userID,
type: "github",
})
.setExpirationTime("60d")
.setProtectedHeader({ alg: "HS256" })
.sign(new TextEncoder().encode(process.env.JWT_SECRET!))
// nitro 有 bug在 cloudflare 里没法 set cookie
// seconds
// const maxAge = 60 * 24 * 60 * 60
// setCookie(event, "user_jwt", jwtToken, { maxAge })
// setCookie(event, "user_avatar", userInfo.avatar_url, { maxAge })
// setCookie(event, "user_name", userInfo.name, { maxAge })
const params = new URLSearchParams({
login: "github",
user_jwt: jwtToken,
user_info: JSON.stringify({
avatar: userInfo.avatar_url,
name: userInfo.name,
}),
})
return sendRedirect(event, `/?${params.toString()}`)
})

View File

@ -1,3 +1,4 @@
import process from "node:process"
import { TTL } from "@shared/consts" import { TTL } from "@shared/consts"
import type { SourceID, SourceResponse } from "@shared/types" import type { SourceID, SourceResponse } from "@shared/types"
import { sources } from "@shared/sources" import { sources } from "@shared/sources"
@ -21,7 +22,7 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
const cacheTable = db ? new Cache(db) : undefined const cacheTable = db ? new Cache(db) : undefined
const now = Date.now() const now = Date.now()
if (cacheTable) { if (cacheTable) {
await cacheTable.init() if (process.env.INIT_TABLE !== "false") await cacheTable.init()
const cache = await cacheTable.get(id) const cache = await cacheTable.get(id)
if (cache) { if (cache) {
// interval 刷新间隔,对于缓存失效也要执行的。本质上表示本来内容更新就很慢,这个间隔内可能内容压根不会更新。 // interval 刷新间隔,对于缓存失效也要执行的。本质上表示本来内容更新就很慢,这个间隔内可能内容压根不会更新。

View File

@ -1,10 +1,9 @@
import type { NewsItem } from "@shared/types" import type { NewsItem } from "@shared/types"
import { load } from "cheerio" import { load } from "cheerio"
import { $fetch } from "ofetch"
export default defineSource(async () => { export default defineSource(async () => {
const url = "https://www.36kr.com/newsflashes" const url = "https://www.36kr.com/newsflashes"
const response = await $fetch(url) const response = await $fetch(url) as any
const $ = load(response) const $ = load(response)
const news: NewsItem[] = [] const news: NewsItem[] = []
const $items = $(".newsflash-item") const $items = $(".newsflash-item")

View File

@ -105,7 +105,12 @@ function NewsCard({ id, inView, handleListeners }: NewsCardProps) {
if (Date.now() - _refetchTime < 1000) { if (Date.now() - _refetchTime < 1000) {
url = `/api/s/${_id}?latest` url = `/api/s/${_id}?latest`
} }
const response: SourceResponse = await ofetch(url, { timeout: 5000 }) const response: SourceResponse = await ofetch(url, {
timeout: 10000,
headers: {
Authorization: `Bearer ${localStorage.getItem("user_jwt")}`,
},
})
if (response.status === "error") { if (response.status === "error") {
throw new Error(response.message) throw new Error(response.message)
} else { } else {

View File

@ -4,6 +4,7 @@ import { Link } from "@tanstack/react-router"
import clsx from "clsx" import clsx from "clsx"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { useEffect } from "react" import { useEffect } from "react"
import { useTitle } from "react-use"
import { Dnd } from "./dnd" import { Dnd } from "./dnd"
import { currentColumnIDAtom } from "~/atoms" import { currentColumnIDAtom } from "~/atoms"
@ -12,7 +13,7 @@ export function Column({ id }: { id: ColumnID }) {
useEffect(() => { useEffect(() => {
setCurrentColumnID(id) setCurrentColumnID(id)
}, [id, setCurrentColumnID]) }, [id, setCurrentColumnID])
useTitle(`NewsNow ${metadata[id].name}`)
return ( return (
<> <>
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">

View File

@ -5,7 +5,7 @@ import { useIsFetching } from "@tanstack/react-query"
import clsx from "clsx" import clsx from "clsx"
import type { SourceID } from "@shared/types" import type { SourceID } from "@shared/types"
import { Homepage, Version } from "@shared/consts" import { Homepage, Version } from "@shared/consts"
import { useCookie } from "react-use" import { useLocalStorage } from "react-use"
import { useDark } from "~/hooks/useDark" import { useDark } from "~/hooks/useDark"
import { currentColumnAtom, goToTopAtom, refetchSourcesAtom } from "~/atoms" import { currentColumnAtom, goToTopAtom, refetchSourcesAtom } from "~/atoms"
@ -22,24 +22,26 @@ function ThemeToggle() {
} }
function LoginIn() { function LoginIn() {
const [name] = useCookie("name") // useLocalStorage 默认会自动序列化
const [avatar] = useCookie("avatar") const [info] = useLocalStorage<{ name: string, avatar: string }>("user_info")
const [jwt, setJwt] = useCookie("jwt") const [jwt, _setJwt] = useLocalStorage<string>("user_jwt", undefined, {
raw: true,
})
if (jwt) { if (jwt) {
return ( return (
<button <button
type="button" type="button"
className="btn-pure" className="btn-pure"
title={name ?? ""} title={info?.name ?? ""}
onClick={() => { onClick={() => {
setJwt("") // setJwt("")
}} }}
> >
<div <div
className="h-5 w-5 rounded-full bg-cover border" className="h-5 w-5 rounded-full bg-cover border"
style={ style={
{ {
backgroundImage: `url(${avatar})`, backgroundImage: `url(${info?.avatar})`,
} }
} }
/> />
@ -48,11 +50,9 @@ function LoginIn() {
} }
return ( return (
<a <a
type="button"
title="Login in with GitHub" title="Login in with GitHub"
className="i-ph:sign-in-duotone btn-pure" className="i-ph:sign-in-duotone btn-pure"
// @ts-expect-error >_< href={`https://github.com/login/oauth/authorize?client_id=${__G_CLIENT_ID__}`}
href={`https://github.com/login/oauth/authorize?client_id=${__G_CLIENT_ID__}&scope=read:user,user:email`}
/> />
) )
} }
@ -124,7 +124,7 @@ export function Header() {
<RefreshButton /> <RefreshButton />
<ThemeToggle /> <ThemeToggle />
<GithubIcon /> <GithubIcon />
<LoginIn /> { __ENABLE_LOGIN__ && <LoginIn />}
</span> </span>
</> </>
) )

View File

@ -15,6 +15,13 @@ export const Route = createRootRouteWithContext<{
}>()({ }>()({
component: RootComponent, component: RootComponent,
notFoundComponent: NotFoundComponent, notFoundComponent: NotFoundComponent,
beforeLoad: () => {
const query = new URLSearchParams(window.location.search)
if (query.has("login")) {
[...query.entries()].forEach(key => localStorage.setItem(key[0], key[1]))
window.history.replaceState({}, document.title, window.location.pathname)
}
},
}) })
function NotFoundComponent() { function NotFoundComponent() {

View File

@ -3,14 +3,10 @@ import { useAtomValue } from "jotai"
import { useMemo } from "react" import { useMemo } from "react"
import { localSourcesAtom } from "~/atoms" import { localSourcesAtom } from "~/atoms"
import { Column } from "~/components/column" import { Column } from "~/components/column"
import { } from "cookie-es"
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: IndexComponent, component: IndexComponent,
loader: async () => {
if (window.location.search.includes("login")) {
window.history.replaceState(null, "", "/")
}
},
}) })
function IndexComponent() { function IndexComponent() {

2
src/vite-env.d.ts vendored
View File

@ -1 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __G_CLIENT_ID__: string
declare const __ENABLE_LOGIN__: boolean

View File

@ -12,12 +12,13 @@ import { projectDir } from "./shared/dir"
const isCF = process.env.CF_PAGES const isCF = process.env.CF_PAGES
dotenv.config({ dotenv.config({
path: join(projectDir, ".env.vars"), path: join(projectDir, ".env.server"),
}) })
export default defineConfig({ export default defineConfig({
define: { define: {
__G_CLIENT_ID__: `"${process.env.G_CLIENT_ID}"`, __G_CLIENT_ID__: `"${process.env.G_CLIENT_ID}"`,
__ENABLE_LOGIN__: ["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].every(k => process.env[k]),
}, },
plugins: [ plugins: [
TanStackRouterVite({ TanStackRouterVite({