Compare commits
3 Commits
e39ac32e43
...
8a8509a0f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8509a0f3 | ||
|
|
151a4d9137 | ||
|
|
c412c75cfd |
@@ -23,6 +23,8 @@ const label = computed(() => {
|
|||||||
return "Outdated"
|
return "Outdated"
|
||||||
case "offline":
|
case "offline":
|
||||||
return "Can’t reach GitHub"
|
return "Can’t reach GitHub"
|
||||||
|
case "unauthorized":
|
||||||
|
return "Sign in to GitHub"
|
||||||
case "unknown":
|
case "unknown":
|
||||||
default:
|
default:
|
||||||
return "Not checked"
|
return "Not checked"
|
||||||
@@ -39,6 +41,8 @@ const tooltip = computed(() => {
|
|||||||
return "GitHub has a newer version. Click to pull latest."
|
return "GitHub has a newer version. Click to pull latest."
|
||||||
case "offline":
|
case "offline":
|
||||||
return "Could not reach GitHub. Click to retry."
|
return "Could not reach GitHub. Click to retry."
|
||||||
|
case "unauthorized":
|
||||||
|
return "GitHub auth expired. Sign in again, then click to retry."
|
||||||
case "checking":
|
case "checking":
|
||||||
return "Checking against GitHub…"
|
return "Checking against GitHub…"
|
||||||
case "unknown":
|
case "unknown":
|
||||||
@@ -133,6 +137,25 @@ const isBusy = computed(() => props.status === "checking")
|
|||||||
>
|
>
|
||||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="status === 'unauthorized'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-lock"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.18 0 2.227 .593 2.85 1.5"
|
||||||
|
/>
|
||||||
|
<path d="M15 16m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||||
|
<path d="M18 14a2 2 0 0 1 2 2v0h-4v0a2 2 0 0 1 2 -2z" />
|
||||||
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else
|
v-else
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -191,6 +214,10 @@ const isBusy = computed(() => props.status === "checking")
|
|||||||
color: var(--color-error, hsl(0, 70%, 45%));
|
color: var(--color-error, hsl(0, 70%, 45%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-unauthorized {
|
||||||
|
color: var(--color-warning, hsl(35, 90%, 45%));
|
||||||
|
}
|
||||||
|
|
||||||
.state-unknown,
|
.state-unknown,
|
||||||
.state-checking {
|
.state-checking {
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ const onBadgeClick = async () => {
|
|||||||
try {
|
try {
|
||||||
if (freshnessStatus.value !== "outdated") {
|
if (freshnessStatus.value !== "outdated") {
|
||||||
await checkFreshness()
|
await checkFreshness()
|
||||||
|
if (freshnessStatus.value === "unauthorized") {
|
||||||
|
errorMessage("🔐 GitHub auth expired — please sign in again")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +244,10 @@ const onBadgeClick = async () => {
|
|||||||
if (newRaw !== null) {
|
if (newRaw !== null) {
|
||||||
rawContent.value = newRaw
|
rawContent.value = newRaw
|
||||||
initialRawContent.value = newRaw
|
initialRawContent.value = newRaw
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (freshnessStatus.value === "unauthorized") {
|
||||||
|
errorMessage("🔐 GitHub auth expired — please sign in again")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("freshness badge click failed", error)
|
console.error("freshness badge click failed", error)
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
const isConflictStatus = (status: number) => status === 409 || status === 422
|
const isConflictStatus = (status: number) => status === 409 || status === 422
|
||||||
|
const isUnauthorizedStatus = (status: number | undefined) => status === 401
|
||||||
|
|
||||||
|
export type FetchShaResult =
|
||||||
|
| { kind: "ok"; sha: string | null }
|
||||||
|
| { kind: "unauthorized" }
|
||||||
|
| { kind: "offline" }
|
||||||
|
|
||||||
export const useGitHubContent = ({
|
export const useGitHubContent = ({
|
||||||
user,
|
user,
|
||||||
@@ -11,23 +17,23 @@ export const useGitHubContent = ({
|
|||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
}) => {
|
}) => {
|
||||||
const fetchLatestSha = async (path: string): Promise<string | null> => {
|
const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const response = await runWithAuthRetry((octokit) =>
|
||||||
const response = await octokit.request(
|
octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
|
||||||
"GET /repos/{owner}/{repo}/contents/{+path}",
|
|
||||||
{
|
|
||||||
owner: user,
|
owner: user,
|
||||||
repo,
|
repo,
|
||||||
path,
|
path,
|
||||||
headers: { "X-GitHub-Api-Version": "2026-03-10" }
|
headers: { "X-GitHub-Api-Version": "2026-03-10" }
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
const data = response?.data
|
const data = response?.data
|
||||||
if (Array.isArray(data) || !data) return null
|
if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
|
||||||
return "sha" in data ? data.sha : null
|
return { kind: "ok", sha: "sha" in data ? data.sha : null }
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null
|
const status = (error as { status?: number })?.status
|
||||||
|
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
|
||||||
|
return { kind: "offline" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type FreshnessStatus =
|
|||||||
| "verified"
|
| "verified"
|
||||||
| "outdated"
|
| "outdated"
|
||||||
| "offline"
|
| "offline"
|
||||||
|
| "unauthorized"
|
||||||
|
|
||||||
const MIN_SPINNER_MS = 400
|
const MIN_SPINNER_MS = 400
|
||||||
|
|
||||||
@@ -43,14 +44,16 @@ export const useNoteFreshness = ({
|
|||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
|
|
||||||
let next: FreshnessStatus
|
let next: FreshnessStatus
|
||||||
const remoteSha = await fetchLatestSha(path.value)
|
const result = await fetchLatestSha(path.value)
|
||||||
if (remoteSha === null) {
|
if (result.kind === "unauthorized") {
|
||||||
|
next = "unauthorized"
|
||||||
|
} else if (result.kind === "offline" || result.sha === null) {
|
||||||
next = "offline"
|
next = "offline"
|
||||||
} else {
|
} else {
|
||||||
latestSha.value = remoteSha
|
latestSha.value = result.sha
|
||||||
lastCheckedAt.value = new Date()
|
lastCheckedAt.value = new Date()
|
||||||
const local = await expectedSha()
|
const local = await expectedSha()
|
||||||
next = remoteSha === local ? "verified" : "outdated"
|
next = result.sha === local ? "verified" : "outdated"
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = performance.now() - startedAt
|
const elapsed = performance.now() - startedAt
|
||||||
@@ -60,13 +63,27 @@ export const useNoteFreshness = ({
|
|||||||
status.value = next
|
status.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveRemoteSha = async (
|
||||||
|
path: string
|
||||||
|
): Promise<{ sha: string | null; failureStatus: FreshnessStatus | null }> => {
|
||||||
|
if (latestSha.value) return { sha: latestSha.value, failureStatus: null }
|
||||||
|
const result = await fetchLatestSha(path)
|
||||||
|
if (result.kind === "unauthorized") {
|
||||||
|
return { sha: null, failureStatus: "unauthorized" }
|
||||||
|
}
|
||||||
|
if (result.kind === "offline" || result.sha === null) {
|
||||||
|
return { sha: null, failureStatus: "offline" }
|
||||||
|
}
|
||||||
|
return { sha: result.sha, failureStatus: null }
|
||||||
|
}
|
||||||
|
|
||||||
const pullLatest = async (): Promise<string | null> => {
|
const pullLatest = async (): Promise<string | null> => {
|
||||||
if (!path.value) return null
|
if (!path.value) return null
|
||||||
const usedCachedSha = latestSha.value !== null
|
const usedCachedSha = latestSha.value !== null
|
||||||
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value))
|
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
|
||||||
if (!remoteSha) {
|
if (!remoteSha) {
|
||||||
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
||||||
status.value = "offline"
|
if (failureStatus) status.value = failureStatus
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const fileContent = await queryFileContent(user, repo, remoteSha)
|
const fileContent = await queryFileContent(user, repo, remoteSha)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Octokit } from "@octokit/rest"
|
import { Octokit } from "@octokit/rest"
|
||||||
|
|
||||||
import { getAccessToken } from "@/modules/user/service/signIn"
|
import { getAccessToken, refreshToken } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
export const DEFAULT_OCTOKIT_TIMEOUT_MS = 8_000
|
export const DEFAULT_OCTOKIT_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
@@ -20,3 +20,21 @@ export const getOctokit = async (): Promise<Octokit> => {
|
|||||||
|
|
||||||
return octokit
|
return octokit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUnauthorized = (error: unknown): boolean =>
|
||||||
|
(error as { status?: number })?.status === 401
|
||||||
|
|
||||||
|
// Runs an Octokit call; on 401, force-refreshes the token and retries once.
|
||||||
|
// Rethrows the original error if the refresh fails or the retry still 401s.
|
||||||
|
export const runWithAuthRetry = async <T>(
|
||||||
|
call: (octokit: Octokit) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await call(await getOctokit())
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnauthorized(error)) throw error
|
||||||
|
const refreshed = await refreshToken({ force: true }).catch(() => null)
|
||||||
|
if (!refreshed) throw error
|
||||||
|
return await call(await getOctokit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
|||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
||||||
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
|
|
||||||
export const getFiles = async (
|
export const getFiles = async (
|
||||||
owner: string,
|
owner: string,
|
||||||
@@ -131,14 +131,12 @@ export const queryFileContent = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const file = await runWithAuthRetry((octokit) =>
|
||||||
const file = await octokit.request(
|
octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", {
|
||||||
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
|
|
||||||
{
|
|
||||||
owner: user,
|
owner: user,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
file_sha: sha
|
file_sha: sha
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
return file?.data.content ?? null
|
return file?.data.content ?? null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ export const needToRefreshToken = async () => {
|
|||||||
return isBefore(expirationDate, minimumViableDate)
|
return isBefore(expirationDate, minimumViableDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const refreshToken = async () => {
|
let inFlightRefresh: Promise<GithubAccessToken | null> | null = null
|
||||||
|
|
||||||
|
const performRefresh = async ({
|
||||||
|
force
|
||||||
|
}: {
|
||||||
|
force: boolean
|
||||||
|
}): Promise<GithubAccessToken | null> => {
|
||||||
const accessToken = await data.get<
|
const accessToken = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
GithubAccessToken
|
GithubAccessToken
|
||||||
@@ -48,7 +54,7 @@ export const refreshToken = async () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const needRefresh = await needToRefreshToken()
|
const needRefresh = force || (await needToRefreshToken())
|
||||||
|
|
||||||
if (needRefresh) {
|
if (needRefresh) {
|
||||||
const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
|
const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
|
||||||
@@ -70,6 +76,18 @@ export const refreshToken = async () => {
|
|||||||
return accessToken
|
return accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub refresh tokens are single-use, so concurrent refreshes race and the
|
||||||
|
// loser ends up with a revoked refresh token. Dedupe in-flight calls.
|
||||||
|
export const refreshToken = async ({
|
||||||
|
force = false
|
||||||
|
}: { force?: boolean } = {}): Promise<GithubAccessToken | null> => {
|
||||||
|
if (inFlightRefresh) return inFlightRefresh
|
||||||
|
inFlightRefresh = performRefresh({ force }).finally(() => {
|
||||||
|
inFlightRefresh = null
|
||||||
|
})
|
||||||
|
return inFlightRefresh
|
||||||
|
}
|
||||||
|
|
||||||
export const getAccessToken = async () => {
|
export const getAccessToken = async () => {
|
||||||
const response = await data.get<
|
const response = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
|
|||||||
Reference in New Issue
Block a user