Skip to content

Commit 29e83ff

Browse files
committed
Improved public boards and hardened upload validation
1 parent d883a88 commit 29e83ff

File tree

10 files changed

+735
-13
lines changed

10 files changed

+735
-13
lines changed

frontend/src/lib/api/publicBoard.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,14 @@ export const publicBoard = {
1111
}
1212
return res.json();
1313
},
14+
15+
async getItem(slug, key) {
16+
const res = await fetch(`/api/public/board/${encodeURIComponent(slug)}/items/${encodeURIComponent(key)}`);
17+
if (!res.ok) {
18+
const err = new Error(`${res.status}`);
19+
err.status = res.status;
20+
throw err;
21+
}
22+
return res.json();
23+
},
1424
};

frontend/src/lib/pages/PublicBoard.svelte

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
import { onMount, onDestroy } from 'svelte';
33
import { publicBoard } from '../api/publicBoard.js';
44
import { themeStore } from '../stores/theme.svelte.js';
5+
import PublicBoardItemDetail from './PublicBoardItemDetail.svelte';
56
67
let { slug } = $props();
78
9+
let selectedItemKey = $state(null);
10+
811
let board = $state(null);
912
let loading = $state(true);
1013
let error = $state(null);
@@ -127,7 +130,14 @@
127130
<!-- Cards -->
128131
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding: 0 2px 8px;">
129132
{#each column.items as card}
130-
<div class="public-card" style="background-color: var(--ds-surface-raised); border: 1px solid var(--ds-border); border-radius: 6px; padding: 12px; box-shadow: var(--ds-shadow-raised);">
133+
<div
134+
class="public-card"
135+
style="background-color: var(--ds-surface-raised); border: 1px solid var(--ds-border); border-radius: 6px; padding: 12px; box-shadow: var(--ds-shadow-raised); cursor: pointer;"
136+
onclick={() => selectedItemKey = card.key}
137+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectedItemKey = card.key; } }}
138+
role="button"
139+
tabindex="0"
140+
>
131141
<!-- Key -->
132142
{#if showField('key')}
133143
<div style="font-size: 11px; font-weight: 500; color: var(--ds-text-subtle); margin-bottom: 4px; font-family: monospace;">{card.key}</div>
@@ -222,6 +232,10 @@
222232
<span style="font-size: 12px; color: var(--ds-text-subtle);">Powered by Windshift</span>
223233
</div>
224234
</footer>
235+
236+
{#if selectedItemKey}
237+
<PublicBoardItemDetail {slug} itemKey={selectedItemKey} onclose={() => selectedItemKey = null} />
238+
{/if}
225239
</div>
226240
227241
<style>
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
<script>
2+
import { onMount } from 'svelte';
3+
import { publicBoard } from '../api/publicBoard.js';
4+
import { formatRelativeTime, formatDateShort } from '../utils/dateFormatter.js';
5+
import { itemTypeIconMap } from '../utils/icons.js';
6+
import { CheckSquare } from 'lucide-svelte';
7+
import Modal from '../dialogs/Modal.svelte';
8+
import ModalHeader from '../dialogs/ModalHeader.svelte';
9+
import StatusBadge from '../components/StatusBadge.svelte';
10+
import Text from '../components/Text.svelte';
11+
12+
let { slug, itemKey, onclose } = $props();
13+
14+
let item = $state(null);
15+
let loading = $state(true);
16+
let error = $state(null);
17+
let LazyMilkdownEditor = $state(null);
18+
19+
let itemTypeIcon = $derived(
20+
item?.item_type_icon ? (itemTypeIconMap[item.item_type_icon] || CheckSquare) : CheckSquare
21+
);
22+
23+
onMount(async () => {
24+
// Load editor component lazily
25+
try {
26+
const mod = await import('../editors/LazyMilkdownEditor.svelte');
27+
LazyMilkdownEditor = mod.default;
28+
} catch {
29+
// Editor failed to load, we'll show plain text fallback
30+
}
31+
32+
try {
33+
item = await publicBoard.getItem(slug, itemKey);
34+
} catch (err) {
35+
error = err.status === 404 ? 'not_found' : 'error';
36+
} finally {
37+
loading = false;
38+
}
39+
});
40+
41+
function getInitials(name) {
42+
if (!name) return '?';
43+
return name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase();
44+
}
45+
</script>
46+
47+
<Modal isOpen={true} maxWidth="max-w-5xl" {onclose}>
48+
{#if loading}
49+
<div class="py-12 text-center">
50+
<div class="spinner w-7 h-7 border-3 rounded-full mx-auto mb-3" style="border-color: var(--ds-border); border-top-color: #2874BB; animation: spin 0.8s linear infinite;"></div>
51+
<p class="text-sm" style="color: var(--ds-text-subtle);">Loading...</p>
52+
</div>
53+
{:else if error === 'not_found'}
54+
<div class="py-12 text-center">
55+
<p class="text-base font-medium mb-1">Item not found</p>
56+
<p class="text-xs" style="color: var(--ds-text-subtle);">This item doesn't exist or isn't part of this board.</p>
57+
</div>
58+
{:else if error}
59+
<div class="py-12 text-center">
60+
<p class="text-base font-medium mb-1">Failed to load item</p>
61+
<p class="text-xs" style="color: var(--ds-text-subtle);">Something went wrong. Please try again.</p>
62+
</div>
63+
{:else if item}
64+
<ModalHeader title={item.title} subtitle={item.key} onClose={onclose} />
65+
66+
<div class="flex overflow-hidden" style="min-height: 60vh; max-height: 70vh;">
67+
<!-- Left column: Description + Comments -->
68+
<div class="flex-1 min-w-0 overflow-y-auto pt-5 pb-5 px-6">
69+
<!-- Description -->
70+
<div class="mb-6">
71+
<h4 class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--ds-text-subtle);">Description</h4>
72+
{#if item.description}
73+
{#if LazyMilkdownEditor}
74+
<div class="rounded-md p-3" style="border: 1px solid var(--ds-border);">
75+
<LazyMilkdownEditor content={item.description} readonly={true} showToolbar={false} />
76+
</div>
77+
{:else}
78+
<div class="rounded-md p-3 text-sm leading-relaxed whitespace-pre-wrap" style="border: 1px solid var(--ds-border); color: var(--ds-text);">
79+
{item.description}
80+
</div>
81+
{/if}
82+
{:else}
83+
<p class="text-xs italic" style="color: var(--ds-text-disabled);">No description</p>
84+
{/if}
85+
</div>
86+
87+
<!-- Comments -->
88+
<div>
89+
<h4 class="text-xs font-semibold uppercase tracking-wider mb-3" style="color: var(--ds-text-subtle);">
90+
Comments {#if item.comments.length > 0}<span class="font-normal">({item.comments.length})</span>{/if}
91+
</h4>
92+
93+
{#if item.comments.length === 0}
94+
<p class="text-xs italic" style="color: var(--ds-text-disabled);">No comments yet</p>
95+
{:else}
96+
<div class="flex flex-col gap-4">
97+
{#each item.comments as comment}
98+
<div class="flex gap-2.5">
99+
<!-- Avatar -->
100+
{#if comment.author_avatar}
101+
<img src={comment.author_avatar} alt={comment.author_name} class="w-7 h-7 rounded-full object-cover flex-shrink-0" />
102+
{:else}
103+
<div class="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-[10px] font-semibold" style="background: #2874BB; color: white;">
104+
{getInitials(comment.author_name)}
105+
</div>
106+
{/if}
107+
108+
<div class="flex-1 min-w-0">
109+
<!-- Author + time -->
110+
<div class="flex items-baseline gap-2 mb-1">
111+
<span class="text-[13px] font-semibold">{comment.author_name}</span>
112+
<span class="text-[11px]" style="color: var(--ds-text-disabled);">{formatRelativeTime(comment.created_at)}</span>
113+
</div>
114+
<!-- Content -->
115+
{#if LazyMilkdownEditor}
116+
<div class="comment-content">
117+
<LazyMilkdownEditor content={comment.content} readonly={true} showToolbar={false} compact={true} />
118+
</div>
119+
{:else}
120+
<div class="text-[13px] leading-normal whitespace-pre-wrap" style="color: var(--ds-text);">
121+
{comment.content}
122+
</div>
123+
{/if}
124+
</div>
125+
</div>
126+
{/each}
127+
</div>
128+
{/if}
129+
</div>
130+
</div>
131+
132+
<!-- Right column: Properties sidebar -->
133+
<div class="w-72 flex-shrink-0 overflow-y-auto px-4 py-4" style="border-left: 1px solid var(--ds-border); background-color: var(--ds-surface);">
134+
{#if item.status_name}
135+
<div class="mb-3">
136+
<div class="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded">
137+
<Text variant="subtle" size="sm">Status</Text>
138+
<div class="flex items-center gap-2">
139+
<StatusBadge status={{ label: item.status_name, categoryColor: item.status_color }} />
140+
</div>
141+
</div>
142+
</div>
143+
{/if}
144+
145+
{#if item.priority_name}
146+
<div class="mb-3">
147+
<div class="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded">
148+
<Text variant="subtle" size="sm">Priority</Text>
149+
<div class="flex items-center gap-2">
150+
<span class="text-[13px] px-2 py-0.5 rounded" style="background: {item.priority_color || 'var(--ds-surface-sunken)'}20; color: {item.priority_color || 'var(--ds-text-subtle)'}; border: 1px solid {item.priority_color || 'var(--ds-border)'}40;">
151+
{item.priority_name}
152+
</span>
153+
</div>
154+
</div>
155+
</div>
156+
{/if}
157+
158+
{#if item.item_type_name}
159+
<div class="mb-3">
160+
<div class="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded">
161+
<Text variant="subtle" size="sm">Type</Text>
162+
<div class="flex items-center gap-2">
163+
<div
164+
class="w-5 h-5 rounded flex items-center justify-center flex-shrink-0"
165+
style="background-color: {item.item_type_color || 'var(--ds-accent-blue)'};"
166+
>
167+
<svelte:component this={itemTypeIcon} class="w-3 h-3" style="color: white;" />
168+
</div>
169+
<span class="text-[13px]">{item.item_type_name}</span>
170+
</div>
171+
</div>
172+
</div>
173+
{/if}
174+
175+
{#if item.assignee_name}
176+
<div class="mb-3">
177+
<div class="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded">
178+
<Text variant="subtle" size="sm">Assignee</Text>
179+
<div class="flex items-center gap-2">
180+
{#if item.assignee_avatar}
181+
<img src={item.assignee_avatar} alt={item.assignee_name} class="w-5.5 h-5.5 rounded-full object-cover" />
182+
{:else}
183+
<div class="w-5.5 h-5.5 rounded-full flex items-center justify-center text-[10px] font-semibold" style="background: #2874BB; color: white;">
184+
{getInitials(item.assignee_name)}
185+
</div>
186+
{/if}
187+
<span class="text-[13px]">{item.assignee_name}</span>
188+
</div>
189+
</div>
190+
</div>
191+
{/if}
192+
193+
{#if item.due_date}
194+
{@const isOverdue = new Date(item.due_date) < new Date()}
195+
<div class="mb-3">
196+
<div class="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded">
197+
<Text variant="subtle" size="sm">Due Date</Text>
198+
<div class="flex items-center gap-2">
199+
<span class="text-[13px]" class:text-red-500={isOverdue} class:font-medium={isOverdue}>
200+
{formatDateShort(item.due_date)}
201+
</span>
202+
</div>
203+
</div>
204+
</div>
205+
{/if}
206+
207+
{#if item.story_points != null}
208+
<div class="mb-3">
209+
<div class="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded">
210+
<Text variant="subtle" size="sm">Points</Text>
211+
<div class="flex items-center gap-2">
212+
<span class="text-[13px]">{item.story_points} SP</span>
213+
</div>
214+
</div>
215+
</div>
216+
{/if}
217+
218+
{#if item.labels?.length}
219+
<div class="mb-3">
220+
<div class="w-full flex items-start px-2 py-1.5 text-sm rounded">
221+
<Text variant="subtle" size="sm" class="flex-shrink-0 pt-0.5">Labels</Text>
222+
<div class="flex flex-wrap gap-1 justify-end ml-auto">
223+
{#each item.labels as label}
224+
<span class="text-[11px] px-2 py-0.5 rounded" style="background: {label.color}20; color: {label.color}; border: 1px solid {label.color}40;">
225+
{label.name}
226+
</span>
227+
{/each}
228+
</div>
229+
</div>
230+
</div>
231+
{/if}
232+
</div>
233+
</div>
234+
{/if}
235+
</Modal>
236+
237+
<style>
238+
@keyframes spin {
239+
to { transform: rotate(360deg); }
240+
}
241+
242+
.comment-content :global(.milkdown-editor) {
243+
min-height: auto !important;
244+
}
245+
</style>

internal/handlers/asset_import.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ func (h *AssetHandler) UploadCSV(w http.ResponseWriter, r *http.Request) {
107107
}
108108
defer file.Close()
109109

110+
// Validate file extension — only CSV and TSV files are accepted
111+
ext := strings.ToLower(filepath.Ext(header.Filename))
112+
if ext != ".csv" && ext != ".tsv" {
113+
respondValidationError(w, r, "Only CSV and TSV files are accepted")
114+
return
115+
}
116+
110117
hasHeader := r.FormValue("has_header") != "false"
111118
delimiterStr := r.FormValue("delimiter")
112119

internal/handlers/attachment.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,10 @@ func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
132132
isPortalBackground := entityType == "portal_background"
133133
isPortalLogo := entityType == "portal_logo"
134134
isHubLogo := entityType == "hub_logo"
135+
isImageEntityType := isAvatar || isWorkspaceAvatar || isCustomerAvatar || isWorkspaceBackground || isPortalBackground || isPortalLogo || isHubLogo
135136

136137
// Validate entity_id is provided (except for avatars, backgrounds, and logos)
137-
if entityIDStr == "" && !isAvatar && !isWorkspaceAvatar && !isCustomerAvatar && !isWorkspaceBackground && !isPortalBackground && !isPortalLogo && !isHubLogo {
138+
if entityIDStr == "" && !isImageEntityType {
138139
slog.Debug("missing entity_id in form", slog.String("component", "attachments"))
139140
respondValidationError(w, r, "entity_id is required")
140141
return
@@ -239,6 +240,16 @@ func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
239240
}
240241
slog.Debug("content verified", slog.String("component", "attachments"), slog.String("mime_type", detectedMimeType))
241242

243+
// SECURITY: For image-only entity types, restrict to known image extensions
244+
if isImageEntityType {
245+
if !isAllowedImageExtension(fileHeader.Filename) {
246+
respondValidationError(w, r, fmt.Sprintf(
247+
"File extension %s is not allowed for %s uploads. Only image files are accepted",
248+
strings.ToLower(filepath.Ext(fileHeader.Filename)), entityType))
249+
return
250+
}
251+
}
252+
242253
// Get attachment settings for validation
243254
slog.Debug("getting attachment settings", slog.String("component", "attachments"))
244255
settings, err := h.getAttachmentSettings()
@@ -400,7 +411,7 @@ func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
400411

401412
// For avatar type checks below
402413
var attachmentEntityID interface{}
403-
if isAvatar || isWorkspaceAvatar || isCustomerAvatar || isWorkspaceBackground || isPortalBackground || isPortalLogo || isHubLogo {
414+
if isImageEntityType {
404415
attachmentEntityID = nil
405416
} else {
406417
attachmentEntityID = entityID
@@ -432,7 +443,7 @@ func (h *AttachmentHandler) Upload(w http.ResponseWriter, r *http.Request) {
432443
}
433444

434445
// Return success response
435-
if isAvatar || isWorkspaceAvatar || isCustomerAvatar || isWorkspaceBackground || isPortalBackground || isPortalLogo || isHubLogo {
446+
if isImageEntityType {
436447
// For avatars, backgrounds, and logos, return the appropriate download URL
437448
// Portal branding (logo, background, hub_logo) uses public endpoint, others use authenticated endpoint
438449
var downloadURL string
@@ -966,6 +977,16 @@ func (h *AttachmentHandler) validateFileExtension(filename string) error {
966977
return nil
967978
}
968979

980+
// isAllowedImageExtension checks if the file extension is a known image format.
981+
// Used for image-only entity types (avatars, backgrounds, logos) as defense-in-depth.
982+
func isAllowedImageExtension(filename string) bool {
983+
allowed := map[string]bool{
984+
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
985+
".webp": true, ".bmp": true, ".ico": true, ".tiff": true, ".tif": true,
986+
}
987+
return allowed[strings.ToLower(filepath.Ext(filename))]
988+
}
989+
969990
// generateUniqueFilename creates a unique filename while preserving the extension
970991
func (h *AttachmentHandler) generateUniqueFilename(originalFilename string) (string, error) {
971992
ext := filepath.Ext(originalFilename)

0 commit comments

Comments
 (0)