Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.http.InvalidMediaTypeException;


import java.io.IOException;
import java.io.InputStream;
Expand Down Expand Up @@ -65,7 +67,10 @@ public ResponseEntity<StreamingResponseBody> downloadFile(

MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
if (caseFile.getContentType() != null && !caseFile.getContentType().isBlank()) {
mediaType = MediaType.parseMediaType(caseFile.getContentType());
try {
mediaType = MediaType.parseMediaType(caseFile.getContentType());
} catch (InvalidMediaTypeException e) {
}
}

StreamingResponseBody body = outputStream -> {
Expand All @@ -86,6 +91,44 @@ public ResponseEntity<StreamingResponseBody> downloadFile(
.body(body);
}

@GetMapping("/{fileId}/preview")
public ResponseEntity<StreamingResponseBody> previewFile(
@PathVariable Long caseRecordId,
@PathVariable Long fileId,
Principal principal
) {
UserEntity currentUser = userService.getCurrentUser(principal);
CaseFile caseFile = caseFileService.getCaseFileForViewer(caseRecordId, fileId, currentUser);

MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
if (caseFile.getContentType() != null && !caseFile.getContentType().isBlank()) {
try {
mediaType = MediaType.parseMediaType(caseFile.getContentType());
} catch (InvalidMediaTypeException e) {
}
}
Comment thread
MartinStenhagen marked this conversation as resolved.

StreamingResponseBody body = outputStream -> {
try (InputStream stream = caseFileService.downloadFile(caseRecordId, fileId, currentUser)) {
stream.transferTo(outputStream);
}
};

boolean safeInline = isSafeInlinePreview(mediaType);

ContentDisposition disposition = (safeInline
? ContentDisposition.inline()
: ContentDisposition.attachment())
.filename(caseFile.getOriginalFilename(), StandardCharsets.UTF_8)
.build();

return ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(mediaType)
.body(body);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@DeleteMapping("/{fileId}")
public ResponseEntity<Void> deleteFile(
@PathVariable Long caseRecordId,
Expand All @@ -96,4 +139,10 @@ public ResponseEntity<Void> deleteFile(
caseFileService.deleteFile(caseRecordId, fileId, currentUser);
return ResponseEntity.noContent().build();
}

private boolean isSafeInlinePreview(MediaType mediaType) {
return mediaType.isCompatibleWith(MediaType.APPLICATION_PDF)
|| "image".equalsIgnoreCase(mediaType.getType())
|| mediaType.isCompatibleWith(MediaType.TEXT_PLAIN);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ public String caseFiles(@PathVariable Long caseId, Model model, Principal princi
return "fragments/case-management/case-file-list :: caseFileList";
}

@GetMapping("/case-records/{caseId}/files/{fileId}/preview-frame")
public String previewFrame(
@PathVariable Long caseId,
@PathVariable Long fileId,
Model model
) {
model.addAttribute("caseId", caseId);
model.addAttribute("fileId", fileId);
return "fragments/case-management/file-preview-frame :: frame";
}

@PostMapping("/case-records/{caseId}/files")
public String uploadCaseFile(
@PathVariable Long caseId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http,
CustomAuthenticationSuccessHandler successHandler) throws Exception {

return http
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
.csrf(csrf -> csrf.ignoringRequestMatchers("/webauthn/**", "/api/files/**"))
.authorizeHttpRequests(
authorizeHttp -> authorizeHttp
Expand Down
103 changes: 69 additions & 34 deletions src/main/resources/templates/fragments/admin-meetings.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ <h3>Skapa sammanträde</h3>
class="meeting-form"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<div class="form-group">
<label for="registryId">Diarium</label>
<select id="registryId" name="registryId" required>
Expand Down Expand Up @@ -71,15 +71,16 @@ <h3>Befintliga sammanträden</h3>

<ul class="meeting-list" th:if="${not #lists.isEmpty(meetings)}">
<li th:each="meeting : ${meetings}">

<a
th:href="@{/admin/meetings/{meetingId}(meetingId=${meeting.id})}"
th:attr="hx-get=@{/admin/meetings/{meetingId}(meetingId=${meeting.id})}"
hx-target="#content-area"
hx-swap="innerHTML"
class="meeting-list-link"
th:href="@{/admin/meetings/{meetingId}(meetingId=${meeting.id})}"
th:attr="hx-get=@{/admin/meetings/{meetingId}(meetingId=${meeting.id})}"
hx-target="#content-area"
hx-swap="innerHTML"
class="meeting-list-link"
>
<strong th:text="${meeting.title}">Kommunstyrelsen april</strong>
<span th:text="${#temporals.format(meeting.startsAt, 'yyyy-MM-dd HH:mm')}">2026-05-10 13:00</span>
<strong th:text="${meeting.title}">Kommunstyrelsen april</strong>
<span th:text="${#temporals.format(meeting.startsAt, 'yyyy-MM-dd HH:mm')}">2026-05-10 13:00</span>
</a>
</li>
</ul>
Expand Down Expand Up @@ -135,9 +136,9 @@ <h4>Redigera sammanträde</h4>
class="meeting-form"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<div class="form-group">
<label for="edit-title">Titel</label>
<input id="edit-title" name="title" type="text" th:value="${selectedMeeting.title}" required>
Expand Down Expand Up @@ -192,9 +193,9 @@ <h4>Redigera sammanträde</h4>
class="delete-meeting-form"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<button type="submit" class="danger-button">Ta bort sammanträde</button>
</form>

Expand All @@ -209,9 +210,9 @@ <h4>Lägg till ärende</h4>
class="meeting-form"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<div class="form-group">
<label for="caseRecordId">Ärende</label>
<select id="caseRecordId" name="caseRecordId" required>
Expand Down Expand Up @@ -250,9 +251,9 @@ <h4>Dagordning</h4>
hx-swap="innerHTML"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<button type="submit" class="secondary-button">Upp</button>
</form>

Expand All @@ -262,9 +263,9 @@ <h4>Dagordning</h4>
hx-swap="innerHTML"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<button type="submit" class="secondary-button">Ner</button>
</form>

Expand All @@ -274,9 +275,9 @@ <h4>Dagordning</h4>
hx-swap="innerHTML"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<button type="submit" class="danger-button">Ta bort ärende</button>
</form>
</div>
Expand All @@ -294,15 +295,20 @@ <h5>Valda beslutsunderlag</h5>
<div class="agenda-document-row">
<span th:text="${document.caseFile.originalFilename}">tjansteskrivelse.pdf</span>

<button type="button"
th:data-preview-url="@{/dashboard/case-management/case-records/{caseId}/files/{fileId}/preview-frame(caseId=${agendaItem.caseRecord.id}, fileId=${document.caseFile.id})}"
title="Öppna fil"
onclick="togglePreview(this)">👁</button>

<form
th:attr="hx-post=@{/admin/meetings/{meetingId}/agenda-items/{agendaItemId}/documents/{documentId}/remove(meetingId=${selectedMeeting.id}, agendaItemId=${agendaItem.id}, documentId=${document.id})}"
hx-target="#content-area"
hx-swap="innerHTML"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
<button type="submit" class="danger-button">Ta bort</button>
</form>
</div>
Expand All @@ -320,15 +326,20 @@ <h5>Tillgängliga handlingar</h5>
<div class="agenda-document-row">
<span th:text="${caseFile.originalFilename}">budgetunderlag.pdf</span>

<button type="button"
th:data-preview-url="@{/dashboard/case-management/case-records/{caseId}/files/{fileId}/preview-frame(caseId=${agendaItem.caseRecord.id}, fileId=${caseFile.id})}"
title="Öppna fil"
onclick="togglePreview(this)">👁</button>

<form
th:attr="hx-post=@{/admin/meetings/{meetingId}/agenda-items/{agendaItemId}/documents(meetingId=${selectedMeeting.id}, agendaItemId=${agendaItem.id})}"
hx-target="#content-area"
hx-swap="innerHTML"
>
<input type="hidden"
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
th:if="${_csrf != null}"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">

<input type="hidden" name="caseFileId" th:value="${caseFile.id}">
<button type="submit" class="submit">Lägg till</button>
Expand All @@ -343,4 +354,28 @@ <h5>Tillgängliga handlingar</h5>
</section>

</div>

<script>
function togglePreview(btn) {
const item = btn.closest('.agenda-document-row');
const existing = item.querySelector('.file-preview-frame');
if (existing) {
existing.remove();
return;
}
const url = btn.getAttribute('data-preview-url');
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Preview failed');
return res.text();
})
.then(html => {
item.insertAdjacentHTML('beforeend', html);
})
.catch(err => {
console.error('Failed to load preview:', err);
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</script>

</div>
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ <h3>Filer</h3>

<li th:each="file : ${files}" class="case-file-item file-row">
<div class="case-file-info">
<span class="case-file-reference"
th:text="${file.documentReference}">KS26-1-1</span>
<span class="case-file-reference"
th:text="${file.documentReference}">KS26-1-1</span>

<span class="case-file-name"
th:text="${file.displayName}">dokument.pdf</span>
Expand All @@ -56,10 +56,16 @@ <h3>Filer</h3>
Ladda ner
</a>

<button th:if="${file.canDownload}"
type="button"
th:data-preview-url="@{/dashboard/case-management/case-records/{caseId}/files/{fileId}/preview-frame(caseId=${caseRecordId}, fileId=${file.id})}"
title="Öppna fil"
onclick="togglePreview(this)">👁</button>

<span th:unless="${file.canDownload}"
class="case-file-restricted-text">
Ingen behörighet
</span>
Ingen behörighet
</span>

<button type="button"
th:attr="hx-delete=@{/dashboard/case-management/case-records/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
Expand All @@ -71,4 +77,28 @@ <h3>Filer</h3>
</div>
</li>
</ul>

<script>
function togglePreview(btn) {
const item = btn.closest('.case-file-item');
const existing = item.querySelector('.file-preview-frame');
if (existing) {
existing.remove();
return;
}
const url = btn.getAttribute('data-preview-url');
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Preview failed');
return res.text();
})
.then(html => {
item.insertAdjacentHTML('beforeend', html);
})
.catch(err => {
console.error('Failed to load preview:', err);
});
}
Comment on lines +81 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling for the fetch request.

The togglePreview function doesn't handle fetch failures. If the network request fails or returns an error status, users won't receive feedback.

🐛 Proposed fix with error handling
     <script>
         function togglePreview(btn) {
             const item = btn.closest('.case-file-item');
             const existing = item.querySelector('.file-preview-frame');
             if (existing) {
                 existing.remove();
                 return;
             }
             const url = btn.getAttribute('data-preview-url');
             fetch(url)
-                .then(res => res.text())
+                .then(res => {
+                    if (!res.ok) throw new Error('Preview failed');
+                    return res.text();
+                })
                 .then(html => {
                     item.insertAdjacentHTML('beforeend', html);
+                })
+                .catch(err => {
+                    console.error('Failed to load preview:', err);
                 });
         }
     </script>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<script>
function togglePreview(btn) {
const item = btn.closest('.case-file-item');
const existing = item.querySelector('.file-preview-frame');
if (existing) {
existing.remove();
return;
}
const url = btn.getAttribute('data-preview-url');
fetch(url)
.then(res => res.text())
.then(html => {
item.insertAdjacentHTML('beforeend', html);
});
}
<script>
function togglePreview(btn) {
const item = btn.closest('.case-file-item');
const existing = item.querySelector('.file-preview-frame');
if (existing) {
existing.remove();
return;
}
const url = btn.getAttribute('data-preview-url');
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Preview failed');
return res.text();
})
.then(html => {
item.insertAdjacentHTML('beforeend', html);
})
.catch(err => {
console.error('Failed to load preview:', err);
});
}
</script>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/templates/fragments/case-management/case-file-list.html`
around lines 81 - 95, The fetch in function togglePreview lacks error handling;
update togglePreview to check response.ok after fetch(url) and throw or handle
non-2xx statuses, and add a .catch handler for network or parsing errors that
injects a visible error node into the .case-file-item (or uses alert) so users
get feedback; keep the existing logic that removes existing .file-preview-frame,
ensure you reference the data-preview-url attribute and existing
.file-preview-frame element when inserting the error message.

</script>

</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div th:fragment="frame" class="file-preview-frame">
<iframe
th:src="@{/api/cases/{caseId}/files/{fileId}/preview(caseId=${caseId}, fileId=${fileId})}"
width="100%"
height="600px"
style="border: none;">
</iframe>
</div>