fix: support gitea git remote uploads
This commit is contained in:
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
UPLOAD_SECRET_KEY=your-secret-key-here
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
GIT_TOKEN=your-git-provider-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
GIT_REPO_URL=https://git.example.com/your-org/your-repo
|
||||||
|
|
||||||
# Nextcloud Drive (public share WebDAV)
|
# Nextcloud Drive (public share WebDAV)
|
||||||
NEXTCLOUD_URL=https://cloud.example.com
|
NEXTCLOUD_URL=https://cloud.example.com
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ RUN npm run build
|
|||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
|
|
||||||
LABEL maintainer="La Fabrik Durable"
|
LABEL maintainer="La Fabrik Durable"
|
||||||
LABEL description="Secure GLTF upload interface with Draco compression and GitHub push"
|
LABEL description="Secure GLTF upload interface with Draco compression and Git push"
|
||||||
|
|
||||||
# Blender is required for server-side Draco GLB export.
|
# Blender is required for server-side Draco GLB export.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs:
|
A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs:
|
||||||
|
|
||||||
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions
|
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions
|
||||||
- **GitHub** — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode for specific models
|
- **Git remote** — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode for specific models
|
||||||
|
|
||||||
Built for La Fabrik Durable's internal use, but open-sourced for anyone looking for a similar solution. The app validates the upload locally, stages it server-side, then compares file diffs to avoid unnecessary uploads and commits. The Drive upload serves as the source of truth and version history, while the GitHub upload delivers the prepared assets to developers.
|
Built for La Fabrik Durable's internal use, but open-sourced for anyone looking for a similar solution. The app validates the upload locally, stages it server-side, then compares file diffs to avoid unnecessary uploads and commits. The Drive upload serves as the source of truth and version history, while the Git upload delivers the prepared assets to developers.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- [**Next.js 16.2.5** (App Router)](https://nextjs.org/docs/app/getting-started/installation) + [React 19](https://react.dev/learn/creating-a-react-app) + [TypeScript](https://www.typescriptlang.org/docs/)
|
- [**Next.js 16.2.5** (App Router)](https://nextjs.org/docs/app/getting-started/installation) + [React 19](https://react.dev/learn/creating-a-react-app) + [TypeScript](https://www.typescriptlang.org/docs/)
|
||||||
- [**Three.js**](https://threejs.org/docs/#manual/en/introduction/Creating-a-scene) ([React Three Fiber](https://r3f.docs.pmnd.rs/getting-started/introduction) + [Drei](https://drei.docs.pmnd.rs/getting-started/introduction)) for 3D preview
|
- [**Three.js**](https://threejs.org/docs/#manual/en/introduction/Creating-a-scene) ([React Three Fiber](https://r3f.docs.pmnd.rs/getting-started/introduction) + [Drei](https://drei.docs.pmnd.rs/getting-started/introduction)) for 3D preview
|
||||||
- [**Tailwind CSS**](https://v3.tailwindcss.com/docs/installation) for styling
|
- [**Tailwind CSS**](https://v3.tailwindcss.com/docs/installation) for styling
|
||||||
- [**Octokit**](https://github.com/octokit/rest.js/#readme) for pushing via the GitHub API
|
- [**Octokit**](https://github.com/octokit/rest.js/#readme) for pushing via a GitHub-compatible API (GitHub or Gitea)
|
||||||
- [**Nextcloud WebDAV**](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html) for Drive archiving with automatic versioning
|
- [**Nextcloud WebDAV**](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html) for Drive archiving with automatic versioning
|
||||||
- [**Sharp**](https://sharp.pixelplumbing.com/install/) for server-side texture compression
|
- [**Sharp**](https://sharp.pixelplumbing.com/install/) for server-side texture compression
|
||||||
- [**npm lockfile + Coolify** (Docker)](https://coolify.io/docs/applications/build-packs/dockerfile) for hosting
|
- [**npm lockfile + Coolify** (Docker)](https://coolify.io/docs/applications/build-packs/dockerfile) for hosting
|
||||||
@@ -39,7 +39,7 @@ This repo also keeps install-time package scripts disabled by default through `.
|
|||||||
npm ci --ignore-scripts --no-audit --no-fund
|
npm ci --ignore-scripts --no-audit --no-fund
|
||||||
```
|
```
|
||||||
|
|
||||||
When a dependency update is needed, prefer a lockfile-only update in a clean environment with no `.env`, no GitHub token, and no cloud credentials:
|
When a dependency update is needed, prefer a lockfile-only update in a clean environment with no `.env`, no Git token, and no cloud credentials:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /tmp/npm-clean-home /tmp/npm-clean-cache
|
mkdir -p /tmp/npm-clean-home /tmp/npm-clean-cache
|
||||||
@@ -118,7 +118,7 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
|
|||||||
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
|
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
|
||||||
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
|
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
|
||||||
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
|
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
|
||||||
- Git LFS uploads are batched in groups of 100 objects to stay within the GitHub LFS Batch API limit
|
- Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ app/
|
|||||||
│ ├── stage/route.ts # POST: upload folder once to temporary staging
|
│ ├── stage/route.ts # POST: upload folder once to temporary staging
|
||||||
│ ├── check/route.ts # POST: prepare staged Git assets and compare with remote files
|
│ ├── check/route.ts # POST: prepare staged Git assets and compare with remote files
|
||||||
│ ├── drive/route.ts # POST: upload staged originals to Nextcloud Drive (VF/Vx versioning)
|
│ ├── drive/route.ts # POST: upload staged originals to Nextcloud Drive (VF/Vx versioning)
|
||||||
│ └── git/route.ts # POST: push staged prepared assets to GitHub
|
│ └── git/route.ts # POST: push staged prepared assets to the Git remote
|
||||||
├── globals.css # Tailwind + CSS variable fonts
|
├── globals.css # Tailwind + CSS variable fonts
|
||||||
├── layout.tsx # Root layout (next/font/google)
|
├── layout.tsx # Root layout (next/font/google)
|
||||||
└── page.tsx # Home page
|
└── page.tsx # Home page
|
||||||
@@ -206,7 +206,7 @@ lib/
|
|||||||
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
|
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
|
||||||
├── sanitize.ts # Filename sanitization
|
├── sanitize.ts # Filename sanitization
|
||||||
├── auth.ts # Upload secret validation (timing-safe)
|
├── auth.ts # Upload secret validation (timing-safe)
|
||||||
├── github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub)
|
├── github.ts # Octokit helpers for GitHub-compatible remotes
|
||||||
├── nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config)
|
├── nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config)
|
||||||
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
|
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
|
||||||
├── upload-lock.ts # Lightweight in-memory per-folder upload lock
|
├── upload-lock.ts # Lightweight in-memory per-folder upload lock
|
||||||
@@ -237,9 +237,9 @@ Copy `.env.example` to `.env.local` and fill in the values:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
UPLOAD_SECRET_KEY=your-secret-key-here
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
GIT_TOKEN=your-git-provider-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
GIT_REPO_URL=https://git.example.com/your-org/your-repo
|
||||||
# Nextcloud Drive (public share WebDAV)
|
# Nextcloud Drive (public share WebDAV)
|
||||||
NEXTCLOUD_URL=https://cloud.example.com
|
NEXTCLOUD_URL=https://cloud.example.com
|
||||||
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
|
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
|
||||||
@@ -250,15 +250,15 @@ NEXTCLOUD_BASE_PATH=Models
|
|||||||
| Variable | Description | Required |
|
| Variable | Description | Required |
|
||||||
|----------|-------------|----------|
|
|----------|-------------|----------|
|
||||||
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
||||||
| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
|
| `GIT_TOKEN` | Git provider token with repository read/write access. `GITHUB_TOKEN` is still accepted for backward compatibility. | Yes |
|
||||||
| `GIT_BRANCH` | Target branch (default: main) | No |
|
| `GIT_BRANCH` | Target branch (default: main) | No |
|
||||||
| `GIT_REPO_URL` | Target GitHub repository URL (`owner/repo`, HTTPS, or SSH) | Yes |
|
| `GIT_REPO_URL` | Target GitHub or Gitea repository URL (`owner/repo`, HTTPS, or SSH) | Yes |
|
||||||
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
|
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
|
||||||
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
|
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
|
||||||
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
|
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
|
||||||
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
|
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
|
||||||
|
|
||||||
> To create a GitHub token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write
|
> GitHub tokens need `Contents: Read and write`. Gitea tokens need repository read/write access.
|
||||||
|
|
||||||
> To create a Nextcloud public share token: Nextcloud > Files > select folder > Share > Create public share > set permissions (write access required) > copy the share link and extract the token
|
> To create a Nextcloud public share token: Nextcloud > Files > select folder > Share > Create public share > set permissions (write access required) > copy the share link and extract the token
|
||||||
|
|
||||||
@@ -278,8 +278,8 @@ After a security patch:
|
|||||||
docker build -t upload-gltf .
|
docker build -t upload-gltf .
|
||||||
docker run -p 3000:3000 \
|
docker run -p 3000:3000 \
|
||||||
-e UPLOAD_SECRET_KEY=your-key \
|
-e UPLOAD_SECRET_KEY=your-key \
|
||||||
-e GITHUB_TOKEN=ghp_xxx \
|
-e GIT_TOKEN=token_xxx \
|
||||||
-e GIT_REPO_URL=https://github.com/org/repo.git \
|
-e GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik \
|
||||||
-e NEXTCLOUD_URL=https://cloud.example.com \
|
-e NEXTCLOUD_URL=https://cloud.example.com \
|
||||||
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
|
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
|
||||||
upload-gltf
|
upload-gltf
|
||||||
@@ -293,12 +293,12 @@ Rotate secrets after patching if the previous deployment exposed a vulnerable Ne
|
|||||||
|
|
||||||
Recommended order:
|
Recommended order:
|
||||||
|
|
||||||
1. Generate a new fine-grained `GITHUB_TOKEN` limited to the target model repository with `Contents: Read and write`.
|
1. Generate a new `GIT_TOKEN` limited to the target model repository with read/write access.
|
||||||
2. Generate a new long random `UPLOAD_SECRET_KEY`.
|
2. Generate a new long random `UPLOAD_SECRET_KEY`.
|
||||||
3. Regenerate the Nextcloud public share token or password when possible.
|
3. Regenerate the Nextcloud public share token or password when possible.
|
||||||
4. Update the variables in Coolify.
|
4. Update the variables in Coolify.
|
||||||
5. Redeploy the patched image.
|
5. Redeploy the patched image.
|
||||||
6. Revoke the old GitHub token and old Nextcloud share credentials after the new deployment is healthy.
|
6. Revoke the old Git token and old Nextcloud share credentials after the new deployment is healthy.
|
||||||
|
|
||||||
Do not commit real `.env` files. `.dockerignore` excludes `.env` and `.env.*`, while keeping `.env.example` as documentation.
|
Do not commit real `.env` files. `.dockerignore` excludes `.env` and `.env.*`, while keeping `.env.example` as documentation.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
|
import { getRemoteFolder, pushAllToGit } from '@/lib/github'
|
||||||
import { buildCommitMessage } from '@/lib/commit-message'
|
import { buildCommitMessage } from '@/lib/commit-message'
|
||||||
import { classifyFileChanges } from '@/lib/diff-files'
|
import { classifyFileChanges } from '@/lib/diff-files'
|
||||||
import { getModelFolderPath } from '@/lib/model-paths'
|
import { getModelFolderPath } from '@/lib/model-paths'
|
||||||
@@ -28,7 +28,7 @@ async function cleanupCompletedStagingUpload(stagingId: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/upload/git
|
* POST /api/upload/git
|
||||||
* Upload prepared files and push to GitHub via Octokit.
|
* Upload prepared files and push to the configured Git remote via Octokit.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const authError = validateUploadSecret(req)
|
const authError = validateUploadSecret(req)
|
||||||
@@ -90,7 +90,7 @@ export async function POST(req: NextRequest) {
|
|||||||
deletedFileNames,
|
deletedFileNames,
|
||||||
)
|
)
|
||||||
|
|
||||||
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
|
const { commitUrl } = await pushAllToGit(changedFilesToPush, deletePaths, commitMessage)
|
||||||
await cleanupCompletedStagingUpload(stagingId)
|
await cleanupCompletedStagingUpload(stagingId)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -100,12 +100,12 @@ export async function POST(req: NextRequest) {
|
|||||||
compressed,
|
compressed,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
compressionError: compressionError || undefined,
|
compressionError: compressionError || undefined,
|
||||||
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
|
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur Git en un seul commit.`,
|
||||||
commitUrl,
|
commitUrl,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err, 'Erreur GitHub inconnue')
|
const message = getErrorMessage(err, 'Erreur Git inconnue')
|
||||||
return uploadErrorMessageResponse(`Upload GitHub echoue: ${message}`, 500)
|
return uploadErrorMessageResponse(`Upload Git echoue: ${message}`, 500)
|
||||||
} finally {
|
} finally {
|
||||||
releaseUploadLock(folderName)
|
releaseUploadLock(folderName)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Upload GLTF',
|
title: 'Upload GLTF',
|
||||||
description: 'Interface de depot securise pour fichiers 3D (.gltf) avec versionnement automatique sur GitHub',
|
description: 'Interface de depot securise pour fichiers 3D (.gltf) avec versionnement automatique sur Git',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ export default function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-base leading-relaxed">
|
<p className="text-gray-400 text-base leading-relaxed">
|
||||||
Deposez vos fichiers 3D — ils seront archives sur le Drive
|
Deposez vos fichiers 3D — ils seront archives sur le Drive
|
||||||
<br />avec versioning, puis envoyes aux devs via GitHub
|
<br />avec versioning, puis envoyes aux devs via Git
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function DriveErrorModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ?
|
Voulez-vous quand meme envoyer les fichiers aux devs via Git ?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ModalActions
|
<ModalActions
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function useUploadOrchestrator({
|
|||||||
|
|
||||||
stagingIdRef.current = staged.stagingId
|
stagingIdRef.current = staged.stagingId
|
||||||
|
|
||||||
const endCheckLog = startTimedLog('Verification', 'GitHub diff', {
|
const endCheckLog = startTimedLog('Verification', 'Git diff', {
|
||||||
folderName: folder.folderName,
|
folderName: folder.folderName,
|
||||||
stagingId: staged.stagingId,
|
stagingId: staged.stagingId,
|
||||||
})
|
})
|
||||||
|
|||||||
+228
-46
@@ -8,29 +8,102 @@ const LFS_BATCH_SIZE = 100
|
|||||||
|
|
||||||
type LogDetails = Record<string, string | number | boolean | undefined>
|
type LogDetails = Record<string, string | number | boolean | undefined>
|
||||||
|
|
||||||
|
interface GitRemoteConfig {
|
||||||
|
apiBaseUrl: string
|
||||||
|
lfsBatchUrl: string
|
||||||
|
owner: string
|
||||||
|
provider: 'github' | 'gitea'
|
||||||
|
repo: string
|
||||||
|
token: string
|
||||||
|
webUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class GitApiError extends Error {
|
||||||
|
constructor(message: string, public status: number) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isHttpError(err: unknown): err is { status: number } {
|
function isHttpError(err: unknown): err is { status: number } {
|
||||||
return isRecord(err) && typeof err.status === 'number'
|
return isRecord(err) && typeof err.status === 'number'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOctokit(): Octokit {
|
function encodePath(path: string) {
|
||||||
const token = process.env.GITHUB_TOKEN
|
return path.split('/').map(encodeURIComponent).join('/')
|
||||||
if (!token) throw new Error('GITHUB_TOKEN non configure')
|
|
||||||
return new Octokit({ auth: token })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRepoUrl(): { owner: string; repo: string } {
|
function getRepoApiPath(remote: GitRemoteConfig) {
|
||||||
|
return `/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.repo)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestGitJson(
|
||||||
|
remote: GitRemoteConfig,
|
||||||
|
path: string,
|
||||||
|
init: { method?: string; body?: unknown } = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch(`${remote.apiBaseUrl}${path}`, {
|
||||||
|
method: init.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `token ${remote.token}`,
|
||||||
|
},
|
||||||
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new GitApiError(text || `Git API request failed (${res.status})`, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGitToken() {
|
||||||
|
const token = process.env.GIT_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim()
|
||||||
|
if (!token) throw new Error('GIT_TOKEN non configure')
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOctokit(remote: GitRemoteConfig): Octokit {
|
||||||
|
return new Octokit({
|
||||||
|
auth: remote.token,
|
||||||
|
baseUrl: remote.apiBaseUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanRepoName(repo: string) {
|
||||||
|
return repo.replace(/\/+$/, '').replace(/\.git$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteConfig(host: string, owner: string, repo: string, protocol = 'https:'): GitRemoteConfig {
|
||||||
|
const normalizedHost = host.toLowerCase()
|
||||||
|
const origin = `${protocol === 'http:' ? 'http' : 'https'}://${host}`
|
||||||
|
const isGitHub = normalizedHost === 'github.com'
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiBaseUrl: isGitHub ? 'https://api.github.com' : `${origin}/api/v1`,
|
||||||
|
lfsBatchUrl: `${origin}/${owner}/${repo}.git/info/lfs/objects/batch`,
|
||||||
|
owner,
|
||||||
|
provider: isGitHub ? 'github' : 'gitea',
|
||||||
|
repo,
|
||||||
|
token: getGitToken(),
|
||||||
|
webUrl: `${origin}/${owner}/${repo}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRepoUrl(): GitRemoteConfig {
|
||||||
const url = process.env.GIT_REPO_URL?.trim()
|
const url = process.env.GIT_REPO_URL?.trim()
|
||||||
if (!url) throw new Error('GIT_REPO_URL non configure')
|
if (!url) throw new Error('GIT_REPO_URL non configure')
|
||||||
|
|
||||||
const cleanRepoName = (repo: string) => repo.replace(/\/+$/, '').replace(/\.git$/, '')
|
|
||||||
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
|
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
|
||||||
if (shortMatch) {
|
if (shortMatch) {
|
||||||
return { owner: shortMatch[1], repo: cleanRepoName(shortMatch[2]) }
|
return buildRemoteConfig('github.com', shortMatch[1], cleanRepoName(shortMatch[2]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sshMatch = url.match(/github\.com:([^/\s]+)\/(.+)$/)
|
const scpLikeMatch = !url.includes('://') ? url.match(/^(?:[^@\s]+@)?([^:\s]+):([^/\s]+)\/([^/\s]+)$/) : null
|
||||||
if (sshMatch) {
|
if (scpLikeMatch) {
|
||||||
return { owner: sshMatch[1], repo: cleanRepoName(sshMatch[2]) }
|
return buildRemoteConfig(scpLikeMatch[1], scpLikeMatch[2], cleanRepoName(scpLikeMatch[3]))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (URL.canParse(url)) {
|
if (URL.canParse(url)) {
|
||||||
@@ -40,8 +113,8 @@ function parseRepoUrl(): { owner: string; repo: string } {
|
|||||||
.split('/')
|
.split('/')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
if (parsed.hostname === 'github.com' && pathParts.length >= 2) {
|
if ((parsed.protocol === 'https:' || parsed.protocol === 'http:' || parsed.protocol === 'ssh:') && pathParts.length >= 2) {
|
||||||
return { owner: pathParts[0], repo: cleanRepoName(pathParts[1]) }
|
return buildRemoteConfig(parsed.hostname, pathParts[0], cleanRepoName(pathParts[1]), parsed.protocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +138,44 @@ function parseLfsPointer(content: string): { oid: string; size: number } | null
|
|||||||
return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) }
|
return { oid: oidMatch[1], size: parseInt(sizeMatch[1], 10) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitContentEntry {
|
||||||
|
content?: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
sha: string
|
||||||
|
size: number
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitContentEntry(value: unknown): value is GitContentEntry {
|
||||||
|
return isRecord(value)
|
||||||
|
&& typeof value.name === 'string'
|
||||||
|
&& typeof value.path === 'string'
|
||||||
|
&& typeof value.sha === 'string'
|
||||||
|
&& typeof value.size === 'number'
|
||||||
|
&& (value.content === undefined || typeof value.content === 'string')
|
||||||
|
&& (value.type === undefined || typeof value.type === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Content(content: string) {
|
||||||
|
return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRemoteContent(remote: GitRemoteConfig, path: string, branch: string) {
|
||||||
|
const query = new URLSearchParams({ ref: branch })
|
||||||
|
return requestGitJson(remote, `${getRepoApiPath(remote)}/contents/${encodePath(path)}?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRemoteFileEntry(remote: GitRemoteConfig, path: string, branch: string): Promise<GitContentEntry | null> {
|
||||||
|
try {
|
||||||
|
const data = await getRemoteContent(remote, path, branch)
|
||||||
|
return isGitContentEntry(data) ? data : null
|
||||||
|
} catch (err) {
|
||||||
|
if (isHttpError(err) && err.status === 404) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatElapsed(startedAt: number) {
|
function formatElapsed(startedAt: number) {
|
||||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||||
}
|
}
|
||||||
@@ -89,6 +200,11 @@ interface LfsObject {
|
|||||||
contentBase64: string
|
contentBase64: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LfsPushFile = PushFile & {
|
||||||
|
oid: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
interface LfsBatchAction {
|
interface LfsBatchAction {
|
||||||
href: string
|
href: string
|
||||||
header?: Record<string, string>
|
header?: Record<string, string>
|
||||||
@@ -151,8 +267,7 @@ function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToLfs(
|
async function uploadToLfs(
|
||||||
owner: string,
|
remote: GitRemoteConfig,
|
||||||
repo: string,
|
|
||||||
objects: LfsObject[],
|
objects: LfsObject[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (objects.length === 0) return
|
if (objects.length === 0) return
|
||||||
@@ -160,13 +275,12 @@ async function uploadToLfs(
|
|||||||
const batches = chunkArray(objects, LFS_BATCH_SIZE)
|
const batches = chunkArray(objects, LFS_BATCH_SIZE)
|
||||||
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
for (let i = 0; i < batches.length; i++) {
|
||||||
await uploadToLfsBatch(owner, repo, batches[i], i + 1, batches.length)
|
await uploadToLfsBatch(remote, batches[i], i + 1, batches.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToLfsBatch(
|
async function uploadToLfsBatch(
|
||||||
owner: string,
|
remote: GitRemoteConfig,
|
||||||
repo: string,
|
|
||||||
objects: LfsObject[],
|
objects: LfsObject[],
|
||||||
batchNumber: number,
|
batchNumber: number,
|
||||||
totalBatches: number,
|
totalBatches: number,
|
||||||
@@ -176,15 +290,12 @@ async function uploadToLfsBatch(
|
|||||||
objects: objects.length,
|
objects: objects.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
const token = process.env.GITHUB_TOKEN!
|
const batchRes = await fetch(remote.lfsBatchUrl, {
|
||||||
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
|
|
||||||
|
|
||||||
const batchRes = await fetch(lfsUrl, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/vnd.git-lfs+json',
|
'Accept': 'application/vnd.git-lfs+json',
|
||||||
'Content-Type': 'application/vnd.git-lfs+json',
|
'Content-Type': 'application/vnd.git-lfs+json',
|
||||||
'Authorization': `token ${token}`,
|
'Authorization': `token ${remote.token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
operation: 'upload',
|
operation: 'upload',
|
||||||
@@ -262,41 +373,107 @@ async function uploadToLfsBatch(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GiteaFileOperation {
|
||||||
|
content?: string
|
||||||
|
operation: 'create' | 'update' | 'delete'
|
||||||
|
path: string
|
||||||
|
sha?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGiteaCommitUrl(value: unknown, remote: GitRemoteConfig, branch: string) {
|
||||||
|
if (isRecord(value) && isRecord(value.commit)) {
|
||||||
|
if (typeof value.commit.html_url === 'string') return value.commit.html_url
|
||||||
|
if (typeof value.commit.sha === 'string') return `${remote.webUrl}/commit/${value.commit.sha}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remote.webUrl}/commits/branch/${branch}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAllToGitea(
|
||||||
|
remote: GitRemoteConfig,
|
||||||
|
regularFiles: PushFile[],
|
||||||
|
lfsFiles: LfsPushFile[],
|
||||||
|
deletePaths: string[],
|
||||||
|
commitMessage: string,
|
||||||
|
branch: string,
|
||||||
|
): Promise<{ commitUrl: string }> {
|
||||||
|
const committedFiles: PushFile[] = [
|
||||||
|
...regularFiles,
|
||||||
|
...lfsFiles.map((file) => ({
|
||||||
|
path: file.path,
|
||||||
|
contentBase64: Buffer.from(buildLfsPointer(file.oid, file.size), 'utf-8').toString('base64'),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const newFilePaths = new Set(committedFiles.map((file) => file.path))
|
||||||
|
const operations: GiteaFileOperation[] = []
|
||||||
|
|
||||||
|
for (const file of committedFiles) {
|
||||||
|
const existing = await getRemoteFileEntry(remote, file.path, branch)
|
||||||
|
operations.push({
|
||||||
|
content: file.contentBase64,
|
||||||
|
operation: existing ? 'update' : 'create',
|
||||||
|
path: file.path,
|
||||||
|
sha: existing?.sha,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of deletePaths) {
|
||||||
|
if (newFilePaths.has(path)) continue
|
||||||
|
|
||||||
|
const existing = await getRemoteFileEntry(remote, path, branch)
|
||||||
|
if (!existing) continue
|
||||||
|
|
||||||
|
operations.push({
|
||||||
|
operation: 'delete',
|
||||||
|
path,
|
||||||
|
sha: existing.sha,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operations.length === 0) {
|
||||||
|
return { commitUrl: `${remote.webUrl}/commits/branch/${branch}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await requestGitJson(remote, `${getRepoApiPath(remote)}/contents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
branch,
|
||||||
|
files: operations,
|
||||||
|
message: commitMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { commitUrl: getGiteaCommitUrl(data, remote, branch) }
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRemoteFolder(
|
export async function getRemoteFolder(
|
||||||
folderPath: string,
|
folderPath: string,
|
||||||
): Promise<{ exists: boolean; files: RemoteFile[] }> {
|
): Promise<{ exists: boolean; files: RemoteFile[] }> {
|
||||||
const octokit = getOctokit()
|
const remote = parseRepoUrl()
|
||||||
const { owner, repo } = parseRepoUrl()
|
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
const branch = process.env.GIT_BRANCH ?? 'main'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await octokit.repos.getContent({
|
const data = await getRemoteContent(remote, folderPath, branch)
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
path: folderPath,
|
|
||||||
ref: branch,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
throw new Error(`Le chemin distant ${folderPath} existe mais ce n'est pas un dossier`)
|
throw new Error(`Le chemin distant ${folderPath} existe mais ce n'est pas un dossier`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: RemoteFile[] = await Promise.all(
|
const files: RemoteFile[] = await Promise.all(
|
||||||
data.map(async (f): Promise<RemoteFile> => {
|
data.map(async (f: unknown): Promise<RemoteFile> => {
|
||||||
|
if (!isGitContentEntry(f)) {
|
||||||
|
throw new Error(`Reponse Git invalide pour ${folderPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLfsFile(f.name) || f.size > 1024) {
|
if (!isLfsFile(f.name) || f.size > 1024) {
|
||||||
return { name: f.name, size: f.size }
|
return { name: f.name, size: f.size }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: fileData } = await octokit.repos.getContent({
|
const fileData = await getRemoteFileEntry(remote, `${folderPath}/${f.name}`, branch)
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
path: `${folderPath}/${f.name}`,
|
|
||||||
ref: branch,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!Array.isArray(fileData) && 'content' in fileData && fileData.content) {
|
if (fileData?.content) {
|
||||||
const content = Buffer.from(fileData.content, 'base64').toString('utf-8')
|
const content = decodeBase64Content(fileData.content)
|
||||||
const pointer = parseLfsPointer(content)
|
const pointer = parseLfsPointer(content)
|
||||||
if (pointer) {
|
if (pointer) {
|
||||||
return { name: f.name, size: pointer.size }
|
return { name: f.name, size: pointer.size }
|
||||||
@@ -319,16 +496,15 @@ export async function getRemoteFolder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushAllToGitHub(
|
export async function pushAllToGit(
|
||||||
files: PushFile[],
|
files: PushFile[],
|
||||||
deletePaths: string[],
|
deletePaths: string[],
|
||||||
commitMessage: string,
|
commitMessage: string,
|
||||||
): Promise<{ commitUrl: string }> {
|
): Promise<{ commitUrl: string }> {
|
||||||
const octokit = getOctokit()
|
const remote = parseRepoUrl()
|
||||||
const { owner, repo } = parseRepoUrl()
|
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
const branch = process.env.GIT_BRANCH ?? 'main'
|
||||||
|
|
||||||
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
|
const lfsFiles: LfsPushFile[] = []
|
||||||
const regularFiles: PushFile[] = []
|
const regularFiles: PushFile[] = []
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -343,12 +519,18 @@ export async function pushAllToGitHub(
|
|||||||
|
|
||||||
if (lfsFiles.length > 0) {
|
if (lfsFiles.length > 0) {
|
||||||
await uploadToLfs(
|
await uploadToLfs(
|
||||||
owner,
|
remote,
|
||||||
repo,
|
|
||||||
lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })),
|
lfsFiles.map((f) => ({ oid: f.oid, size: f.size, contentBase64: f.contentBase64 })),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (remote.provider === 'gitea') {
|
||||||
|
return pushAllToGitea(remote, regularFiles, lfsFiles, deletePaths, commitMessage, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
const octokit = getOctokit(remote)
|
||||||
|
const { owner, repo } = remote
|
||||||
|
|
||||||
const { data: ref } = await octokit.git.getRef({
|
const { data: ref } = await octokit.git.getRef({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -425,5 +607,5 @@ export async function pushAllToGitHub(
|
|||||||
sha: newCommit.sha,
|
sha: newCommit.sha,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { commitUrl: newCommit.html_url }
|
return { commitUrl: newCommit.html_url || `${remote.webUrl}/commit/${newCommit.sha}` }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -223,6 +223,6 @@ export async function uploadGit(
|
|||||||
warning: getCompressionWarning(data),
|
warning: getCompressionWarning(data),
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: getNetworkUploadError(err, 'Erreur GitHub') }
|
return { success: false, error: getNetworkUploadError(err, 'Erreur Git') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user