4 Commits

Author SHA1 Message Date
H1K0 9a14c50250 fix(frontend): reload the file grid on filter/sort change
deploy / deploy (push) Successful in 57s
Changing the filter (or sort/order) reset the grid to empty but never fetched
page 1 — the reset effect only loaded for the deep-link anchor case and
otherwise relied on InfiniteScroll, which doesn't re-trigger without a remount
or scroll. So filtering blanked the list until a hard refresh. Load page 1 from
the reset effect itself for the non-anchor case (guarded by `loading`, so it
doesn't double-fetch with InfiniteScroll's mount load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:49:34 +03:00
H1K0 e7d24f0677 feat(frontend): default the apply-to-existing tag-rule toggle on
New tag rules should retroactively apply to existing files by default, so flip
the tagRuleApplyToExisting default to true. (Settings already saved in
localStorage keep their stored value.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:49:23 +03:00
H1K0 d357ae3156 feat: open file original in a new tab via authenticated direct link
The file viewer's preview is now a real link (target=_blank) to the original,
instead of fetching it into a blob. A navigation can't send the auth header, so
the access token rides in the query — the auth middleware accepts ?access_token=
as a fallback, but only for GET, so a crafted link can't drive a mutation.

GetContent gains an ?inline=1 toggle (Content-Disposition: inline) so the tab
views the original instead of downloading it; download stays the default.

Documented in openapi.yaml; TestMediaQueryTokenAuth covers GET-with-query-token
(200), missing token (401) and query-token rejected on a non-GET (401).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:40:50 +03:00
H1K0 03936243e4 fix(frontend): reflect rule-applied tags in batch edit
BulkTagEditor optimistically marked only the clicked tag as common after a bulk
add, so tags applied by auto-tag rules (resolved server-side) never appeared.
Refetch /files/bulk/common-tags after each change and rebuild the common/partial
sets from the response, so rule-applied tags and partial->common shifts show up.

Backend bulk path was already correct — covered now by TestBulkTagAutoRule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:06:25 +03:00
8 changed files with 166 additions and 17 deletions
+6 -1
View File
@@ -402,9 +402,14 @@ func (h *FileHandler) GetContent(c *gin.Context) {
c.Header("Content-Type", res.MIMEType)
c.Header("Cache-Control", "private, max-age=3600")
// Default to attachment (download); ?inline=1 serves it for in-tab viewing.
disposition := "attachment"
if c.Query("inline") == "1" {
disposition = "inline"
}
if res.OriginalName != nil {
c.Header("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
fmt.Sprintf("%s; filename=%q", disposition, *res.OriginalName))
}
c.Status(http.StatusOK)
io.Copy(c.Writer, res.Body) //nolint:errcheck
+17 -3
View File
@@ -24,8 +24,8 @@ func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
// On success it calls c.Next(); on failure it aborts with 401 JSON.
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
return func(c *gin.Context) {
raw := c.GetHeader("Authorization")
if !strings.HasPrefix(raw, "Bearer ") {
token := bearerToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(),
Message: "authorization header missing or malformed",
@@ -33,7 +33,6 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
c.Abort()
return
}
token := strings.TrimPrefix(raw, "Bearer ")
claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token)
if err != nil {
@@ -50,3 +49,18 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
c.Next()
}
}
// bearerToken extracts the access token from the Authorization header. As a
// fallback it accepts an ?access_token= query parameter, but only for GET
// requests — this lets the browser open media (e.g. /files/{id}/content) via a
// plain link/new tab, where it can't send the header, without allowing a crafted
// link to drive a state-changing request.
func bearerToken(c *gin.Context) string {
if raw := c.GetHeader("Authorization"); strings.HasPrefix(raw, "Bearer ") {
return strings.TrimPrefix(raw, "Bearer ")
}
if c.Request.Method == http.MethodGet {
return c.Query("access_token")
}
return ""
}
@@ -715,6 +715,79 @@ func TestRecordFileView(t *testing.T) {
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
}
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
func TestBulkTagAutoRule(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var outdoor map[string]any
resp.decode(t, &outdoor)
outdoorID := outdoor["id"].(string)
resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var nature map[string]any
resp.decode(t, &nature)
natureID := nature["id"].(string)
resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{"then_tag_id": natureID}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
file := h.uploadJPEG(adminToken, "park.jpg")
fileID := file["id"].(string)
// Bulk-add only "outdoor" to the file.
resp = h.doJSON("POST", "/files/bulk/tags", map[string]any{
"file_ids": []string{fileID},
"action": "add",
"tag_ids": []string{outdoorID},
}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// The auto-applied "nature" should be on the file too.
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var tagsResp []any
resp.decode(t, &tagsResp)
names := make([]string, 0, len(tagsResp))
for _, tg := range tagsResp {
names = append(names, tg.(map[string]any)["name"].(string))
}
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
}
// TestMediaQueryTokenAuth verifies the ?access_token= fallback: it authenticates
// a GET (so media can be opened via a plain link/new tab) but is rejected for a
// non-GET, and a missing token is still 401.
func TestMediaQueryTokenAuth(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
token := h.login("admin", "admin")
file := h.uploadJPEG(token, "q.jpg")
fileID := file["id"].(string)
// GET with token in the query, no Authorization header → 200.
resp := h.do("GET", "/files/"+fileID+"/content?access_token="+token, nil, "", "")
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// No token anywhere → 401.
resp = h.do("GET", "/files/"+fileID+"/content", nil, "", "")
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
// Query token must NOT authorize a state-changing (non-GET) request → 401.
resp = h.do("DELETE", "/files/"+fileID+"?access_token="+token, nil, "", "")
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
}
// ---------------------------------------------------------------------------
// Security regression tests
// ---------------------------------------------------------------------------
@@ -71,14 +71,24 @@
return color ? `background-color: #${color}` : '';
}
// Refetch which tags are common/partial across the selection. Run after any
// bulk change so rule-applied tags (and partial→common shifts) show up — the
// server applies auto-tag rules, so we can't infer the result locally.
async function refreshCommon() {
const res = await api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>(
'/files/bulk/common-tags',
{ file_ids: fileIds }
);
commonIds = new Set(res.common_tag_ids ?? []);
partialIds = new Set(res.partial_tag_ids ?? []);
}
async function add(tagId: string) {
if (busy) return;
busy = true;
try {
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] });
commonIds = new Set([...commonIds, tagId]);
partialIds.delete(tagId);
partialIds = new Set(partialIds);
await refreshCommon();
} finally {
busy = false;
}
@@ -90,9 +100,7 @@
busy = true;
try {
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] });
commonIds = new Set([...commonIds, tagId]);
partialIds.delete(tagId);
partialIds = new Set(partialIds);
await refreshCommon();
} finally {
busy = false;
}
@@ -103,10 +111,7 @@
busy = true;
try {
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'remove', tag_ids: [tagId] });
commonIds.delete(tagId);
partialIds.delete(tagId);
commonIds = new Set(commonIds);
partialIds = new Set(partialIds);
await refreshCommon();
} finally {
busy = false;
}
@@ -101,6 +101,16 @@
}
}
// Direct link to the full-resolution original, opened in a new tab. A
// navigation can't send the auth header, so the token rides in the query —
// the server accepts ?access_token= for GET media. Reactive on the token so a
// silent refresh keeps the link valid.
let originalUrl = $derived(
fileId
? `/api/v1/files/${fileId}/content?inline=1&access_token=${encodeURIComponent($authStore.accessToken ?? '')}`
: '#'
);
// ---- Tags (lazy) ----
// Fetch the current file's tags the first time the Tags section is visible.
// Re-runs when fileId changes while the section is still on-screen.
@@ -222,7 +232,15 @@
<!-- Preview -->
<div class="preview-wrap">
{#if previewSrc}
<img src={previewSrc} alt={file?.original_name ?? ''} class="preview-img" />
<a
class="preview-link"
href={originalUrl}
target="_blank"
rel="noopener"
title="Open original in a new tab"
>
<img src={previewSrc} alt={file?.original_name ?? ''} class="preview-img" />
</a>
{:else if loading}
<div class="preview-placeholder shimmer"></div>
{:else}
@@ -414,6 +432,17 @@
overflow: hidden;
}
/* Whole preview area is a link: click opens the original in a new tab. */
.preview-link {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-in;
text-decoration: none;
}
.preview-img {
max-width: 100%;
max-height: 100%;
+1 -1
View File
@@ -8,7 +8,7 @@ export interface AppSettings {
const DEFAULTS: AppSettings = {
fileLoadLimit: 100,
tagRuleApplyToExisting: false
tagRuleApplyToExisting: true
};
function load(): AppSettings {
+5 -1
View File
@@ -119,9 +119,13 @@
error = '';
// Deep-link return carrying a position anchor but no loaded grid: load a
// window centred on the anchor instead of page 1, so we can scroll to it
// and grow the grid in both directions.
// and grow the grid in both directions. Otherwise (first mount, or a sort/
// filter change) load page 1 right here — the list isn't remounted on a
// query change, so InfiniteScroll won't re-trigger on its own.
if (firstRun && anchorParam) {
void loadAroundAnchor(anchorParam);
} else {
void loadMore();
}
});
+19
View File
@@ -330,8 +330,27 @@ paths:
get:
tags: [Files]
summary: Download file content
description: >
Returns the original file bytes. Served as an attachment (download) by
default; pass inline=1 to serve it for in-tab viewing
(Content-Disposition: inline). For browser navigation/new-tab opens that
can't send the Authorization header, the access token may be supplied as
the access_token query parameter (GET only).
parameters:
- $ref: '#/components/parameters/file_id'
- name: inline
in: query
required: false
schema:
type: string
enum: ['1']
description: When '1', serve inline (view) instead of as a download.
- name: access_token
in: query
required: false
schema:
type: string
description: Access token, as an alternative to the Authorization header (GET only).
responses:
'200':
description: File binary