Bug fixes for video serving and updats to documentation for mobile use screenshots
@ -1,134 +0,0 @@
|
|||||||
# Red-Team Findings Tracker — 2026-04-12
|
|
||||||
|
|
||||||
Source: Red-team audit (auth, IDOR, public exposure, injection).
|
|
||||||
Coordinator: Claude (main). Status legend: ⬜ pending · 🟡 in progress · ✅ done · ❎ wontfix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0 — Zero-Auth Data Exposure (public endpoints leaking PII)
|
|
||||||
|
|
||||||
| # | Finding | File(s) | Status |
|
|
||||||
|---|---------|---------|--------|
|
|
||||||
| P0-1 | **Public map → server-side heatmap with ~1km bucketing** | `api/.../locations.service.ts` · `.../locations.routes.ts` · `admin/.../public/MapPage.tsx` · `admin/src/types/leaflet-heat.d.ts` (new) | ✅ |
|
|
||||||
| P0-2 | Petition signers endpoint — stripped displayName/comment/geo | `petitions.service.ts:545` | ✅ |
|
|
||||||
| P0-3 | Petition public-stats — removed `recentSigners` | `petitions.service.ts:618` | ✅ |
|
|
||||||
| P0-4 | Campaign response wall — removed `submittedByName` + `userComment` | `responses.service.ts:121` | ✅ |
|
|
||||||
| P0-5 | `createdByUserEmail` gated to SUPER_ADMIN only | `campaigns.service.ts` | ✅ |
|
|
||||||
|
|
||||||
## P1 — IDOR / Broken Access Control
|
|
||||||
|
|
||||||
| # | Finding | File(s) | Status |
|
|
||||||
|---|---------|---------|--------|
|
|
||||||
| P1-1..3 | Campaign findById/update/delete ownership checks | `campaigns.{service,routes}.ts` | ✅ |
|
|
||||||
| P1-4,5 | `/campaigns/:id/emails` + `/email-stats` ownership checks | `campaign-emails.routes.ts` | ✅ |
|
|
||||||
| P1-6 | GPS tracking route — SUPER_ADMIN or session owner only | `tracking.routes.ts` | ✅ |
|
|
||||||
| P1-7 | Canvass volunteer stats — SUPER_ADMIN or self only | `canvass.routes.ts` | ✅ |
|
|
||||||
| P1-8 | People household endpoints restricted to INFLUENCE_ROLES ∪ MAP_ROLES | `people.routes.ts` | ✅ |
|
|
||||||
| P1-9 | CCP upgrade branch/path validation (SAFE_BRANCH, SAFE_PATH) | `upgrade.service.ts` | ✅ |
|
|
||||||
|
|
||||||
## P2 — Token Security
|
|
||||||
|
|
||||||
| # | Finding | File(s) | Status |
|
|
||||||
|---|---------|---------|--------|
|
|
||||||
| P2-1 | Query-param JWT tokens → HMAC-signed short-lived URLs | `utils/signed-url.ts` (new), `media/middleware/auth.ts`, `video-streaming.routes.ts`, `photos.routes.ts`, `chat-notifications.routes.ts`, `social.routes.ts`, `media/routes/sign.routes.ts` (new), `useChatNotifications.ts`, `useSSE.ts` | ✅ |
|
|
||||||
| P2-2 | `GITEA_SSO_SECRET` & `SERVICE_PASSWORD_SALT` — fallback removed | `env.ts`, provisioners, routes, `.env.example` | ✅ **BREAKING** |
|
|
||||||
| P2-3..5 | Refresh-token device fingerprint + 24h expiry + per-email rate limits + body-fallback removed | `auth.service.ts`, `auth.routes.ts`, `auth.rate-limits.ts`, `utils/device-fingerprint.ts` (new), `env.ts` | ✅ |
|
|
||||||
|
|
||||||
## P3 — Hardening / Defense-in-depth
|
|
||||||
|
|
||||||
| # | Finding | File(s) | Status |
|
|
||||||
|---|---------|---------|--------|
|
|
||||||
| P3-1 | Landing page HTML/CSS — DOMPurify sanitization | `LandingPage.tsx`, `utils/sanitize.ts` | ✅ |
|
|
||||||
| P3-2 | CCP certificate slug + hostname validation | `certificate.service.ts` | ✅ |
|
|
||||||
| P3-3 | `/api/health?detailed=true` disk-space leak — mode removed | `server.ts:218` | ✅ |
|
|
||||||
| P3-4 | Search endpoint tighter rate limits | `search.service.ts` | ⬜ (deferred — low risk) |
|
|
||||||
| P3-5 | Activity feed exposure review | `activity-public.service.ts` | ⬜ (deferred — cached, low risk) |
|
|
||||||
| P3-6 | Moderation notes gated to SUPER_ADMIN | `campaigns.service.ts` | ✅ (folded into P0-5) |
|
|
||||||
| P3-7 | userId removed from reset/verify token logs | `*-token.service.ts` | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Breaking Changes Operators Must Handle
|
|
||||||
|
|
||||||
**P2-2** (required restart-blocker):
|
|
||||||
- `GITEA_SSO_SECRET` and `SERVICE_PASSWORD_SALT` are now **required** (≥32 chars).
|
|
||||||
- Previously, an empty value silently fell back to `JWT_ACCESS_SECRET` — a JWT leak compromised SSO cookies + service passwords.
|
|
||||||
- Operators must run `openssl rand -hex 32` twice and set both in `.env` before next restart. Startup fails with a clear Zod error otherwise.
|
|
||||||
- `config.sh` already generates distinct values, so fresh installs are fine; upgrades need manual action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deferred (none remaining)
|
|
||||||
|
|
||||||
All audit findings have been landed. Remaining `⬜` rows in the P3 table are
|
|
||||||
low-risk polish items (search rate-limit tweak, activity-feed exposure review)
|
|
||||||
tracked for normal maintenance — not shipping blockers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification — End-to-End Tested 2026-04-12
|
|
||||||
|
|
||||||
### Static checks
|
|
||||||
- `api` TypeScript: clean (pre-existing `shifts.service.ts` errors unrelated)
|
|
||||||
- `admin` TypeScript: clean
|
|
||||||
- `changemaker-control-panel/api` TypeScript: clean aside from pre-existing `health.service.ts`
|
|
||||||
|
|
||||||
### Runtime verification (curl against running containers)
|
|
||||||
|
|
||||||
| Finding | Test | Result |
|
|
||||||
|---------|------|--------|
|
|
||||||
| P0-1 | `GET /api/map/locations/public` — shape is `{points:[{lat,lng,count}]}`, 2-decimal bucketing, no address/supportLevel/signSize | ✅ 17 points, banned fields absent |
|
|
||||||
| P0-2 | `GET /api/petitions/:slug/signers` — sample keys only `id/isAnonymous/createdAt` | ✅ no `displayName/signerComment/geoCity/geoCountry` |
|
|
||||||
| P0-3 | `GET /api/petitions/:slug/public-stats` — no `recentSigners` | ✅ keys: total/verified/goal/percentComplete/byCountry/byRegion |
|
|
||||||
| P0-4 | `GET /api/campaigns/:slug/responses` — no submitter PII | ✅ shape clean |
|
|
||||||
| P0-5 | SUPER_ADMIN vs INFLUENCE_ADMIN field-level RBAC on `/campaigns/:id` | ✅ super sees email+modNotes; influence gets 404 via IDOR |
|
|
||||||
| P1-1..5 | Non-owner IDOR on campaign findById/update/delete/emails/email-stats | ✅ all return 404 |
|
|
||||||
| P1-6 | MAP_ADMIN accessing another user's tracking session route | ✅ 404; SUPER_ADMIN passes 200 |
|
|
||||||
| P1-7 | Canvass volunteer stats cross-user access | ✅ 404 for non-owner non-SUPER |
|
|
||||||
| P1-8 | MEDIA_ADMIN blocked from `/api/people/household/:id` | ✅ 403; INFLUENCE_ADMIN passes 200 |
|
|
||||||
| P1-9 / P3-2 | SAFE_BRANCH + SAFE_SLUG reject injection payloads (`;`, `$()`, backticks, `/O=Evil`) | ✅ all blocked |
|
|
||||||
| P2-1 | `POST /api/media/sign` returns signed URL; signed URL authenticates; legacy `?token=JWT` rejected; tampered/expired/cross-path all 401 | ✅ all cases |
|
|
||||||
| P2-2 | Startup fails cleanly when `GITEA_SSO_SECRET`/`SERVICE_PASSWORD_SALT` empty | ✅ confirmed; operator must set both |
|
|
||||||
| P2-3 | Refresh JWT contains `df` claim, 24h expiry | ✅ |
|
|
||||||
| P2-3 | Fingerprint mismatch on refresh revokes all sessions | ✅ 401 FINGERPRINT_MISMATCH |
|
|
||||||
| P2-4 | Refresh token via request body rejected | ✅ 401 (cookie only) |
|
|
||||||
| P2-5 | 4th password-reset request for same (IP, email) → 429; different email same IP → 200 | ✅ composite key works |
|
|
||||||
| P3-1 | LandingPage uses `sanitizeLandingHtml` + `sanitizeLandingCss` | ✅ wired |
|
|
||||||
| P3-3 | `GET /api/health?detailed=true` no longer leaks `diskFreeGB` or `mkdocs` status | ✅ |
|
|
||||||
| P3-7 | "Password reset token created" log line contains no userId cuid | ✅ |
|
|
||||||
|
|
||||||
### Operational changes required to .env (now applied)
|
|
||||||
- `GITEA_SSO_SECRET` — generated via `openssl rand -hex 32`
|
|
||||||
- `SERVICE_PASSWORD_SALT` — generated via `openssl rand -hex 32`
|
|
||||||
- `JWT_REFRESH_EXPIRY` reduced 7d → 24h
|
|
||||||
|
|
||||||
### docker-compose.yml changes
|
|
||||||
- media-api now receives `GITEA_SSO_SECRET`, `SERVICE_PASSWORD_SALT`, `JWT_ACCESS_EXPIRY`, `JWT_REFRESH_EXPIRY` (it shares the api's env schema)
|
|
||||||
- api+media-api `JWT_REFRESH_EXPIRY` default 7d → 24h
|
|
||||||
- api `GITEA_SSO_SECRET` / `SERVICE_PASSWORD_SALT` no longer have `:-` empty-default fallback (required)
|
|
||||||
|
|
||||||
### Post-test runtime additions
|
|
||||||
- `npm install leaflet.heat@^0.2.0` in the admin container (package.json already updated — install just needed to be materialized)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment System Audit (post-hoc, 2026-04-12)
|
|
||||||
|
|
||||||
Confirmed ripple effects across every deployment path:
|
|
||||||
|
|
||||||
| System | Status | Action |
|
|
||||||
|--------|--------|--------|
|
|
||||||
| `config.sh` | ✅ already generates `GITEA_SSO_SECRET` + `SERVICE_PASSWORD_SALT` via `update_env_var_if_empty` (lines 508-519) — fresh installs and re-runs on old installs will fill empty values | No change needed |
|
|
||||||
| `docker-compose.yml` (dev/source installs) | ✅ updated earlier during test plan | Done |
|
|
||||||
| `docker-compose.prod.yml` (release installs) | ✅ **fixed now**: removed `:-` empty fallback for both secrets, default refresh expiry 7d→24h, added both secrets + expiry to media-api service | Done |
|
|
||||||
| `changemaker-control-panel/templates/env.hbs` (CCP-provisioned instances) | ✅ **fixed now**: added `GITEA_SSO_SECRET` + `SERVICE_PASSWORD_SALT` template fields, refresh expiry 7d→24h | Done |
|
|
||||||
| `changemaker-control-panel/api/src/services/secret-generator.ts` | ✅ **fixed now**: `InstanceSecrets` interface + `generateSecrets()` now produce `giteaSsoSecret` + `servicePasswordSalt` | Done |
|
|
||||||
| CCP's own app (separate from Changemaker instances) | ✅ not affected — has its own env schema | No change needed |
|
|
||||||
| `scripts/validate-env.sh`, `scripts/install.sh`, `scripts/upgrade.sh`, `scripts/build-release.sh` | ✅ none reference the changed env vars directly | No change needed |
|
|
||||||
|
|
||||||
### Operator migration paths
|
|
||||||
|
|
||||||
- **Fresh source install:** `config.sh` generates everything correctly. ✅
|
|
||||||
- **Fresh release install:** `install.sh` → `config.sh` generates everything. ✅
|
|
||||||
- **Existing install upgrading:** re-run `./config.sh` to fill empty values, OR manually add both secrets to `.env` via `openssl rand -hex 32`. The Zod validator will emit a clear startup error if either is missing.
|
|
||||||
- **Existing CCP-managed instance:** run `config.sh` on the managed instance's host (same as existing install path). The CCP template regen only applies to *newly provisioned* instances.
|
|
||||||
@ -1,17 +1,8 @@
|
|||||||
import { Card, Tag, Badge } from 'antd';
|
import { Card, Tag, Badge } from 'antd';
|
||||||
import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
import type { PhotoAlbum } from '@/types/media';
|
import type { PhotoAlbum } from '@/types/media';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlbumCardProps {
|
interface AlbumCardProps {
|
||||||
album: PhotoAlbum;
|
album: PhotoAlbum;
|
||||||
onClick?: (album: PhotoAlbum) => void;
|
onClick?: (album: PhotoAlbum) => void;
|
||||||
@ -19,6 +10,7 @@ interface AlbumCardProps {
|
|||||||
|
|
||||||
export default function AlbumCard({ album, onClick }: AlbumCardProps) {
|
export default function AlbumCard({ album, onClick }: AlbumCardProps) {
|
||||||
const coverUrl = album.coverThumbnailUrl;
|
const coverUrl = album.coverThumbnailUrl;
|
||||||
|
const signedCoverUrl = useSignedMediaUrl(coverUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -35,9 +27,9 @@ export default function AlbumCard({ album, onClick }: AlbumCardProps) {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{coverUrl ? (
|
{coverUrl && signedCoverUrl ? (
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(coverUrl)}
|
src={signedCoverUrl}
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -7,16 +7,26 @@ import {
|
|||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
|
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img> src URLs */
|
function PhotoThumbnail({ url, alt }: { url: string; alt: string }) {
|
||||||
function getAuthenticatedUrl(url: string): string {
|
const signed = useSignedMediaUrl(url);
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
if (!signed) {
|
||||||
const accessToken = getAccessToken();
|
return (
|
||||||
if (!accessToken) return url;
|
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4 }} aria-label={alt} />
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
);
|
||||||
return `${url}${separator}token=${accessToken}`;
|
}
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={signed}
|
||||||
|
width={60}
|
||||||
|
height={45}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
preview={false}
|
||||||
|
alt={alt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlbumDetailDrawerProps {
|
interface AlbumDetailDrawerProps {
|
||||||
@ -200,13 +210,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }:
|
|||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
photo.thumbnailUrl ? (
|
photo.thumbnailUrl ? (
|
||||||
<Image
|
<PhotoThumbnail url={photo.thumbnailUrl} alt={photo.title || photo.originalFilename || ''} />
|
||||||
src={getAuthenticatedUrl(photo.thumbnailUrl)}
|
|
||||||
width={60}
|
|
||||||
height={45}
|
|
||||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
|
||||||
preview={false}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<PictureOutlined style={{ color: '#555' }} />
|
<PictureOutlined style={{ color: '#555' }} />
|
||||||
|
|||||||
@ -8,18 +8,9 @@ import {
|
|||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
import type { Photo } from '@/types/media';
|
import type { Photo } from '@/types/media';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhotoCardProps {
|
interface PhotoCardProps {
|
||||||
photo: Photo;
|
photo: Photo;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@ -50,6 +41,7 @@ export default function PhotoCard({
|
|||||||
onTogglePublish,
|
onTogglePublish,
|
||||||
}: PhotoCardProps) {
|
}: PhotoCardProps) {
|
||||||
const thumbnailUrl = photo.thumbnailUrl;
|
const thumbnailUrl = photo.thumbnailUrl;
|
||||||
|
const signedThumbnailUrl = useSignedMediaUrl(thumbnailUrl);
|
||||||
|
|
||||||
const hoverActions = (
|
const hoverActions = (
|
||||||
<div
|
<div
|
||||||
@ -112,9 +104,9 @@ export default function PhotoCard({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{thumbnailUrl ? (
|
{thumbnailUrl && signedThumbnailUrl ? (
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(thumbnailUrl)}
|
src={signedThumbnailUrl}
|
||||||
alt={photo.title || photo.filename}
|
alt={photo.title || photo.filename}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -1,17 +1,8 @@
|
|||||||
import { Modal, Descriptions, Tag, Grid } from 'antd';
|
import { Modal, Descriptions, Tag, Grid } from 'antd';
|
||||||
import { CameraOutlined } from '@ant-design/icons';
|
import { CameraOutlined } from '@ant-design/icons';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
import type { Photo } from '@/types/media';
|
import type { Photo } from '@/types/media';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhotoViewerModalProps {
|
interface PhotoViewerModalProps {
|
||||||
photo: Photo | null;
|
photo: Photo | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -22,9 +13,10 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
|
|||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
if (!photo) return null;
|
const adminImageUrl = photo ? `/media/photos/${photo.id}/image?size=large` : null;
|
||||||
|
const signedImageUrl = useSignedMediaUrl(adminImageUrl);
|
||||||
|
|
||||||
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
|
if (!photo) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -48,7 +40,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(adminImageUrl)}
|
src={signedImageUrl}
|
||||||
alt={photo.title || photo.filename}
|
alt={photo.title || photo.filename}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@ -2,19 +2,10 @@ import { Card, Checkbox, Tag, Spin } from 'antd';
|
|||||||
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
import VideoActions from './VideoActions';
|
import VideoActions from './VideoActions';
|
||||||
import ScheduleBadge from './ScheduleBadge';
|
import ScheduleBadge from './ScheduleBadge';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img>/<video> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
video: Video;
|
video: Video;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@ -48,6 +39,7 @@ export default function VideoCard({
|
|||||||
}: VideoCardProps) {
|
}: VideoCardProps) {
|
||||||
const [thumbnailLoading, setThumbnailLoading] = useState(true);
|
const [thumbnailLoading, setThumbnailLoading] = useState(true);
|
||||||
const [thumbnailError, setThumbnailError] = useState(false);
|
const [thumbnailError, setThumbnailError] = useState(false);
|
||||||
|
const signedThumbnailUrl = useSignedMediaUrl(video.thumbnailUrl);
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@ -76,10 +68,10 @@ export default function VideoCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Thumbnail image or fallback */}
|
{/* Thumbnail image or fallback */}
|
||||||
{video.thumbnailUrl && !thumbnailError ? (
|
{video.thumbnailUrl && !thumbnailError && signedThumbnailUrl ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(video.thumbnailUrl)}
|
src={signedThumbnailUrl}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 're
|
|||||||
import { Alert, Spin } from 'antd';
|
import { Alert, Spin } from 'antd';
|
||||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { getAuthCallbacks } from '@/lib/api';
|
||||||
|
import { signedMediaUrl } from '@/lib/media-url';
|
||||||
|
|
||||||
export interface VideoMetadata {
|
export interface VideoMetadata {
|
||||||
id: number;
|
id: number;
|
||||||
@ -122,15 +123,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
fetchMetadata();
|
fetchMetadata();
|
||||||
}, [videoId]);
|
}, [videoId]);
|
||||||
|
|
||||||
const appendToken = (url: string): string => {
|
|
||||||
if (!isAdmin) return url;
|
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${sep}token=${accessToken}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMetadata = async () => {
|
const fetchMetadata = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -157,10 +149,11 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
|
// For admin previews of unpublished media, sign stream/thumbnail URLs
|
||||||
|
// (the legacy ?token=<JWT> path was removed 2026-04-12).
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
|
if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl);
|
||||||
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
|
if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
|||||||
@ -2,16 +2,7 @@ import { Modal } from 'antd';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <video> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoViewerModalProps {
|
interface VideoViewerModalProps {
|
||||||
video: Video | null;
|
video: Video | null;
|
||||||
@ -24,6 +15,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
|
|||||||
const [viewId, setViewId] = useState<number | null>(null);
|
const [viewId, setViewId] = useState<number | null>(null);
|
||||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const lastWatchTime = useRef<number>(0);
|
const lastWatchTime = useRef<number>(0);
|
||||||
|
const streamUrl = useSignedMediaUrl(video ? `/media/videos/${video.id}/stream` : null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && video) {
|
if (open && video) {
|
||||||
@ -175,7 +167,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
|
|||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
|
src={streamUrl}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
113
admin/src/lib/media-url.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { mediaApi } from './media-api';
|
||||||
|
|
||||||
|
// Per-URL HMAC signing for <img>/<video> src — replaces the legacy
|
||||||
|
// `?token=<JWT>` pattern (removed server-side 2026-04-12). The backend
|
||||||
|
// rejects ?token=, so admin previews of unpublished media 404 unless we
|
||||||
|
// hit POST /api/media/sign for a short-lived path-bound signature.
|
||||||
|
|
||||||
|
interface SignResponse {
|
||||||
|
url: string;
|
||||||
|
sig: string;
|
||||||
|
exp: string;
|
||||||
|
uid: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
url: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const inflight = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
// Refresh a bit before expiry so an in-flight <video> stream doesn't
|
||||||
|
// hit a freshly-expired signature mid-playback.
|
||||||
|
const REFRESH_BUFFER_SECONDS = 30;
|
||||||
|
|
||||||
|
function clientToServerPath(clientPath: string): string {
|
||||||
|
// Strip query string before translating prefix.
|
||||||
|
const queryIdx = clientPath.indexOf('?');
|
||||||
|
const path = queryIdx === -1 ? clientPath : clientPath.slice(0, queryIdx);
|
||||||
|
const query = queryIdx === -1 ? '' : clientPath.slice(queryIdx);
|
||||||
|
// Nginx rewrites /media/* → /api/* before the request reaches media-api.
|
||||||
|
// The signing endpoint requires the post-rewrite (server-side) path.
|
||||||
|
const serverPath = path.startsWith('/media/')
|
||||||
|
? '/api/' + path.slice('/media/'.length)
|
||||||
|
: path;
|
||||||
|
return serverPath + query;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signedMediaUrl(clientPath: string): Promise<string> {
|
||||||
|
if (!clientPath) return clientPath;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cached = cache.get(clientPath);
|
||||||
|
if (cached && cached.exp - REFRESH_BUFFER_SECONDS > now) {
|
||||||
|
return cached.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = inflight.get(clientPath);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const serverPath = clientToServerPath(clientPath);
|
||||||
|
const { data } = await mediaApi.post<SignResponse>('/media/sign', {
|
||||||
|
path: serverPath,
|
||||||
|
});
|
||||||
|
const sep = clientPath.includes('?') ? '&' : '?';
|
||||||
|
const query = `sig=${data.sig}&exp=${data.exp}&uid=${data.uid}`;
|
||||||
|
const url = `${clientPath}${sep}${query}`;
|
||||||
|
cache.set(clientPath, { url, exp: Number(data.exp) });
|
||||||
|
return url;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(clientPath);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(clientPath, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook: returns a signed media URL for embedding in <img>/<video> src.
|
||||||
|
*
|
||||||
|
* Returns `undefined` until the first sign call completes. Pass `null` /
|
||||||
|
* `undefined` / empty string to disable (returns `undefined`).
|
||||||
|
*
|
||||||
|
* Cached across re-renders, so a list of 50 thumbnails issues 50 sign
|
||||||
|
* requests on initial mount and zero on subsequent renders within the TTL.
|
||||||
|
*/
|
||||||
|
export function useSignedMediaUrl(
|
||||||
|
clientPath: string | null | undefined
|
||||||
|
): string | undefined {
|
||||||
|
const [signed, setSigned] = useState<string | undefined>(() => {
|
||||||
|
if (!clientPath) return undefined;
|
||||||
|
const cached = cache.get(clientPath);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return cached.exp - REFRESH_BUFFER_SECONDS > now ? cached.url : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientPath) {
|
||||||
|
setSigned(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
signedMediaUrl(clientPath)
|
||||||
|
.then((url) => {
|
||||||
|
if (!cancelled) setSigned(url);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Leave undefined; <img onError> / <video onError> can show a fallback.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [clientPath]);
|
||||||
|
|
||||||
|
return signed;
|
||||||
|
}
|
||||||
@ -25,7 +25,12 @@ COPY tsconfig.json ./
|
|||||||
|
|
||||||
# Development stage with hot reload
|
# Development stage with hot reload
|
||||||
FROM base AS development
|
FROM base AS development
|
||||||
|
# su-exec for dropping privileges after fixing mounted volume permissions
|
||||||
|
RUN apk add --no-cache su-exec
|
||||||
RUN npm install tsx --save-dev
|
RUN npm install tsx --save-dev
|
||||||
|
COPY media-docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/media-docker-entrypoint.sh
|
||||||
|
ENTRYPOINT ["media-docker-entrypoint.sh"]
|
||||||
CMD ["npx", "tsx", "watch", "src/media-server.ts"]
|
CMD ["npx", "tsx", "watch", "src/media-server.ts"]
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
@ -37,8 +42,9 @@ RUN npm run build
|
|||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching
|
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching.
|
||||||
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
# su-exec lets the entrypoint drop privileges after fixing mounted volume permissions.
|
||||||
|
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip su-exec && pip3 install --break-system-packages yt-dlp
|
||||||
|
|
||||||
# Copy manifests and install production-only deps (no devDeps like typescript)
|
# Copy manifests and install production-only deps (no devDeps like typescript)
|
||||||
COPY --from=build /app/package*.json ./
|
COPY --from=build /app/package*.json ./
|
||||||
@ -50,7 +56,11 @@ COPY --from=build /app/dist ./dist
|
|||||||
COPY --from=build /app/prisma ./prisma
|
COPY --from=build /app/prisma ./prisma
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Run as non-root user
|
# Entrypoint chowns mounted /media subdirs (root-owned on fresh deploys) then
|
||||||
USER node
|
# drops to node via su-exec. Note: USER node is intentionally NOT set here —
|
||||||
|
# the entrypoint must start as root to repair host bind-mount permissions.
|
||||||
|
COPY media-docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/media-docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["media-docker-entrypoint.sh"]
|
||||||
CMD ["node", "dist/media-server.js"]
|
CMD ["node", "dist/media-server.js"]
|
||||||
|
|||||||
22
api/media-docker-entrypoint.sh
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Fix permissions for mounted volumes (host dirs may be root-owned on first
|
||||||
|
# run — Docker auto-creates missing bind-mount sources as root:root).
|
||||||
|
# /media's :ro parent mount cannot be chowned, but the :rw subdirs that
|
||||||
|
# media-api writes into can — and that's all the process needs.
|
||||||
|
# /app/logs is shared with the api container via the ./api:/app dev mount;
|
||||||
|
# api's entrypoint already chowns it but media-api may start independently.
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
for d in /media/local/inbox /media/local/thumbnails /media/local/photos \
|
||||||
|
/media/local/documents /media/public /app/logs; do
|
||||||
|
[ -d "$d" ] && chown -R node:node "$d" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Drop to node user if running as root (production image uses su-exec).
|
||||||
|
if [ "$(id -u)" = "0" ] && command -v su-exec >/dev/null 2>&1; then
|
||||||
|
exec su-exec node "$@"
|
||||||
|
else
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
@ -3,8 +3,8 @@ import { prisma } from '../../../config/database';
|
|||||||
import { requireAdminRole } from '../middleware/auth';
|
import { requireAdminRole } from '../middleware/auth';
|
||||||
import { videoAnalyticsService } from '../services/video-analytics.service';
|
import { videoAnalyticsService } from '../services/video-analytics.service';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { sign } from 'jsonwebtoken';
|
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
|
import { signMediaPath } from '../../../utils/signed-url';
|
||||||
import { copyFile } from 'fs/promises';
|
import { copyFile } from 'fs/promises';
|
||||||
import { join, dirname, basename, extname, normalize, resolve } from 'path';
|
import { join, dirname, basename, extname, normalize, resolve } from 'path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -299,7 +299,13 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /videos/:id/preview-link
|
* GET /videos/:id/preview-link
|
||||||
* Generate a temporary preview link with expiring JWT token
|
* Generate a shareable, time-limited preview link.
|
||||||
|
*
|
||||||
|
* Uses path-bound HMAC signatures (sig/exp/uid) — same scheme as
|
||||||
|
* POST /api/media/sign — instead of the legacy `?token=<JWT>` form,
|
||||||
|
* which leaked full session tokens via access logs/referer headers.
|
||||||
|
* The signature carries only the admin's user-id and is bound to the
|
||||||
|
* stream URL, so it can be safely shared with stakeholders.
|
||||||
*/
|
*/
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/:id/preview-link',
|
'/:id/preview-link',
|
||||||
@ -318,24 +324,20 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.code(404).send({ message: 'Video not found' });
|
return reply.code(404).send({ message: 'Video not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token that expires in 24 hours
|
|
||||||
const expiryHours = parseInt(process.env.VIDEO_PREVIEW_LINK_EXPIRY_HOURS || '24');
|
const expiryHours = parseInt(process.env.VIDEO_PREVIEW_LINK_EXPIRY_HOURS || '24');
|
||||||
const token = sign(
|
const ttlSeconds = expiryHours * 60 * 60;
|
||||||
{
|
const userId = request.user!.id;
|
||||||
videoId,
|
|
||||||
purpose: 'preview',
|
|
||||||
},
|
|
||||||
env.JWT_ACCESS_SECRET,
|
|
||||||
{ expiresIn: `${expiryHours}h` }
|
|
||||||
);
|
|
||||||
|
|
||||||
const previewUrl = `${env.MEDIA_API_PUBLIC_URL}/api/videos/${videoId}/preview?token=${token}`;
|
const streamPath = `/api/videos/${videoId}/stream`;
|
||||||
|
const signed = signMediaPath(streamPath, userId, ttlSeconds);
|
||||||
|
const query = `sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
|
||||||
|
const previewUrl = `${env.MEDIA_API_PUBLIC_URL}${streamPath}?${query}`;
|
||||||
|
|
||||||
logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
|
logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previewUrl,
|
previewUrl,
|
||||||
expiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000).toISOString(),
|
expiresAt: new Date(Number(signed.exp) * 1000).toISOString(),
|
||||||
expiryHours,
|
expiryHours,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 60 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/achievements.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/activity.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 113 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/canvass-map.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 54 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/dashboard.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/discover.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/feed.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/friends.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 59 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/profile.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/routes.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
mkdocs/docs/assets/images/screenshots/volunteer/shifts.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
@ -13,6 +13,8 @@ tags:
|
|||||||
|
|
||||||
Recognize volunteer contributions with unlockable achievement badges and competitive leaderboards.
|
Recognize volunteer contributions with unlockable achievement badges and competitive leaderboards.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@ -91,3 +93,5 @@ The Achievements page also displays aggregate stats for the current user:
|
|||||||
## Volunteer Routes
|
## Volunteer Routes
|
||||||
|
|
||||||
- `/volunteer/achievements` — badge gallery, progress bars, leaderboard tabs, and personal stats
|
- `/volunteer/achievements` — badge gallery, progress bars, leaderboard tabs, and personal stats
|
||||||
|
|
||||||
|
{ loading=lazy width=320 }
|
||||||
|
|||||||
@ -13,6 +13,8 @@ tags:
|
|||||||
|
|
||||||
The volunteer canvass map is your main tool for door-to-door outreach — a full-screen GPS-tracked experience.
|
The volunteer canvass map is your main tool for door-to-door outreach — a full-screen GPS-tracked experience.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Volunteer Map
|
## The Volunteer Map
|
||||||
@ -47,12 +49,18 @@ The volunteer canvass map is your main tool for door-to-door outreach — a full
|
|||||||
- The map works offline for basic viewing, but you need a connection to save visits
|
- The map works offline for basic viewing, but you need a connection to save visits
|
||||||
- If GPS is inaccurate, manually tap the correct marker on the map
|
- If GPS is inaccurate, manually tap the correct marker on the map
|
||||||
|
|
||||||
|
The map is designed to be used in the field on a phone:
|
||||||
|
|
||||||
|
{ loading=lazy width=320 }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
The **Routes** tab shows your past canvassing routes on a map, helping you see which areas you've covered and plan your next outing.
|
The **Routes** tab shows your past canvassing routes on a map, helping you see which areas you've covered and plan your next outing.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Volunteer Routes
|
## Volunteer Routes
|
||||||
|
|||||||
@ -11,6 +11,8 @@ tags:
|
|||||||
|
|
||||||
Welcome! This guide walks you through everything you need as a campaign volunteer.
|
Welcome! This guide walks you through everything you need as a campaign volunteer.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@ -32,6 +34,10 @@ After logging in, you'll land on the volunteer portal. Use the bottom navigation
|
|||||||
- **Friends** — social connections with other volunteers
|
- **Friends** — social connections with other volunteers
|
||||||
- **Achievements** — badges and leaderboards
|
- **Achievements** — badges and leaderboards
|
||||||
|
|
||||||
|
The portal is mobile-first — the same layout works on a phone in the field:
|
||||||
|
|
||||||
|
{ loading=lazy width=320 }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In This Section
|
## In This Section
|
||||||
|
|||||||
@ -12,6 +12,8 @@ tags:
|
|||||||
|
|
||||||
View your upcoming and past volunteer shifts from the **Shifts** tab in the bottom navigation.
|
View your upcoming and past volunteer shifts from the **Shifts** tab in the bottom navigation.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Shift Details
|
## Shift Details
|
||||||
@ -32,6 +34,8 @@ The **Activity** tab shows your complete visit history:
|
|||||||
- **Visit list** — each visit with address, outcome, time, and notes
|
- **Visit list** — each visit with address, outcome, time, and notes
|
||||||
- **Stats** — total visits, addresses covered, and sessions completed
|
- **Stats** — total visits, addresses covered, and sessions completed
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Volunteer Routes
|
## Volunteer Routes
|
||||||
|
|||||||
@ -22,6 +22,8 @@ Connect with fellow volunteers through friend requests, activity feeds, team gro
|
|||||||
- **Mutual friends** — view shared connections between users
|
- **Mutual friends** — view shared connections between users
|
||||||
- **Block / unblock** — blocked users cannot send requests or appear in suggestions
|
- **Block / unblock** — blocked users cannot send requests or appear in suggestions
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Discover
|
## Discover
|
||||||
@ -33,6 +35,8 @@ The Discover page suggests potential friends using a ranked scoring algorithm ba
|
|||||||
- Shared shifts (co-volunteers from the last 90 days)
|
- Shared shifts (co-volunteers from the last 90 days)
|
||||||
- Shared campaigns (co-participants from the last 90 days)
|
- Shared campaigns (co-participants from the last 90 days)
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Activity Feed
|
## Activity Feed
|
||||||
@ -42,6 +46,8 @@ The Social Feed at `/volunteer/feed` shows recent activity from your friends:
|
|||||||
- Shift signups, campaign emails, canvass sessions, and response submissions
|
- Shift signups, campaign emails, canvass sessions, and response submissions
|
||||||
- Limited to the last 30 days (max 50 items)
|
- Limited to the last 30 days (max 50 items)
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
@ -57,6 +63,8 @@ Groups are automatically created based on platform activity:
|
|||||||
|
|
||||||
Each volunteer has a social profile showing volunteer stats, achievement badges, friendship status, and recent activity.
|
Each volunteer has a social profile showing volunteer stats, achievement badges, friendship status, and recent activity.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pokes
|
## Pokes
|
||||||
@ -81,6 +89,14 @@ Opt into periodic social digest emails with friend activity, unread notification
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
The notification center at `/volunteer/notifications` collects friend requests, pokes, achievement unlocks, and group activity. Use **Preferences** to choose which notifications appear and **Mark All Read** to clear the badge.
|
||||||
|
|
||||||
|
{ loading=lazy }
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Volunteer Routes
|
## Volunteer Routes
|
||||||
|
|
||||||
- `/volunteer/feed` — social activity feed
|
- `/volunteer/feed` — social activity feed
|
||||||
|
|||||||
@ -227,6 +227,7 @@ mkdir -p "$STAGE_DIR/data/upgrade"
|
|||||||
mkdir -p "$STAGE_DIR/media/local/inbox"
|
mkdir -p "$STAGE_DIR/media/local/inbox"
|
||||||
mkdir -p "$STAGE_DIR/media/local/thumbnails"
|
mkdir -p "$STAGE_DIR/media/local/thumbnails"
|
||||||
mkdir -p "$STAGE_DIR/media/local/photos"
|
mkdir -p "$STAGE_DIR/media/local/photos"
|
||||||
|
mkdir -p "$STAGE_DIR/media/local/documents"
|
||||||
mkdir -p "$STAGE_DIR/media/public"
|
mkdir -p "$STAGE_DIR/media/public"
|
||||||
mkdir -p "$STAGE_DIR/local-files"
|
mkdir -p "$STAGE_DIR/local-files"
|
||||||
info "Data directories (empty)"
|
info "Data directories (empty)"
|
||||||
|
|||||||