feat: fallback token admin GET + Private Pages persistantes
1. _require_gitea: fallback token admin pour les GET (orgs, projects, tree, file)
_require_user_gitea: token OAuth requis pour les write (save, delete)
2. Private Pages: table gitea_private_pages (user_id, owner, repo, title, content)
API CRUD: GET/POST/PUT/DELETE /api/gitea/projects/{o}/{r}/private-pages
UI: liste + éditeur dans le workspace Gitea, lien 🔒 Private Pages dans sidebar
Lié logiquement au projet (owner+repo), conservé entre sessions
This commit is contained in:
@@ -395,6 +395,20 @@ def init_db():
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
# v2.7: Private pages for Gitea workspaces
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS gitea_private_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
gitea_owner TEXT NOT NULL,
|
||||
gitea_repo TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT 'Untitled',
|
||||
content TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
+117
-3
@@ -6,7 +6,16 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
|
||||
|
||||
def _require_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401."""
|
||||
"""Return a per-user GiteaClient, falling back to admin token for read-only ops."""
|
||||
from app.services.gitea_client import get_user_gitea_client, GiteaClient
|
||||
client = get_user_gitea_client(request)
|
||||
if not client:
|
||||
client = GiteaClient() # fallback to server admin token
|
||||
return client
|
||||
|
||||
|
||||
def _require_user_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
if not client:
|
||||
@@ -79,7 +88,7 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
|
||||
async def save_file(request: Request, owner: str, repo: str):
|
||||
"""Create or update a file in the repo."""
|
||||
import json
|
||||
gitea = _require_gitea(request)
|
||||
gitea = _require_user_gitea(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -102,7 +111,7 @@ async def save_file(request: Request, owner: str, repo: str):
|
||||
async def delete_file(request: Request, owner: str, repo: str):
|
||||
"""Delete a file from the repo."""
|
||||
import json
|
||||
gitea = _require_gitea(request)
|
||||
gitea = _require_user_gitea(request)
|
||||
path = request.query_params.get("path", "")
|
||||
sha = request.query_params.get("sha", "")
|
||||
message = request.query_params.get("message", "Delete via FlowDeck")
|
||||
@@ -152,3 +161,108 @@ async def disconnect_gitea(request: Request):
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages")
|
||||
async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
"""List private pages for this Gitea project."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"pages": []})
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM gitea_private_pages WHERE user_id=? AND gitea_owner=? AND gitea_repo=? ORDER BY updated_at DESC",
|
||||
(user["id"], owner, repo),
|
||||
).fetchall()
|
||||
return {"pages": [{"id": r["id"], "title": r["title"], "updated_at": r["updated_at"]} for r in rows]}
|
||||
|
||||
|
||||
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||
async def create_private_page(owner: str, repo: str, request: Request):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
import json
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?,?,?,?)",
|
||||
(user["id"], owner, repo, title),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "page": {"id": cursor.lastrowid, "title": title}}
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Get a single private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(page_id, user["id"], owner, repo),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return {"page": {"id": row["id"], "title": row["title"], "content": row["content"], "updated_at": row["updated_at"]}}
|
||||
|
||||
|
||||
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Update a private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
import json
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "").strip()
|
||||
content = body.get("content", "")
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute(
|
||||
"UPDATE gitea_private_pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(title, page_id, user["id"], owner, repo),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE gitea_private_pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(content, page_id, user["id"], owner, repo),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Delete a private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
||||
(page_id, user["id"], owner, repo),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -82,11 +82,52 @@
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!showFile && !showEditor">
|
||||
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||
<div class="gw-content">
|
||||
<div class="empty-state">
|
||||
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
|
||||
<p>Select a file from the sidebar to view it.</p>
|
||||
<p>Select a file from the sidebar to view it, or browse private pages.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private pages view -->
|
||||
<template x-if="showPrivate && !editingPrivate">
|
||||
<div class="gw-content">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>🔒 Private Pages</h3>
|
||||
<button class="btn btn-primary" @click="newPrivate()">+ New Page</button>
|
||||
</div>
|
||||
<template x-if="privatePages.length === 0">
|
||||
<div class="empty-state" style="padding:40px 20px;">
|
||||
<p>No private pages yet. Create one to store notes linked to this project.</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="pp in privatePages" :key="pp.id">
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private page editor -->
|
||||
<template x-if="editingPrivate">
|
||||
<div>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">💾 Save</button>
|
||||
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,6 +143,11 @@ function giteaWorkspace() {
|
||||
owner, repo,
|
||||
showFile: false,
|
||||
showEditor: false,
|
||||
showPrivate: false,
|
||||
privatePages: [],
|
||||
editingPrivate: null,
|
||||
editingPrivateTitle: '',
|
||||
editingPrivateContent: '',
|
||||
filePath: '',
|
||||
fileContent: '',
|
||||
fileSize: 0,
|
||||
@@ -137,9 +183,24 @@ function giteaWorkspace() {
|
||||
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-sha', item.sha || '');
|
||||
li.style.cssText = 'padding-left:12px;cursor:pointer;';
|
||||
li.innerHTML = '<span class=\"page-icon\">' + icon + '</span><span class=\"page-name\">' + item.name + '</span>';
|
||||
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
|
||||
container.appendChild(li);
|
||||
});
|
||||
// Add Private Pages link
|
||||
var sep = document.createElement('li');
|
||||
sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0;';
|
||||
container.appendChild(sep);
|
||||
var pli = document.createElement('li');
|
||||
pli.className = 'sidebar-item';
|
||||
pli.style.cssText = 'padding-left:12px;cursor:pointer;';
|
||||
pli.innerHTML = '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span>';
|
||||
pli.addEventListener('click', function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var gw = document.querySelector('[x-data]').__x.$data;
|
||||
gw.showPrivate = true; gw.showFile = false; gw.showEditor = false;
|
||||
gw.loadPrivatePages();
|
||||
});
|
||||
container.appendChild(pli);
|
||||
} catch(e) { console.error('Sidebar tree load failed:', e); }
|
||||
},
|
||||
|
||||
@@ -266,6 +327,68 @@ function giteaWorkspace() {
|
||||
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
||||
return (bytes/1048576).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
// ── Private Pages ──
|
||||
|
||||
async loadPrivatePages() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
newPrivate() {
|
||||
this.editingPrivate = 'new';
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
},
|
||||
|
||||
async openPrivate(id) {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.editingPrivate = id;
|
||||
this.editingPrivateTitle = d.page.title;
|
||||
this.editingPrivateContent = d.page.content;
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async savePrivate() {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
||||
var method = 'POST';
|
||||
if (this.editingPrivate !== 'new') {
|
||||
url += '/' + this.editingPrivate;
|
||||
method = 'PUT';
|
||||
}
|
||||
try {
|
||||
var r = await fetch(url, {
|
||||
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
|
||||
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.editingPrivate = null;
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
await this.loadPrivatePages();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async deletePrivate(id) {
|
||||
if (!confirm('Delete this private page?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||
});
|
||||
if (r.ok) await this.loadPrivatePages();
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
getCsrfToken() {
|
||||
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user