Skip to content

Commit 854aea1

Browse files
committed
FIX - logged hours bug and imgs not showing up
1 parent 1cbcae9 commit 854aea1

12 files changed

Lines changed: 297 additions & 77 deletions

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Agent guidance (Stack)
2+
3+
## Security & privacy
4+
5+
### Secrets & env
6+
7+
- Never commit `.env`, API keys, `DATABASE_URL`, session secrets, Hackatime/Airtable/CDN credentials, or OAuth tokens.
8+
- Keep secrets in server `process.env` only; never expose them in client bundles, logs, or JSON responses.
9+
- `.env.example` may list variable **names** only, not real values.
10+
11+
### API exposure
12+
13+
- **Admin / staff routes** (`/api/admin/*`, review approve/reject, user PII, journal CSV) must use `requireFullAdmin`, `requireStaffReview`, or `requireSuperAdmin` — never public.
14+
- **User routes** must use `requireUser` and scope data to `req.session.userId`.
15+
- Prefer whitelisted DTOs (`toPublicProject`, `toPublicUser`) over spreading DB rows to JSON.
16+
- Gate dev/debug endpoints behind production checks or admin auth.
17+
- Third-party calls (Hackatime, Airtable, CDN) stay **server-side**; tokens must not be sent to the browser.
18+
19+
### Errors & logging
20+
21+
- Production 500 responses: generic message only (`clientErrorMessage` in `server/security.js`).
22+
- Do not log access tokens, refresh tokens, sync secrets, or full OAuth responses.
23+
24+
### Client
25+
26+
- Admin `fetch` calls must use `credentials: "include"`.
27+
- Do not embed private API keys in `client/` code.

client/src/components/AdminReviewPage.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@
156156
object-fit: cover;
157157
}
158158

159+
.admin-review-image-fallback {
160+
padding: 0.75rem;
161+
text-align: center;
162+
color: rgba(232, 244, 255, 0.55);
163+
font-size: 0.9rem;
164+
}
165+
159166
.admin-review-card-content {
160167
display: flex;
161168
flex: 1;

client/src/components/AdminReviewPage.jsx

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,27 @@
11
import { useAuth } from "../auth/AuthContext.jsx";
22
import { useEffect, useMemo, useState } from "react";
3+
import { JournalDescription } from "./JournalDescription.jsx";
4+
import { resolveStackAssetUrl } from "../utils/mediaUrls.js";
5+
import "./AdminReviewPage.css";
36

4-
function isVideoUrl(url) {
5-
const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
6-
return ["mp4", "webm", "ogg", "mov"].includes(ext);
7-
}
7+
function ReviewImage({ src, className, alt = "" }) {
8+
const resolved = resolveStackAssetUrl(src);
9+
const [failed, setFailed] = useState(false);
810

9-
function JournalDescriptionRenderer({ text }) {
10-
if (!text) return null;
11-
const parts = [];
12-
const imgRe = /!\[([^\]]*)\]\((https:\/\/cdn\.hackclub\.com\/[^\s)]+)\)/g;
13-
let last = 0;
14-
let match;
15-
while ((match = imgRe.exec(text)) !== null) {
16-
if (match.index > last) parts.push({ type: "text", value: text.slice(last, match.index) });
17-
parts.push({ type: "media", alt: match[1], url: match[2] });
18-
last = match.index + match[0].length;
11+
if (!resolved || failed) {
12+
return <span className="admin-review-image-fallback">{alt || "Image could not be loaded"}</span>;
1913
}
20-
if (last < text.length) parts.push({ type: "text", value: text.slice(last) });
2114

2215
return (
23-
<div className="admin-review-journal-description">
24-
{parts.map((part, i) =>
25-
part.type === "text" ? (
26-
<span key={i} style={{ whiteSpace: "pre-wrap" }}>{part.value}</span>
27-
) : isVideoUrl(part.url) ? (
28-
<video key={i} className="admin-review-media-item" src={part.url} controls preload="metadata" />
29-
) : (
30-
<img key={i} className="admin-review-media-item" src={part.url} alt={part.alt} />
31-
)
32-
)}
33-
</div>
16+
<img
17+
className={className}
18+
src={resolved}
19+
alt={alt}
20+
loading="lazy"
21+
onError={() => setFailed(true)}
22+
/>
3423
);
3524
}
36-
import "./AdminReviewPage.css";
3725

3826
function formatHours(value) {
3927
return Number(value ?? 0).toFixed(2);
@@ -179,7 +167,11 @@ function AdminReviewIndex() {
179167
{filteredProjects.map((project) => (
180168
<a className="admin-review-card" href={`/admin/review/project/${project.id}`} key={project.id}>
181169
<div className="admin-review-thumb">
182-
{project.imageUrl ? <img src={project.imageUrl} alt="" /> : <span>Image could not be loaded</span>}
170+
{project.imageUrl ? (
171+
<ReviewImage src={project.imageUrl} className="" alt="" />
172+
) : (
173+
<span className="admin-review-image-fallback">Image could not be loaded</span>
174+
)}
183175
</div>
184176
<div className="admin-review-card-content">
185177
<div className="admin-review-card-header">
@@ -199,7 +191,7 @@ function AdminReviewIndex() {
199191
{slackDisplay(project.user)}
200192
</div>
201193
<div className="admin-review-stats">
202-
<strong>{formatHours(project.totalHours)}h</strong>
194+
<strong>{formatHours(project.combinedHours ?? 0)}h</strong>
203195
<small>{formatDate(project.shippedAt)}</small>
204196
</div>
205197
</footer>
@@ -380,7 +372,7 @@ function AdminReviewDetail({ projectId }) {
380372
</header>
381373

382374
{project.imageUrl ? (
383-
<img className="admin-review-banner" src={project.imageUrl} alt="" />
375+
<ReviewImage src={project.imageUrl} className="admin-review-banner" alt="" />
384376
) : (
385377
<div className="admin-review-banner admin-review-banner--empty">Screenshot unavailable</div>
386378
)}
@@ -438,7 +430,7 @@ function AdminReviewDetail({ projectId }) {
438430
</div>
439431
<div>
440432
<span>Raw Hours</span>
441-
<strong>{formatHours((Number(project.totalHours || 0) + Number(project.hackatimeHours || 0)).toFixed(2))}</strong>
433+
<strong>{formatHours(project.combinedHours ?? 0)}</strong>
442434
</div>
443435
<div>
444436
<span>Previously banked</span>
@@ -486,7 +478,11 @@ function AdminReviewDetail({ projectId }) {
486478
journalEntries.map((entry) => (
487479
<article className="admin-review-journal-entry" key={entry.id}>
488480
<strong>{entry.timeDone ? new Date(entry.timeDone).toLocaleString() : "N/A"} · {entry.hoursWorked}h</strong>
489-
<JournalDescriptionRenderer text={entry.description} />
481+
<JournalDescription
482+
text={entry.description}
483+
className="admin-review-journal-description"
484+
mediaClassName="admin-review-media-item"
485+
/>
490486
{entry.toolsUsed?.length ? <small>Tools: {entry.toolsUsed.join(", ")}</small> : null}
491487
</article>
492488
))

client/src/components/AdminShopOrdersPage.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function AdminShopOrdersPage() {
4040
useEffect(() => {
4141
async function loadOrders() {
4242
try {
43-
const response = await fetch("/api/admin/shop/orders");
43+
const response = await fetch("/api/admin/shop/orders", { credentials: "include" });
4444
const data = await response.json();
4545
if (!response.ok) throw new Error(data.error || "Failed to load orders.");
4646
setOrders(data.orders || []);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { isVideoUrl, parseJournalDescription } from "../utils/mediaUrls.js";
2+
3+
export function JournalDescription({ text, className = "journal-description", mediaClassName = "journal-media-item" }) {
4+
const parts = parseJournalDescription(text);
5+
if (parts.length === 0) return null;
6+
7+
return (
8+
<div className={className}>
9+
{parts.map((part, i) =>
10+
part.type === "text" ? (
11+
<span key={i} style={{ whiteSpace: "pre-wrap" }}>
12+
{part.value}
13+
</span>
14+
) : isVideoUrl(part.url) ? (
15+
<video key={i} className={mediaClassName} src={part.url} controls preload="metadata" />
16+
) : (
17+
<img key={i} className={mediaClassName} src={part.url} alt={part.alt || "Journal attachment"} loading="lazy" />
18+
)
19+
)}
20+
</div>
21+
);
22+
}

client/src/components/ProjectsPage.jsx

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
const platformBackground = "https://cdn.hackclub.com/019e3e5a-908f-707d-9790-91f9ec414045/bkg.png";
22
import { useEffect, useRef, useState } from "react";
33
import { useAuth } from "../auth/AuthContext.jsx";
4+
import { JournalDescription } from "./JournalDescription.jsx";
45
import { PlatformStatusBar } from "./PlatformStatusBar.jsx";
6+
import { resolveStackAssetUrl } from "../utils/mediaUrls.js";
57
const sideBrick = "https://cdn.hackclub.com/019e3e5a-9d8a-7fcb-ad80-d6166cfd97f8/side_brick.png";
68
const statusBtn = "https://cdn.hackclub.com/019e3e5a-9f39-7875-aec6-cf24a58b87d4/status_btn.png";
79
const hoursBtn = "https://cdn.hackclub.com/019e3e5a-9b72-709a-8224-a87f14fd5e78/hours_btn.png";
@@ -112,39 +114,6 @@ const ALLOWED_UPLOAD_TYPES = new Set([
112114
const PROJECT_IMAGE_UPLOAD_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif"]);
113115
const PROJECT_IMAGE_MAX_BYTES = 3 * 1024 * 1024;
114116

115-
function isVideoUrl(url) {
116-
const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
117-
return ["mp4", "webm", "ogg", "mov"].includes(ext);
118-
}
119-
120-
function JournalDescriptionRenderer({ text }) {
121-
if (!text) return null;
122-
const parts = [];
123-
const imgRe = /!\[([^\]]*)\]\((https:\/\/cdn\.hackclub\.com\/[^\s)]+)\)/g;
124-
let last = 0;
125-
let match;
126-
while ((match = imgRe.exec(text)) !== null) {
127-
if (match.index > last) parts.push({ type: "text", value: text.slice(last, match.index) });
128-
parts.push({ type: "media", alt: match[1], url: match[2] });
129-
last = match.index + match[0].length;
130-
}
131-
if (last < text.length) parts.push({ type: "text", value: text.slice(last) });
132-
133-
return (
134-
<div className="journal-description">
135-
{parts.map((part, i) =>
136-
part.type === "text" ? (
137-
<span key={i} style={{ whiteSpace: "pre-wrap" }}>{part.value}</span>
138-
) : isVideoUrl(part.url) ? (
139-
<video key={i} className="journal-media-item" src={part.url} controls preload="metadata" />
140-
) : (
141-
<img key={i} className="journal-media-item" src={part.url} alt={part.alt} />
142-
)
143-
)}
144-
</div>
145-
);
146-
}
147-
148117
function displayStatus(project) {
149118
if (project.status === "approved") return "Approved";
150119
if (project.status === "rejected") return "Rejected";
@@ -593,7 +562,13 @@ function ProjectDetailsModal({ project, onClose, onEdit, onJournal, onShip, onDe
593562

594563
<h2 className="projects-page__modal-title">{project.name}</h2>
595564
<p className="projects-page__modal-description">{project.description || "No description yet."}</p>
596-
{project.imageUrl ? <img className="projects-page__project-image" src={project.imageUrl} alt="" /> : null}
565+
{project.imageUrl ? (
566+
<img
567+
className="projects-page__project-image"
568+
src={resolveStackAssetUrl(project.imageUrl) || project.imageUrl}
569+
alt=""
570+
/>
571+
) : null}
597572

598573
<div className="projects-page__modal-row">
599574
<span>Type</span>
@@ -793,7 +768,7 @@ function JournalModal({ project, entries, form, onChange, onClose, onSubmit }) {
793768
<strong>
794769
{entry.timeDone ? new Date(entry.timeDone).toLocaleDateString() : "N/A"} - {entry.hoursWorked || 0} hrs
795770
</strong>
796-
<JournalDescriptionRenderer text={entry.description} />
771+
<JournalDescription text={entry.description} />
797772
{entry.toolsUsed?.length ? <small>Tools: {entry.toolsUsed.join(", ")}</small> : null}
798773
</article>
799774
))
@@ -943,7 +918,11 @@ function ProjectFormModal({
943918
}}
944919
/>
945920
{project.imageUrl ? (
946-
<img className="projects-page__image-preview" src={project.imageUrl} alt="" />
921+
<img
922+
className="projects-page__image-preview"
923+
src={resolveStackAssetUrl(project.imageUrl) || project.imageUrl}
924+
alt=""
925+
/>
947926
) : (
948927
<p>Drag &amp; drop an image here</p>
949928
)}

client/src/utils/mediaUrls.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Resolve project/journal asset URLs for the current app origin.
3+
* Rewrites dev upload hosts (127.0.0.1:3000) and relative /uploads paths
4+
* so images work on the Vite dev server and in production.
5+
*/
6+
export function resolveStackAssetUrl(url) {
7+
if (!url || typeof url !== "string") return null;
8+
const trimmed = url.trim();
9+
if (!trimmed) return null;
10+
11+
try {
12+
const parsed = new URL(trimmed, window.location.origin);
13+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
14+
return null;
15+
}
16+
17+
if (parsed.pathname.startsWith("/uploads/")) {
18+
return `${window.location.origin}${parsed.pathname}${parsed.search}`;
19+
}
20+
21+
const isLocalUploadHost =
22+
(parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") &&
23+
parsed.pathname.startsWith("/uploads/");
24+
if (isLocalUploadHost) {
25+
return `${window.location.origin}${parsed.pathname}${parsed.search}`;
26+
}
27+
28+
return parsed.href;
29+
} catch {
30+
return trimmed.startsWith("/uploads/") ? `${window.location.origin}${trimmed}` : trimmed;
31+
}
32+
}
33+
34+
/** @param {string} url */
35+
export function isAllowedJournalMediaUrl(url) {
36+
if (!url || typeof url !== "string") return false;
37+
try {
38+
const parsed = new URL(url.trim(), window.location.origin);
39+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
40+
if (parsed.hostname === "cdn.hackclub.com") return true;
41+
if (parsed.pathname.startsWith("/uploads/")) return true;
42+
if (parsed.hostname === window.location.hostname) return true;
43+
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
44+
} catch {
45+
return false;
46+
}
47+
}
48+
49+
const MARKDOWN_MEDIA_RE = /!\[([^\]]*)\]\(([^)]+)\)/g;
50+
51+
/**
52+
* @param {string | null | undefined} text
53+
* @returns {Array<{ type: "text", value: string } | { type: "media", alt: string, url: string }>}
54+
*/
55+
export function parseJournalDescription(text) {
56+
if (!text) return [];
57+
58+
const parts = [];
59+
let last = 0;
60+
let match;
61+
62+
while ((match = MARKDOWN_MEDIA_RE.exec(text)) !== null) {
63+
if (match.index > last) {
64+
parts.push({ type: "text", value: text.slice(last, match.index) });
65+
}
66+
67+
const rawUrl = match[2].trim();
68+
if (isAllowedJournalMediaUrl(rawUrl)) {
69+
const resolved = resolveStackAssetUrl(rawUrl);
70+
if (resolved) {
71+
parts.push({ type: "media", alt: match[1], url: resolved });
72+
} else {
73+
parts.push({ type: "text", value: match[0] });
74+
}
75+
} else {
76+
parts.push({ type: "text", value: match[0] });
77+
}
78+
79+
last = match.index + match[0].length;
80+
}
81+
82+
if (last < text.length) {
83+
parts.push({ type: "text", value: text.slice(last) });
84+
}
85+
86+
return parts;
87+
}
88+
89+
export function isVideoUrl(url) {
90+
const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
91+
return ["mp4", "webm", "ogg", "mov"].includes(ext);
92+
}

client/vite.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export default defineConfig({
2222
target: "http://127.0.0.1:3000",
2323
changeOrigin: true,
2424
},
25+
"/uploads": {
26+
target: "http://127.0.0.1:3000",
27+
changeOrigin: true,
28+
},
2529
},
2630
},
2731
});

server/airtablePeriodic.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { pool } from "./db.js";
2+
import { STACK_LAUNCH_UTC } from "./hackatimeAuth.js";
3+
4+
const STACK_LAUNCH_TIMESTAMPTZ = STACK_LAUNCH_UTC.toISOString();
25
import { hasAirtableProjectsConfig, syncProjectToAirtable, persistProjectAirtableRecordId } from "./airtableProjects.js";
36
import { hasAirtableUsersConfig, syncPostgresUserToAirtable } from "./airtableUsers.js";
47

@@ -87,6 +90,7 @@ export async function syncAllUsersAndProjectsToAirtable() {
8790
LEFT JOIN journal_entries
8891
ON journal_entries.project_id = projects.id
8992
AND journal_entries.user_id = projects.user_id
93+
AND COALESCE(journal_entries.time_done, journal_entries.created_at) >= '${STACK_LAUNCH_TIMESTAMPTZ}'::timestamptz
9094
GROUP BY projects.id, users.id
9195
ORDER BY projects.id ASC
9296
`);

0 commit comments

Comments
 (0)