fix: enforce workspace isolation logging
This commit is contained in:
@@ -191,8 +191,13 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
ResetFileBrowser event,
|
ResetFileBrowser event,
|
||||||
Emitter<FileBrowserState> emit,
|
Emitter<FileBrowserState> emit,
|
||||||
) {
|
) {
|
||||||
|
final oldOrgId = _currentOrgId;
|
||||||
|
final clearedCount = _currentFiles.length;
|
||||||
|
print(
|
||||||
|
'[FILE-BROWSER] Switching workspace, oldOrgId=$oldOrgId, newOrgId=${event.nextOrgId}, clearing=$clearedCount files, reloading state',
|
||||||
|
);
|
||||||
emit(DirectoryInitial());
|
emit(DirectoryInitial());
|
||||||
_currentOrgId = '';
|
_currentOrgId = event.nextOrgId;
|
||||||
_currentPath = '/';
|
_currentPath = '/';
|
||||||
_currentFiles = [];
|
_currentFiles = [];
|
||||||
_filteredFiles = [];
|
_filteredFiles = [];
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
|
|||||||
List<Object> get props => [orgId, parentPath, folderName];
|
List<Object> get props => [orgId, parentPath, folderName];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResetFileBrowser extends FileBrowserEvent {}
|
class ResetFileBrowser extends FileBrowserEvent {
|
||||||
|
final String nextOrgId;
|
||||||
|
|
||||||
|
const ResetFileBrowser(this.nextOrgId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [nextOrgId];
|
||||||
|
}
|
||||||
|
|
||||||
class LoadPage extends FileBrowserEvent {
|
class LoadPage extends FileBrowserEvent {
|
||||||
final int page;
|
final int page;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
);
|
);
|
||||||
// Reset all dependent blocs
|
// Reset all dependent blocs
|
||||||
permissionBloc.add(PermissionsReset());
|
permissionBloc.add(PermissionsReset());
|
||||||
fileBrowserBloc.add(ResetFileBrowser());
|
fileBrowserBloc.add(ResetFileBrowser(event.orgId));
|
||||||
uploadBloc.add(ResetUploads());
|
uploadBloc.add(ResetUploads());
|
||||||
// Load permissions for the selected org
|
// Load permissions for the selected org
|
||||||
permissionBloc.add(LoadPermissions(event.orgId));
|
permissionBloc.add(LoadPermissions(event.orgId));
|
||||||
@@ -168,7 +168,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
|
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
|
||||||
// Reset blocs and load permissions for new org
|
// Reset blocs and load permissions for new org
|
||||||
permissionBloc.add(PermissionsReset());
|
permissionBloc.add(PermissionsReset());
|
||||||
fileBrowserBloc.add(ResetFileBrowser());
|
fileBrowserBloc.add(ResetFileBrowser(newOrg.id));
|
||||||
uploadBloc.add(ResetUploads());
|
uploadBloc.add(ResetUploads());
|
||||||
permissionBloc.add(LoadPermissions(newOrg.id));
|
permissionBloc.add(LoadPermissions(newOrg.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
if (oldWidget.orgId != widget.orgId) {
|
if (oldWidget.orgId != widget.orgId) {
|
||||||
// Reset and reload when switching between Personal and Org workspaces
|
// Reset and reload when switching between Personal and Org workspaces
|
||||||
final bloc = context.read<FileBrowserBloc>();
|
final bloc = context.read<FileBrowserBloc>();
|
||||||
bloc.add(ResetFileBrowser());
|
print(
|
||||||
|
'[FILE-BROWSER] UI detected workspace switch, oldOrgId=${oldWidget.orgId}, newOrgId=${widget.orgId}, clearing local view',
|
||||||
|
);
|
||||||
|
bloc.add(ResetFileBrowser(widget.orgId));
|
||||||
bloc.add(LoadDirectory(orgId: widget.orgId, path: '/'));
|
bloc.add(LoadDirectory(orgId: widget.orgId, path: '/'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -824,7 +827,9 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
splashColor: Colors.transparent,
|
splashColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final parentPath = _getParentPath(state.currentPath);
|
final parentPath = _getParentPath(
|
||||||
|
state.currentPath,
|
||||||
|
);
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
LoadDirectory(
|
LoadDirectory(
|
||||||
orgId: widget.orgId,
|
orgId: widget.orgId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -255,7 +256,7 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||||
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||||
if page <= 0 {
|
if page <= 0 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -264,22 +265,31 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q s
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
orgIDStr := orgID.String()
|
||||||
|
userIDStr := userID.String()
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
|
||||||
|
|
||||||
// Basic search and pagination. Returns only direct children of the given path.
|
// Basic search and pagination. Returns only direct children of the given path.
|
||||||
// For root ("/"), we want files where path doesn't contain "/" after the first character.
|
// For root ("/"), we want files where path doesn't contain "/" after the first character.
|
||||||
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
|
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
|
||||||
FROM files
|
FROM files f
|
||||||
WHERE org_id = $1
|
WHERE f.org_id = $1
|
||||||
AND path != $2
|
AND EXISTS (
|
||||||
AND (
|
SELECT 1
|
||||||
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
FROM memberships m
|
||||||
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
|
WHERE m.org_id = $1 AND m.user_id = $2
|
||||||
)
|
)
|
||||||
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
AND f.path != $3
|
||||||
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
|
AND (
|
||||||
LIMIT $4 OFFSET $5
|
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
|
||||||
`, orgID, path, q, pageSize, offset)
|
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
|
||||||
|
)
|
||||||
|
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
|
||||||
|
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
|
||||||
|
LIMIT $5 OFFSET $6
|
||||||
|
`, orgID, userID, path, q, pageSize, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -303,7 +313,11 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q s
|
|||||||
}
|
}
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
return files, rows.Err()
|
err = rows.Err()
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
|
||||||
|
}
|
||||||
|
return files, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserFiles returns files for a user's personal workspace at a given path
|
// GetUserFiles returns files for a user's personal workspace at a given path
|
||||||
@@ -317,10 +331,12 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
|
|||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
// Return only direct children of the given path
|
// Return only direct children of the given path
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
FROM files
|
FROM files
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
|
AND org_id IS NULL
|
||||||
AND path != $2
|
AND path != $2
|
||||||
AND (
|
AND (
|
||||||
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
||||||
@@ -353,7 +369,11 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
|
|||||||
}
|
}
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
return files, rows.Err()
|
err = rows.Err()
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
|
||||||
|
}
|
||||||
|
return files, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
||||||
@@ -361,16 +381,21 @@ func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUI
|
|||||||
var f File
|
var f File
|
||||||
var orgIDVal interface{}
|
var orgIDVal interface{}
|
||||||
var userIDVal interface{}
|
var userIDVal interface{}
|
||||||
|
orgIDStr := ""
|
||||||
|
userIDStr := ""
|
||||||
if orgID != nil {
|
if orgID != nil {
|
||||||
orgIDVal = *orgID
|
orgIDVal = *orgID
|
||||||
|
orgIDStr = orgID.String()
|
||||||
} else {
|
} else {
|
||||||
orgIDVal = nil
|
orgIDVal = nil
|
||||||
}
|
}
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
userIDVal = *userID
|
userIDVal = *userID
|
||||||
|
userIDStr = userID.String()
|
||||||
} else {
|
} else {
|
||||||
userIDVal = nil
|
userIDVal = nil
|
||||||
}
|
}
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
INSERT INTO files (org_id, user_id, name, path, type, size)
|
INSERT INTO files (org_id, user_id, name, path, type, size)
|
||||||
@@ -380,6 +405,7 @@ func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
|
||||||
return &f, nil
|
return &f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,17 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a
|
|||||||
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
// Org ID is provided by middleware.Org
|
// Org ID is provided by middleware.Org
|
||||||
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||||
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||||
|
if !ok || userIDStr == "" {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogError(r, err, "Invalid user id in context")
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Query params: path, q (search), page, pageSize
|
// Query params: path, q (search), page, pageSize
|
||||||
path := r.URL.Query().Get("path")
|
path := r.URL.Query().Get("path")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -372,7 +383,7 @@ func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|||||||
fmt.Sscanf(ps, "%d", &pageSize)
|
fmt.Sscanf(ps, "%d", &pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := db.GetOrgFiles(r.Context(), orgID, path, q, page, pageSize)
|
files, err := db.GetOrgFiles(r.Context(), orgID, userID, path, q, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Failed to get org files")
|
errors.LogError(r, err, "Failed to get org files")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
|
|||||||
Reference in New Issue
Block a user