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
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -84,6 +84,7 @@ func NewRouter(
|
||||
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
||||
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
|
||||
files.GET("/:id/preview", fileHandler.GetPreview)
|
||||
files.POST("/:id/views", fileHandler.RecordView)
|
||||
files.POST("/:id/restore", fileHandler.Restore)
|
||||
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
|
||||
|
||||
|
||||
@@ -690,6 +690,31 @@ func TestTagAutoRule(t *testing.T) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -59,6 +59,9 @@ type FileRepo interface {
|
||||
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
||||
// SetTags replaces all tags on a file (full replace semantics).
|
||||
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.
|
||||
|
||||
@@ -208,6 +208,26 @@ func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, erro
|
||||
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.
|
||||
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
isPublic = fileData.is_public ?? false;
|
||||
dirty = false;
|
||||
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) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load file';
|
||||
} finally {
|
||||
|
||||
@@ -407,6 +407,18 @@ paths:
|
||||
'404':
|
||||
$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:
|
||||
post:
|
||||
tags: [Files]
|
||||
|
||||
Reference in New Issue
Block a user