Files
remanso/src/modules/repo/services/repo.ts
Julien Calixte a09e541fa8 feat(repo): hide write UI for users without push access
Fetch the viewer's push permission via GET /repos/{owner}/{repo} when
entering a repo and gate every write affordance behind it: edit and
image-upload buttons in StackedNote, new-fleeting-note and YouTube
buttons in FleetingNotes, and interactive checkboxes in TodoNotes
(rendered disabled when read-only). Anonymous viewers get the same
treatment because the permissions field is absent without auth, which
collapses to canPush = false.

Previously every write button was visible regardless of role, so
read-role collaborators and anonymous viewers could enter edit mode and
type before the save failed with 401/403.
2026-05-29 15:24:12 +02:00

159 lines
3.6 KiB
TypeScript

import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
export const getRepoPermission = async (
owner: string,
repo: string
): Promise<boolean> => {
if (!owner || !repo) {
return false
}
const octokit = await getOctokit()
const { data } = await octokit.repos.get({ owner, repo })
return data.permissions?.push ?? false
}
export const getFiles = async (
owner: string,
repo: string
): Promise<RepoFile[]> => {
if (!owner || !repo) {
return []
}
const octokit = await getOctokit()
const commits = await octokit.request("GET /repos/{owner}/{repo}/commits", {
owner,
repo
})
const lastCommit = commits.data.shift()
if (!lastCommit) {
return []
}
const treeResponse = await octokit.request(
"GET /repos/{owner}/{repo}/git/trees/{tree_sha}",
{
owner,
repo,
tree_sha: lastCommit.commit.tree.sha,
recursive: "true",
request: { signal: AbortSignal.timeout(20_000) }
}
)
return treeResponse?.data.tree.filter((t) => t.type === "blob") ?? []
}
export const getCachedMainReadme = async (owner: string, repo: string) => {
if (!owner || !repo) {
return null
}
const { render } = markdownBuilder()
const { getCachedNote } = prepareNoteCache(`${owner}-${repo}-README`)
const { note: cachedReadme } = await getCachedNote()
if (!cachedReadme) {
return null
}
return render(cachedReadme.content)
}
export const getMainReadme = async (owner: string, repo: string) => {
if (!owner || !repo) {
return null
}
const { render } = markdownBuilder()
const { getCachedNote, saveCacheNote } = prepareNoteCache(
`${owner}-${repo}-README`
)
try {
const octokit = await getOctokit()
const README = await octokit.repos.getReadme({
owner,
repo
})
if (README) {
saveCacheNote(README.data.content)
return render(README.data.content)
}
} catch (error) {
console.warn(error)
const { note: cachedReadme } = await getCachedNote()
if (cachedReadme) {
return render(cachedReadme.content)
}
// No cached fallback — surface the error so the caller can classify
// network/timeout failures vs auth/404 and tailor the UI accordingly.
throw error
}
return null
}
export const getUserSettingsContent = async (
user: string,
repo: string,
files: RepoFile[]
): Promise<Omit<UserSettings, "chosenFontFamily"> | null> => {
const configFile = files.find((file) => file.path === ".remanso.json")
if (!configFile?.sha) {
return null
}
const content = await queryFileContent(user, repo, configFile.sha)
if (!content) {
return null
}
const raw = JSON.parse(atob(content)) as UserSettings & {
t?: string
p?: string
}
const { t, p, ...rest } = raw
return {
...rest,
chosenTitleFont: t,
chosenBodyFont: p
}
}
export const queryFileContent = async (
user: string,
repo: string,
sha: string
) => {
if (!user || !repo) {
return null
}
try {
const file = await runWithAuthRetry((octokit) =>
octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", {
owner: user,
repo: repo,
file_sha: sha
})
)
return file?.data.content ?? null
} catch (error) {
console.warn("queryFileContent failed", { user, repo, sha, error })
return null
}
}