Upload per ZIP-Tool (1/1)

This commit is contained in:
Oliver Braasch
2026-09-25 22:24:54 +02:00
parent 2f0cba1cd1
commit 8cc20fb068
3 changed files with 446 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Oliver Braasch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+29 -1
View File
@@ -1,2 +1,30 @@
# gitea-uploader # ZIP → Gitea Upload
Lädt eine ZIP-Datei direkt aus dem Browser in ein Gitea-Repository hoch -
mit Ordnerstruktur, in einem oder mehreren Commits. Läuft vollständig
client-seitig; es wird ausschließlich mit dem selbst angegebenen
Gitea-Server kommuniziert.
## Nutzung
1. Gitea-Server-URL, Benutzername und Passwort oder Zugangstoken eingeben,
"Verbinden & Repos laden" klicken.
2. Bestehendes Repo aus der Liste wählen **oder** einen neuen Namen
eingeben und "Repo erstellen" klicken.
3. ZIP-Datei auswählen.
4. Commit-Nachricht anpassen (optional) und "Jetzt hochladen" klicken.
Vorhandene Dateien mit gleichem Pfad werden aktualisiert statt doppelt
angelegt. Über die Checkbox "Dateien im Repo löschen, die nicht mehr in
der ZIP enthalten sind" kann der Repo-Stand komplett an die ZIP angepasst
werden (mit Bedacht nutzen).
## Voraussetzung auf dem Gitea-Server
Damit der Browser die Gitea-API von einer anderen Domain aus aufrufen
darf, muss CORS für diese Domain aktiviert sein (Abschnitt `[cors]` in
`app.ini`).
## Lizenz
MIT, siehe [LICENSE](./LICENSE).
+396
View File
@@ -0,0 +1,396 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ZIP zu Gitea hochladen</title>
<style>
:root{
--bg:#eef0f2; --card:#ffffff; --text:#1a1a1a; --muted:#5a6472;
--accent:#f07d1e; --accent-dark:#c9650f; --border:#d7dbe0;
--ok:#1a7f37; --err:#c0392b; --header-bg:#111214;
}
*{box-sizing:border-box;}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);}
header{background:var(--header-bg);color:#fff;padding:22px 24px;}
header h1{margin:0;font-size:1.6rem;}
header h1 .accent{color:var(--accent);}
header p{margin:6px 0 0;color:#b8bcc2;font-size:.95rem;}
main{max-width:760px;margin:24px auto 60px;padding:0 16px;}
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px 22px;margin-bottom:20px;}
.card h2{margin:0 0 4px;font-size:1.05rem;}
.card .step-num{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--accent);color:#fff;font-size:.85rem;margin-right:8px;}
label{display:block;font-size:.88rem;color:var(--muted);margin:12px 0 4px;}
input[type=text],input[type=password],input[type=url],select{
width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:6px;font-size:.95rem;background:#fbfbfc;
}
input[type=checkbox]{margin-right:6px;}
.row{display:flex;gap:12px;flex-wrap:wrap;}
.row > *{flex:1;min-width:160px;}
button{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:10px 16px;font-size:.95rem;cursor:pointer;margin-top:14px;}
button:hover{background:var(--accent-dark);}
button.secondary{background:#fff;color:var(--text);border:1px solid var(--border);}
button.secondary:hover{background:#f3f4f5;}
button:disabled{background:#c9ccd1;cursor:not-allowed;}
.hint{font-size:.82rem;color:var(--muted);margin-top:6px;}
.status{margin-top:14px;font-size:.9rem;}
.status.ok{color:var(--ok);}
.status.err{color:var(--err);}
#log{margin-top:14px;background:#0e0f11;color:#d7dbe0;border-radius:8px;padding:12px;font-family:ui-monospace,Consolas,monospace;font-size:.82rem;max-height:220px;overflow-y:auto;white-space:pre-wrap;display:none;}
#log .l-ok{color:#7ed99a;}
#log .l-err{color:#ff8a80;}
.progress-wrap{margin-top:10px;height:8px;border-radius:4px;background:#e2e4e7;overflow:hidden;display:none;}
.progress-bar{height:100%;background:var(--accent);width:0%;transition:width .2s;}
fieldset{border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:8px;}
fieldset legend{font-size:.85rem;color:var(--muted);padding:0 6px;}
.disabled-section{opacity:.5;pointer-events:none;}
footer{text-align:center;color:var(--muted);font-size:.8rem;padding:20px;}
</style>
</head>
<body>
<header>
<h1>ZIP <span class="accent">→</span> Gitea</h1>
<p>Lädt eine ZIP-Datei direkt aus deinem Browser in ein Gitea-Repository hoch. Nichts verlässt deinen Rechner außer der Verbindung zu deinem eigenen Gitea-Server.</p>
</header>
<main>
<div class="card">
<h2><span class="step-num">1</span>Gitea-Zugang</h2>
<label for="serverUrl">Gitea-Server</label>
<input type="url" id="serverUrl" value="https://git.obraasch.de" placeholder="https://git.example.de">
<div class="row">
<div>
<label for="username">Benutzername</label>
<input type="text" id="username" autocomplete="username">
</div>
<div>
<label for="password">Passwort oder Zugangstoken</label>
<input type="password" id="password" autocomplete="current-password">
</div>
</div>
<p class="hint">Ein Zugangstoken (Einstellungen → Anwendungen → Neues Token in Gitea) ist sicherer als das echte Passwort, weil du es jederzeit einzeln widerrufen kannst. Beides funktioniert hier.</p>
<button id="btnLoadRepos">Verbinden &amp; Repos laden</button>
<div id="authStatus" class="status"></div>
</div>
<div class="card disabled-section" id="repoCard">
<h2><span class="step-num">2</span>Repository wählen</h2>
<label for="repoSelect">Vorhandenes Repo</label>
<select id="repoSelect"><option value="">– bitte zuerst verbinden –</option></select>
<fieldset style="margin-top:16px;">
<legend>Oder neues Repo anlegen</legend>
<div class="row">
<input type="text" id="newRepoName" placeholder="repo-name">
<label style="display:flex;align-items:center;flex:0 0 auto;margin-top:0;">
<input type="checkbox" id="newRepoPrivate" checked> Privat
</label>
</div>
<button class="secondary" id="btnCreateRepo">Repo erstellen &amp; auswählen</button>
</fieldset>
<div id="repoStatus" class="status"></div>
</div>
<div class="card disabled-section" id="zipCard">
<h2><span class="step-num">3</span>ZIP-Datei wählen</h2>
<input type="file" id="zipFile" accept=".zip">
<p class="hint">
<label style="display:inline;margin:0;">
<input type="checkbox" id="stripRoot" checked> Obersten Ordner aus der ZIP entfernen
</label>
– meistens gewünscht, wenn die ZIP einen einzelnen Projektordner enthält (z.B. "meinprojekt/...").
</p>
<p class="hint">
<label style="display:inline;margin:0;">
<input type="checkbox" id="removeMissing">
Dateien im Repo löschen, die nicht mehr in der ZIP enthalten sind
</label>
– nur aktivieren, wenn die ZIP wirklich den kompletten, aktuellen Stand enthält!
</p>
<div id="zipStatus" class="status"></div>
</div>
<div class="card disabled-section" id="uploadCard">
<h2><span class="step-num">4</span>Hochladen</h2>
<label for="commitMsg">Commit-Nachricht</label>
<input type="text" id="commitMsg" value="Upload per ZIP-Tool">
<button id="btnUpload">Jetzt hochladen</button>
<div class="progress-wrap" id="progressWrap"><div class="progress-bar" id="progressBar"></div></div>
<div id="uploadStatus" class="status"></div>
<div id="log"></div>
</div>
</main>
<footer>Läuft vollständig in deinem Browser. Es werden keine Daten an irgendeinen Server außer dem oben angegebenen Gitea-Server übertragen.</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
(function(){
"use strict";
var state = {
server: "", authHeader: "", repos: [], repo: null, // {owner, name, defaultBranch}
zipFiles: null // [{path, base64}]
};
function el(id){ return document.getElementById(id); }
function setStatus(id, msg, cls){
var n = el(id);
n.textContent = msg;
n.className = "status" + (cls ? " " + cls : "");
}
function enable(id){ el(id).classList.remove("disabled-section"); }
function disable(id){ el(id).classList.add("disabled-section"); }
function b64utf8(str){
return btoa(unescape(encodeURIComponent(str)));
}
function apiUrl(path){
return state.server.replace(/\/+$/,"") + path;
}
function logLine(msg, isErr){
var log = el("log");
log.style.display = "block";
var line = document.createElement("div");
line.className = isErr ? "l-err" : "l-ok";
line.textContent = msg;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
async function apiFetch(path, opts){
opts = opts || {};
opts.headers = Object.assign({
"Authorization": state.authHeader,
"Content-Type": "application/json"
}, opts.headers || {});
var res = await fetch(apiUrl(path), opts);
if(!res.ok){
var msg = "HTTP " + res.status;
try{
var body = await res.json();
if(body && body.message) msg += ": " + body.message;
}catch(e){}
throw new Error(msg);
}
if(res.status === 204) return null;
return res.json();
}
// ---- Schritt 1: Verbinden & Repos laden ----
el("btnLoadRepos").addEventListener("click", async function(){
state.server = el("serverUrl").value.trim();
var user = el("username").value.trim();
var pass = el("password").value;
if(!state.server || !user || !pass){
setStatus("authStatus", "Bitte Server, Benutzername und Passwort/Token ausfüllen.", "err");
return;
}
state.authHeader = "Basic " + b64utf8(user + ":" + pass);
setStatus("authStatus", "Verbinde...", "");
try{
var all = [];
for(var page = 1; page <= 5; page++){
var batch = await apiFetch("/api/v1/user/repos?limit=50&page=" + page);
all = all.concat(batch);
if(batch.length < 50) break;
}
state.repos = all;
var sel = el("repoSelect");
sel.innerHTML = "";
if(all.length === 0){
sel.innerHTML = "<option value=''>– keine Repos gefunden –</option>";
}else{
sel.innerHTML = "<option value=''>– bitte auswählen –</option>" +
all.map(function(r, i){
return "<option value='" + i + "'>" + r.full_name + (r.private ? " (privat)" : "") + "</option>";
}).join("");
}
enable("repoCard");
setStatus("authStatus", "Verbunden. " + all.length + " Repo(s) gefunden.", "ok");
}catch(e){
setStatus("authStatus", "Login fehlgeschlagen: " + e.message, "err");
}
});
el("repoSelect").addEventListener("change", function(){
var i = this.value;
if(i === ""){ state.repo = null; disable("zipCard"); return; }
var r = state.repos[i];
state.repo = { owner: r.owner.login, name: r.name, defaultBranch: r.default_branch || "main" };
setStatus("repoStatus", "Ausgewählt: " + r.full_name + " (Branch: " + state.repo.defaultBranch + ")", "ok");
enable("zipCard");
});
// ---- Schritt 2: Neues Repo erstellen ----
el("btnCreateRepo").addEventListener("click", async function(){
var name = el("newRepoName").value.trim();
if(!name){ setStatus("repoStatus", "Bitte einen Repo-Namen eingeben.", "err"); return; }
setStatus("repoStatus", "Erstelle Repo...", "");
try{
var priv = el("newRepoPrivate").checked;
var r = await apiFetch("/api/v1/user/repos", {
method: "POST",
body: JSON.stringify({ name: name, private: priv, auto_init: true })
});
state.repos.push(r);
var sel = el("repoSelect");
var opt = document.createElement("option");
opt.value = state.repos.length - 1;
opt.textContent = r.full_name + (r.private ? " (privat)" : "");
sel.appendChild(opt);
sel.value = state.repos.length - 1;
sel.dispatchEvent(new Event("change"));
setStatus("repoStatus", "Repo '" + r.full_name + "' erstellt und ausgewählt.", "ok");
}catch(e){
setStatus("repoStatus", "Erstellen fehlgeschlagen: " + e.message, "err");
}
});
// ---- Schritt 3: ZIP einlesen ----
el("zipFile").addEventListener("change", async function(){
var file = this.files[0];
if(!file) return;
setStatus("zipStatus", "Lese ZIP...", "");
try{
var zip = await JSZip.loadAsync(file);
var entries = [];
var names = Object.keys(zip.files);
for(var i = 0; i < names.length; i++){
var entry = zip.files[names[i]];
if(entry.dir) continue;
var b64 = await entry.async("base64");
entries.push({ path: entry.name, base64: b64 });
}
if(entries.length === 0){
setStatus("zipStatus", "ZIP enthält keine Dateien.", "err");
return;
}
// gemeinsamen obersten Ordner erkennen
if(el("stripRoot").checked){
var firstSlash = entries[0].path.indexOf("/");
if(firstSlash > 0){
var prefix = entries[0].path.substring(0, firstSlash + 1);
var allShare = entries.every(function(e){ return e.path.indexOf(prefix) === 0; });
if(allShare){
entries = entries.map(function(e){
return { path: e.path.substring(prefix.length), base64: e.base64 };
});
}
}
}
state.zipFiles = entries;
enable("uploadCard");
setStatus("zipStatus", entries.length + " Datei(en) gefunden, bereit zum Hochladen.", "ok");
}catch(e){
setStatus("zipStatus", "ZIP konnte nicht gelesen werden: " + e.message, "err");
}
});
// ---- Schritt 4: Hochladen ----
el("btnUpload").addEventListener("click", async function(){
if(!state.repo || !state.zipFiles){
setStatus("uploadStatus", "Bitte erst Repo wählen und ZIP einlesen.", "err");
return;
}
var btn = el("btnUpload");
btn.disabled = true;
el("log").innerHTML = "";
el("log").style.display = "block";
el("progressWrap").style.display = "block";
setStatus("uploadStatus", "Ermittle aktuellen Stand im Repo...", "");
try{
// aktuellen Baum holen (welche Dateien gibt es schon, mit welcher SHA)
var existing = {};
try{
var tree = await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name +
"/git/trees/" + encodeURIComponent(state.repo.defaultBranch) + "?recursive=true"
);
(tree.tree || []).forEach(function(t){
if(t.type === "blob") existing[t.path] = t.sha;
});
}catch(e){
logLine("Hinweis: Konnte bestehenden Stand nicht lesen (evtl. leeres Repo) - " + e.message, false);
}
var newPaths = {};
var operations = state.zipFiles.map(function(f){
newPaths[f.path] = true;
var op = { path: f.path, content: f.base64 };
if(existing.hasOwnProperty(f.path)){
op.operation = "update";
op.sha = existing[f.path];
}else{
op.operation = "create";
}
return op;
});
if(el("removeMissing").checked){
Object.keys(existing).forEach(function(p){
if(!newPaths[p]){
operations.push({ path: p, operation: "delete", sha: existing[p] });
}
});
}
// in Batches aufteilen (Größe & Anzahl begrenzen, wegen Server-Limits)
var batches = [];
var current = [];
var currentSize = 0;
var MAX_FILES = 20;
var MAX_BYTES = 3 * 1024 * 1024; // ~3MB Base64 pro Anfrage
operations.forEach(function(op){
var size = op.content ? op.content.length : 0;
if(current.length >= MAX_FILES || (currentSize + size) > MAX_BYTES){
batches.push(current);
current = [];
currentSize = 0;
}
current.push(op);
currentSize += size;
});
if(current.length) batches.push(current);
var msg = el("commitMsg").value.trim() || "Upload per ZIP-Tool";
var done = 0, total = operations.length;
for(var b = 0; b < batches.length; b++){
setStatus("uploadStatus", "Lade Batch " + (b+1) + " von " + batches.length + " hoch...", "");
await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name + "/contents",
{
method: "POST",
body: JSON.stringify({
files: batches[b],
message: msg + " (" + (b+1) + "/" + batches.length + ")",
branch: state.repo.defaultBranch
})
}
);
done += batches[b].length;
el("progressBar").style.width = Math.round(done / total * 100) + "%";
logLine("Batch " + (b+1) + "/" + batches.length + " ok (" + batches[b].length + " Datei(en))", false);
}
setStatus("uploadStatus", "Fertig! " + total + " Datei(en) hochgeladen/aktualisiert.", "ok");
}catch(e){
setStatus("uploadStatus", "Fehler: " + e.message, "err");
logLine("Abgebrochen: " + e.message, true);
}finally{
btn.disabled = false;
}
});
})();
</script>
</body>
</html>