Shoraky commited on
Commit
692e287
·
1 Parent(s): df39d85

Restrict and implement session data deletion

Browse files
Files changed (2) hide show
  1. app.py +87 -2
  2. src/analysis.ts +38 -1
app.py CHANGED
@@ -444,6 +444,89 @@ def session_row(session_id, user_id):
444
  return row
445
 
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  @app.get("/api/sessions/{session_id}")
448
  def results(session_id: UUID, request: Request):
449
  row = session_row(session_id, request.state.user_id)
@@ -465,6 +548,8 @@ def results(session_id: UUID, request: Request):
465
  fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
466
  actions, failed = [], []
467
  for index, report in enumerate(reports):
 
 
468
  start = int(report.get("start_frame") or 0)
469
  end = int(report.get("end_frame") or start)
470
  action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
@@ -509,8 +594,7 @@ def results(session_id: UUID, request: Request):
509
  "url": f"/storage/{session_id}/videos/{filename}",
510
  "poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
511
  for camera_id, filename in camera_files]
512
- digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
513
- action_id = f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
514
  actions.append({"id": action_id, "label": label,
515
  "start": str(start), "end": str(end), "fps": action_fps,
516
  "startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
@@ -537,6 +621,7 @@ def results(session_id: UUID, request: Request):
537
  created_at = created_at.replace(tzinfo=timezone.utc)
538
  return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
539
  "name": row.get("name", "Analysis session"),
 
540
  "createdAt": int(created_at.timestamp() * 1000),
541
  "targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}
542
 
 
444
  return row
445
 
446
 
447
+ def result_action_id(session_id, index):
448
+ digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
449
+ return f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
450
+
451
+
452
+ def owned_session_row(session_id, user_id):
453
+ row = backend(f"/{session_id}", user_id)
454
+ if row.get("user_id") != user_id:
455
+ raise HTTPException(404, "Session not found")
456
+ return row
457
+
458
+
459
+ def upload_session_results(path_in_repo, payload, commit_message):
460
+ from io import BytesIO
461
+
462
+ hub().upload_file(
463
+ path_or_fileobj=BytesIO(payload),
464
+ path_in_repo=path_in_repo,
465
+ repo_id=os.environ["STORAGE_REPO_ID"],
466
+ repo_type="dataset",
467
+ commit_message=commit_message,
468
+ )
469
+
470
+
471
+ @app.delete("/api/sessions/{session_id}/actions/{action_id}")
472
+ def delete_action(session_id: UUID, action_id: UUID, request: Request):
473
+ row = owned_session_row(session_id, request.state.user_id)
474
+ if row.get("path") != f"sessions/{session_id}":
475
+ raise HTTPException(409, "Session results are not available")
476
+
477
+ result_path = f'{row["path"]}/results.json'
478
+ downloaded_path = hf_hub_download(
479
+ repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
480
+ filename=result_path, token=os.environ["HF_TOKEN"],
481
+ )
482
+ original_payload = Path(downloaded_path).read_bytes()
483
+ reports = json.loads(original_payload.decode("utf-8"))
484
+ matching_index = next((
485
+ index for index, report in enumerate(reports)
486
+ if not report.get("error") and not report.get("_deleted")
487
+ and result_action_id(session_id, index) == str(action_id)
488
+ ), None)
489
+ if matching_index is None:
490
+ raise HTTPException(404, "Action not found")
491
+
492
+ # Keep a tombstone so action IDs based on pipeline report indexes remain
493
+ # stable for every action that follows the deleted one.
494
+ reports[matching_index]["_deleted"] = True
495
+ updated_reports = reports
496
+ updated_payload = json.dumps(json_value(updated_reports), allow_nan=False).encode("utf-8")
497
+ upload_session_results(result_path, updated_payload, f"Delete action {action_id}")
498
+ try:
499
+ backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "DELETE")
500
+ except Exception:
501
+ try:
502
+ upload_session_results(result_path, original_payload, f"Restore action {action_id} after delete failure")
503
+ except Exception:
504
+ logger.exception("Could not restore session results after action deletion failed: session=%s action=%s", session_id, action_id)
505
+ raise
506
+ return {"ok": True}
507
+
508
+
509
+ @app.delete("/api/sessions/{session_id}")
510
+ def delete_session(session_id: UUID, request: Request):
511
+ row = owned_session_row(session_id, request.state.user_id)
512
+ expected_path = f"sessions/{session_id}"
513
+ storage_path = row.get("path")
514
+ if storage_path is not None and storage_path != expected_path:
515
+ raise HTTPException(409, "Session uses a different storage layout")
516
+
517
+ if storage_path == expected_path:
518
+ prefix = f"{storage_path}/"
519
+ existing_files = hub().list_repo_files(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset")
520
+ if any(file_path.startswith(prefix) for file_path in existing_files):
521
+ hub().delete_folder(
522
+ repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
523
+ path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
524
+ )
525
+
526
+ backend(f"/{session_id}", request.state.user_id, "DELETE")
527
+ return {"ok": True}
528
+
529
+
530
  @app.get("/api/sessions/{session_id}")
531
  def results(session_id: UUID, request: Request):
532
  row = session_row(session_id, request.state.user_id)
 
548
  fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
549
  actions, failed = [], []
550
  for index, report in enumerate(reports):
551
+ if report.get("_deleted"):
552
+ continue
553
  start = int(report.get("start_frame") or 0)
554
  end = int(report.get("end_frame") or start)
555
  action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
 
594
  "url": f"/storage/{session_id}/videos/{filename}",
595
  "poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
596
  for camera_id, filename in camera_files]
597
+ action_id = result_action_id(session_id, index)
 
598
  actions.append({"id": action_id, "label": label,
599
  "start": str(start), "end": str(end), "fps": action_fps,
600
  "startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
 
621
  created_at = created_at.replace(tzinfo=timezone.utc)
622
  return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
623
  "name": row.get("name", "Analysis session"),
624
+ "isOwner": row.get("user_id") == request.state.user_id,
625
  "createdAt": int(created_at.timestamp() * 1000),
626
  "targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}
627
 
src/analysis.ts CHANGED
@@ -87,12 +87,49 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
87
  router.get("/:sessionId", async (req, res, next) => {
88
  try {
89
  const sessionId = z.uuid().parse(req.params.sessionId);
90
- const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
91
  .eq("session_id", sessionId).maybeSingle();
92
  if (error) return res.status(503).json({ error: "Unable to load session" });
93
  return data ? res.json(data) : res.sendStatus(404);
94
  } catch (error) { next(error); }
95
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  router.put("/:sessionId", async (req, res, next) => {
97
  try {
98
  const sessionId = z.uuid().parse(req.params.sessionId);
 
87
  router.get("/:sessionId", async (req, res, next) => {
88
  try {
89
  const sessionId = z.uuid().parse(req.params.sessionId);
90
+ const { data, error } = await db.from("sessions").select("session_id,player_id,user_id,path,status,name,created_at")
91
  .eq("session_id", sessionId).maybeSingle();
92
  if (error) return res.status(503).json({ error: "Unable to load session" });
93
  return data ? res.json(data) : res.sendStatus(404);
94
  } catch (error) { next(error); }
95
  });
96
+ router.delete("/:sessionId/actions/:actionId", async (req, res, next) => {
97
+ try {
98
+ const sessionId = z.uuid().parse(req.params.sessionId);
99
+ const actionId = z.uuid().parse(req.params.actionId);
100
+ const userId = res.locals.userId as string;
101
+ const { data: session, error: sessionError } = await db.from("sessions")
102
+ .select("session_id").eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
103
+ if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
104
+ if (!session) return res.status(404).json({ error: "Session not found." });
105
+
106
+ const { data: action, error } = await db.from("actions").delete()
107
+ .eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
108
+ if (error) return res.status(503).json({ error: "Unable to delete action." });
109
+ if (!action) return res.status(404).json({ error: "Action not found." });
110
+ return res.sendStatus(204);
111
+ } catch (error) { next(error); }
112
+ });
113
+ router.delete("/:sessionId", async (req, res, next) => {
114
+ try {
115
+ const sessionId = z.uuid().parse(req.params.sessionId);
116
+ const userId = res.locals.userId as string;
117
+ const { data: session, error: sessionError } = await db.from("sessions")
118
+ .select("session_id").eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
119
+ if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
120
+ if (!session) return res.status(404).json({ error: "Session not found." });
121
+
122
+ // The schema keeps actions when a session row is removed, so explicitly
123
+ // delete them first. Their annotations and feedback cascade from actions.
124
+ const { error: actionsError } = await db.from("actions").delete().eq("session_id", sessionId);
125
+ if (actionsError) return res.status(503).json({ error: "Unable to remove session actions." });
126
+ const { data: deleted, error } = await db.from("sessions").delete()
127
+ .eq("session_id", sessionId).eq("user_id", userId).select("session_id").maybeSingle();
128
+ if (error) return res.status(503).json({ error: "Unable to delete session." });
129
+ if (!deleted) return res.status(404).json({ error: "Session not found." });
130
+ return res.sendStatus(204);
131
+ } catch (error) { next(error); }
132
+ });
133
  router.put("/:sessionId", async (req, res, next) => {
134
  try {
135
  const sessionId = z.uuid().parse(req.params.sessionId);