diff --git a/app/server.go b/app/server.go index 5014f21..071a623 100644 --- a/app/server.go +++ b/app/server.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "sync" + "time" "lol.mleku.dev/chk" "next.orly.dev/app/config" @@ -156,6 +157,11 @@ func (s *Server) UserInterface() { s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus) s.mux.HandleFunc("/api/auth/logout", s.handleAuthLogout) s.mux.HandleFunc("/api/permissions/", s.handlePermissions) + // Export endpoints + s.mux.HandleFunc("/api/export", s.handleExport) + s.mux.HandleFunc("/api/export/mine", s.handleExportMine) + // Import endpoint (admin only) + s.mux.HandleFunc("/api/import", s.handleImport) } // handleLoginInterface serves the main user interface for login @@ -356,3 +362,126 @@ func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) { w.Write(jsonData) } + +// handleExport streams all events as JSONL (NDJSON). Admins only. +func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require auth cookie + c, err := r.Cookie("orly_auth") + if err != nil || c.Value == "" { + http.Error(w, "Not authenticated", http.StatusUnauthorized) + return + } + requesterPubHex := c.Value + requesterPub, err := hex.Dec(requesterPubHex) + if chk.E(err) { + http.Error(w, "Invalid auth cookie", http.StatusUnauthorized) + return + } + // Check permissions + if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Optional filtering by pubkey(s) + var pks [][]byte + q := r.URL.Query() + for _, pkHex := range q["pubkey"] { + if pkHex == "" { + continue + } + if pk, err := hex.Dec(pkHex); !chk.E(err) { + pks = append(pks, pk) + } + } + + w.Header().Set("Content-Type", "application/x-ndjson") + filename := "events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl" + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + + // Stream export + s.D.Export(s.Ctx, w, pks...) +} + + +// handleExportMine streams only the authenticated user's events as JSONL (NDJSON). +func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require auth cookie + c, err := r.Cookie("orly_auth") + if err != nil || c.Value == "" { + http.Error(w, "Not authenticated", http.StatusUnauthorized) + return + } + pubkey, err := hex.Dec(c.Value) + if chk.E(err) { + http.Error(w, "Invalid auth cookie", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/x-ndjson") + filename := "my-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl" + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + + // Stream export for this user's pubkey only + s.D.Export(s.Ctx, w, pubkey) +} + +// handleImport receives a JSONL/NDJSON file or body and enqueues an async import. Admins only. +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require auth cookie + c, err := r.Cookie("orly_auth") + if err != nil || c.Value == "" { + http.Error(w, "Not authenticated", http.StatusUnauthorized) + return + } + requesterPub, err := hex.Dec(c.Value) + if chk.E(err) { + http.Error(w, "Invalid auth cookie", http.StatusUnauthorized) + return + } + // Admins only + if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + if err := r.ParseMultipartForm(32 << 20); chk.E(err) { // 32MB memory, rest to temp files + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if chk.E(err) { + http.Error(w, "Missing file", http.StatusBadRequest) + return + } + defer file.Close() + s.D.Import(file) + } else { + if r.Body == nil { + http.Error(w, "Empty request body", http.StatusBadRequest) + return + } + s.D.Import(r.Body) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(`{"success": true, "message": "Import started"}`)) +} diff --git a/app/web/src/App.jsx b/app/web/src/App.jsx index f7ba37f..df847e0 100644 --- a/app/web/src/App.jsx +++ b/app/web/src/App.jsx @@ -10,6 +10,7 @@ function App() { // Login view layout measurements const titleRef = useRef(null); + const fileInputRef = useRef(null); const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px useEffect(() => { @@ -339,6 +340,34 @@ function App() { updateStatus('Logged out', 'info'); } + function handleImportButton() { + try { + fileInputRef?.current?.click(); + } catch (_) {} + } + + async function handleImportChange(e) { + const file = e?.target?.files && e.target.files[0]; + if (!file) return; + try { + updateStatus('Uploading import file...', 'info'); + const fd = new FormData(); + fd.append('file', file); + const res = await fetch('/api/import', { method: 'POST', body: fd }); + if (res.ok) { + updateStatus('Import started. Processing will continue in the background.', 'success'); + } else { + const txt = await res.text(); + updateStatus('Import failed: ' + txt, 'error'); + } + } catch (err) { + updateStatus('Import failed: ' + (err?.message || String(err)), 'error'); + } finally { + // reset input so selecting the same file again works + if (e && e.target) e.target.value = ''; + } + } + // Prevent UI flash: wait until we checked auth status if (checkingAuth) { return null; @@ -347,8 +376,9 @@ function App() { return ( <> {user?.permission ? ( - // Logged in view with user profile -
here you can configure all the things
+Download your own events as line-delimited JSON (JSONL/NDJSON). Only events you authored will be included.
+Download all stored events as line-delimited JSON (JSONL/NDJSON). This may take a while on large databases.
+Upload events in line-delimited JSON (JSONL/NDJSON) to import into the database.
+