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:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
conn.commit()
|
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
|
@contextmanager
|
||||||
|
|||||||
+117
-3
@@ -6,7 +6,16 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
|||||||
|
|
||||||
|
|
||||||
def _require_gitea(request: Request):
|
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
|
from app.services.gitea_client import get_user_gitea_client
|
||||||
client = get_user_gitea_client(request)
|
client = get_user_gitea_client(request)
|
||||||
if not client:
|
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):
|
async def save_file(request: Request, owner: str, repo: str):
|
||||||
"""Create or update a file in the repo."""
|
"""Create or update a file in the repo."""
|
||||||
import json
|
import json
|
||||||
gitea = _require_gitea(request)
|
gitea = _require_user_gitea(request)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
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):
|
async def delete_file(request: Request, owner: str, repo: str):
|
||||||
"""Delete a file from the repo."""
|
"""Delete a file from the repo."""
|
||||||
import json
|
import json
|
||||||
gitea = _require_gitea(request)
|
gitea = _require_user_gitea(request)
|
||||||
path = request.query_params.get("path", "")
|
path = request.query_params.get("path", "")
|
||||||
sha = request.query_params.get("sha", "")
|
sha = request.query_params.get("sha", "")
|
||||||
message = request.query_params.get("message", "Delete via FlowDeck")
|
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.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"status": "ok"}
|
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>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<template x-if="!showFile && !showEditor">
|
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||||
<div class="gw-content">
|
<div class="gw-content">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -102,6 +143,11 @@ function giteaWorkspace() {
|
|||||||
owner, repo,
|
owner, repo,
|
||||||
showFile: false,
|
showFile: false,
|
||||||
showEditor: false,
|
showEditor: false,
|
||||||
|
showPrivate: false,
|
||||||
|
privatePages: [],
|
||||||
|
editingPrivate: null,
|
||||||
|
editingPrivateTitle: '',
|
||||||
|
editingPrivateContent: '',
|
||||||
filePath: '',
|
filePath: '',
|
||||||
fileContent: '',
|
fileContent: '',
|
||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
@@ -137,9 +183,24 @@ function giteaWorkspace() {
|
|||||||
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file');
|
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file');
|
||||||
li.setAttribute('data-gitea-sha', item.sha || '');
|
li.setAttribute('data-gitea-sha', item.sha || '');
|
||||||
li.style.cssText = 'padding-left:12px;cursor:pointer;';
|
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);
|
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); }
|
} catch(e) { console.error('Sidebar tree load failed:', e); }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -266,6 +327,68 @@ function giteaWorkspace() {
|
|||||||
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
||||||
return (bytes/1048576).toFixed(1) + ' MB';
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user