The activity.file_views table existed but nothing ever wrote to it. Add a
POST /files/{id}/views endpoint: FileRepo.RecordView inserts a history row,
FileService.RecordView enforces view ACL first. The file viewer fires it
(fire-and-forget) when a file is opened, including while paging prev/next.
Documented in openapi.yaml; covered by TestRecordFileView (204 on view,
repeatable, 404 for unknown file).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -801,3 +801,13 @@ func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordView appends a row to activity.file_views. viewed_at defaults to
|
||||||
|
// statement_timestamp(), so each call records a distinct view in the history.
|
||||||
|
func (r *FileRepo) RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error {
|
||||||
|
const query = `INSERT INTO activity.file_views (file_id, user_id) VALUES ($1, $2)`
|
||||||
|
if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, fileID, userID); err != nil {
|
||||||
|
return fmt.Errorf("FileRepo.RecordView: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -300,6 +300,25 @@ func (h *FileHandler) GetMeta(c *gin.Context) {
|
|||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /files/:id/views
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RecordView logs that the current user viewed the file (activity.file_views).
|
||||||
|
func (h *FileHandler) RecordView(c *gin.Context) {
|
||||||
|
id, ok := parseFileID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.fileSvc.RecordView(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PATCH /files/:id
|
// PATCH /files/:id
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func NewRouter(
|
|||||||
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
||||||
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
|
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
|
||||||
files.GET("/:id/preview", fileHandler.GetPreview)
|
files.GET("/:id/preview", fileHandler.GetPreview)
|
||||||
|
files.POST("/:id/views", fileHandler.RecordView)
|
||||||
files.POST("/:id/restore", fileHandler.Restore)
|
files.POST("/:id/restore", fileHandler.Restore)
|
||||||
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
|
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
|
||||||
|
|
||||||
|
|||||||
@@ -690,6 +690,31 @@ func TestTagAutoRule(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
|
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecordFileView verifies that viewing a file is logged (POST .../views),
|
||||||
|
// is repeatable (view history, not a unique flag), and 404s for unknown files.
|
||||||
|
func TestRecordFileView(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
h := setupSuite(t)
|
||||||
|
adminToken := h.login("admin", "admin")
|
||||||
|
|
||||||
|
file := h.uploadJPEG(adminToken, "seen.jpg")
|
||||||
|
fileID := file["id"].(string)
|
||||||
|
|
||||||
|
resp := h.doJSON("POST", "/files/"+fileID+"/views", nil, adminToken)
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Viewing again logs another history row, not a conflict.
|
||||||
|
resp = h.doJSON("POST", "/files/"+fileID+"/views", nil, adminToken)
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Unknown file id → 404.
|
||||||
|
resp = h.doJSON("POST", "/files/00000000-0000-0000-0000-000000000000/views", nil, adminToken)
|
||||||
|
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Security regression tests
|
// Security regression tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ type FileRepo interface {
|
|||||||
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
||||||
// SetTags replaces all tags on a file (full replace semantics).
|
// SetTags replaces all tags on a file (full replace semantics).
|
||||||
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
||||||
|
|
||||||
|
// RecordView appends a view-history row (activity.file_views) for the user.
|
||||||
|
RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagRepo is the persistence interface for tags.
|
// TagRepo is the persistence interface for tags.
|
||||||
|
|||||||
@@ -208,6 +208,26 @@ func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, erro
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordView appends a view-history entry for the current user, enforcing view
|
||||||
|
// ACL (you can only record a view of a file you may see).
|
||||||
|
func (s *FileService) RecordView(ctx context.Context, id uuid.UUID) error {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
f, err := s.files.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
return s.files.RecordView(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// Update applies metadata changes to a file, enforcing edit ACL.
|
// Update applies metadata changes to a file, enforcing edit ACL.
|
||||||
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|||||||
@@ -77,6 +77,9 @@
|
|||||||
isPublic = fileData.is_public ?? false;
|
isPublic = fileData.is_public ?? false;
|
||||||
dirty = false;
|
dirty = false;
|
||||||
void fetchPreview(id);
|
void fetchPreview(id);
|
||||||
|
// Log the view (activity.file_views). Fire-and-forget — never block or
|
||||||
|
// fail the viewer over view tracking.
|
||||||
|
void api.post(`/files/${id}/views`).catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load file';
|
error = e instanceof ApiError ? e.message : 'Failed to load file';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -407,6 +407,18 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/files/{file_id}/views:
|
||||||
|
post:
|
||||||
|
tags: [Files]
|
||||||
|
summary: Record that the current user viewed the file
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/file_id'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: View recorded
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
/files/{file_id}/restore:
|
/files/{file_id}/restore:
|
||||||
post:
|
post:
|
||||||
tags: [Files]
|
tags: [Files]
|
||||||
|
|||||||
Reference in New Issue
Block a user