Plik Webapp — Architecture & Gotchas
Non-obvious details, design decisions, and pitfalls that agents should know before iterating on this codebase. For system-wide overview, see the root ARCHITECTURE.md.
Tech Stack
| Layer | Tech |
|---|---|
| Framework | Vue 3 (Composition API, <script setup>) |
| Router | Vue Router 4, hash history (#/) |
| Styling | Tailwind CSS v4 (via @import "tailwindcss") with custom @utility and @theme blocks |
| Build | Vite |
| HTTP | fetch() for JSON APIs, XMLHttpRequest for file uploads (progress tracking) |
| Backend | Go (Plik server, serves the SPA from webapp/dist/ via http.FileServer) |
Routing & URL Format
All routes use hash-history (#/):
| Route | View | Purpose |
|---|---|---|
/#/ | RootView | Upload (no query) or Download (?id=...) |
/#/login | LoginView | Local + OAuth login |
/#/home | HomeView | User dashboard (uploads, tokens, account) |
/#/admin | AdminView | Admin panel (stats, users, all uploads) |
/#/clients | ClientsView | CLI client downloads |
/#/upload/:id | (redirect) | Legacy URL → /?id=:id |
Admin link (upload-level): /#/?id=<uploadId>&uploadToken=<token>
RootView.vue checks route.query.id — if present, renders DownloadView; otherwise UploadView.
Gotcha: The router uses
createWebHashHistory(), so all URLs include#/. Thebaseinapi.jsis computed fromwindow.location.origin + pathname(without hash), so API calls go to the correct backend path.
Auth Navigation Guard
When config.feature_authentication is "forced", a router.beforeEach guard redirects unauthenticated users to /#/login. Exceptions:
- The login page itself (
to.name === 'login') - Download pages (
to.name === 'root' && to.query.id) — so shared links still work
Upload Token (Admin Auth)
How it works
The Plik server generates an uploadToken when an upload is created. This token grants admin access (add/remove files, delete upload). It is returned as part of the upload JSON response.
Token lifecycle
UploadView creates upload → server returns { id, uploadToken, ... }
↓
Token stored in memory (tokenStore.js) via setToken(id, token)
↓
Router navigates to /?id=<id> (NO token in URL)
↓
DownloadView reads token from getToken(id) → sends as X-UploadToken headerAdmin URL sharing
The Admin URL (shown in DownloadView sidebar) is the only way to share admin access:
http://host/#/?id=<id>&uploadToken=<token>When someone opens an admin URL:
onMountedin DownloadView readsuploadTokenfromroute.query- Stores it in memory via
setToken(id, token) - Immediately strips it from the URL via
router.replace()to prevent accidental sharing
Key rules
- Never persist tokens to
localStorage,sessionStorage, or cookies - Never leave tokens in the URL after initial load — strip immediately
- Tokens are sent as
X-UploadTokenheader, never in the request body - Token is per-tab, per-session — refreshing the page loses admin access (by design)
getUpload()and other API calls pass the token so the server returnsadmin: truein the response
File Status Values
Files in the Plik API have a status field with 5 possible values:
| Status | Meaning | Displayed? |
|---|---|---|
missing | File entry created, waiting to be uploaded | ✅ Yes (uploading UI) |
uploading | File is currently being uploaded | ✅ Yes (progress bar) |
uploaded | File has been uploaded and is ready for download | ✅ Yes |
removed | File has been removed by user, not yet cleaned up | ❌ No |
deleted | File has been deleted from the data backend | ❌ No |
activeFiles computed property
Files with removed or deleted status are filtered out. All other statuses are displayed:
const activeFiles = computed(() => {
return upload.value.files.filter(f => f.status !== 'removed' && f.status !== 'deleted')
})Gotcha: After deleting a file via the API, the server returns
"ok"(plain text, not JSON). The file's status changes toremovedserver-side but the API does not return the updated file object. You must callfetchUpload()again to refresh the list.
Gotcha: If
activeFiles.length === 0after fetching, DownloadView redirects to home (/). This handles the case where all files have been deleted or consumed (one-shot).
API Error Handling
The two-pass text→JSON pattern
The Plik server returns errors as either JSON or plain text depending on the endpoint. The apiCall function handles this with a two-pass approach:
// 1. Read as text first (always works)
const text = await resp.text()
// 2. Try to parse as JSON
try {
const body = JSON.parse(text)
message = body.message || body || message
} catch {
// 3. Fall back to raw text (e.g., "upload abc123 not found")
message = text || message
}Why: Calling
resp.json()first consumes the body stream. If it fails (plain text response),resp.text()would then also fail with "body stream already read". The text-first approach avoids this.
Success responses
Some endpoints return plain text on success:
DELETE /upload/:id→"ok"DELETE /file/:uploadId/:fileId/:fileName→"ok"
The apiCall function handles this too:
const text = await resp.text()
if (!text) return null
try { return JSON.parse(text) } catch { return text }Error display
Errors from fetchUpload are displayed inline (not redirecting). This shows the actual server message like "upload feafea not found" instead of a generic error.
File Upload Mechanics
Two URL patterns for uploading files
| Scenario | URL pattern |
|---|---|
Initial upload (has fileId from createUpload) | POST /file/:uploadId/:fileId/:fileName |
| Adding files to existing upload (no fileId) | POST /file/:uploadId |
The api.js uploadFile function picks the right pattern:
if (fileEntry.id) {
url = `${base}/${mode}/${upload.id}/${fileEntry.id}/${fileEntry.fileName}`
} else {
url = `${base}/${mode}/${upload.id}`
}Stream vs File mode
The URL prefix changes based on whether the upload uses streaming:
- Normal:
/file/... - Streaming:
/stream/...
Upload flow (UploadView)
createUpload(params)→ server returns upload withid,uploadToken, and pre-created file entries (with IDs)- For each file:
uploadFile(upload, fileEntry, onProgress)→ sends FormData with thefilefield - On success:
setToken(id, token), thenrouter.push({ path: '/', query: { id } })
Staged upload flow (DownloadView)
When adding files to an existing upload:
onFilesSelectedstages files inpendingFilesref (NOT uploaded yet)- User sees staged files with remove buttons, can review before uploading
- Clicking "Upload" runs
uploadPendingFiles()which uploads each file with progress - After all uploads:
pendingFilescleared,fetchUpload()refreshes the list - Files added to existing uploads have no pre-created fileId — server assigns one
Staged File Object Shape
Files stored locally before upload use this shape (NOT the server shape):
{
reference: 'ref-1707123456-1', // Local unique ID (from generateRef())
fileName: 'photo.jpg',
size: 1048576,
file: File, // The browser File object
status: 'toUpload', // 'toUpload' | 'uploading' | 'uploaded' | 'error'
progress: 0, // 0-100 upload progress
}Gotcha: Local files use
referenceas a key, notid. Theidis only assigned by the server after upload. Thesizefield issizelocally butfileSizein server responses.
Feature Flags (Config)
The server exposes feature flags via GET /config:
| Value | Meaning |
|---|---|
enabled | Feature is available, default off |
disabled | Feature is hidden entirely |
forced | Feature is on, user cannot toggle it off |
default | Feature is available, default on |
The config object keys use the pattern feature_<name> (e.g., feature_one_shot, feature_stream).
Helper functions (in config.js):
isFeatureEnabled(name)→ returnstrueunless value is"disabled"isFeatureForced(name)→ returnstrueonly if value is"forced"isFeatureDefaultOn(name)→ returnstrueif value is"default"or"forced"(controls initial toggle state)
Other Config Keys
The GET /config response also includes:
| Key | Purpose | |-----|---------|| | maxFileSize | Max file size in bytes (shown in upload drop zone) | | maxUserSize | Max total size per user | | maxTTL | Max TTL in seconds | | googleAuthentication | true if Google OAuth is configured → shows Google login button | | ovhAuthentication | true if OVH OAuth is configured → shows OVH login button | | localAuthentication | true if local login is enabled (auth enabled AND DisableLocalLogin is false) | | oidcAuthentication | true if OIDC is configured → shows OIDC login button | | oidcProviderName | Display name for OIDC button (e.g. "Keycloak", defaults to "OpenID") | | downloadDomain | Alternate domain for download URLs (set in api.js via setDownloadDomain) | | abuseContact | Abuse contact email → displayed in global footer (App.vue) |
Size & TTL Limit Precedence
The server enforces layered limits: user-specific → server config. The special values 0 (use default) and -1 (unlimited) are key.
Value Semantics
| Value | Meaning |
|---|---|
> 0 | Explicit limit (bytes for size, seconds for TTL) |
0 | Use server default |
-1 | Unlimited (no limit enforced) |
Precedence Rules (from server/context/upload.go)
MaxFileSize (GetMaxFileSize()):
if user != nil && user.MaxFileSize != 0 → user.MaxFileSize
else → config.MaxFileSizeMaxUserSize (GetUserMaxSize()):
if user == nil → unlimited (-1) // anonymous = no user quota
if user.MaxUserSize > 0 → user.MaxUserSize // explicit user limit
if user.MaxUserSize < 0 → unlimited (-1) // user explicitly unlimited
if user.MaxUserSize == 0 → config.MaxUserSize // fall back to server defaultMaxTTL (inside setTTL()):
maxTTL = config.MaxTTL
if user != nil && user.MaxTTL != 0 → maxTTL = user.MaxTTL
if maxTTL > 0 → enforce (reject infinite or over-limit TTL)
if maxTTL <= 0 → no limit enforcedEffective Limit Calculation (Client-Side)
UploadView.vue computes effective limits via auth.user with fallback to config:
const effectiveMaxFileSize = computed(() => {
const user = auth.user
if (user && user.maxFileSize !== 0 && user.maxFileSize !== undefined) return user.maxFileSize
return config.maxFileSize
})The same pattern applies for effectiveMaxTTL, which is passed as a prop to UploadSidebar.
Size Unit Convention (SI / 1000-based)
IMPORTANT
All size formatting uses SI units (1 GB = 1,000,000,000 bytes), matching the server's go-humanize library (humanize.ParseBytes / humanize.Bytes). The GB constant in edit modals is 1000³, not 1024³.
This means:
- Config
MaxFileSizeStr = "10GB"→ 10,000,000,000 bytes → displays "10.00 GB" everywhere - Admin enters "1" GB in edit modal → stores 1,000,000,000 bytes → shows "1.00 GB"
CAUTION
Never use 1024-based division with "GB" labels. If you need binary units, use "GiB" labels with 1024-based math.
Component Architecture
App.vue
├── AppHeader.vue — top nav bar (Upload, CLI, Source, user/admin links)
├── RootView.vue — switches between Upload/Download based on query.id
│ ├── UploadView.vue — file staging, settings, upload execution
│ │ ├── UploadSidebar — upload settings (one-shot, stream, TTL, etc.)
│ │ └── FileRow — individual file display
│ └── DownloadView.vue — file list, download links, admin actions
│ ├── DownloadSidebar — upload info, admin URL, action buttons
│ ├── FileRow — download/QR/copy/remove per file
│ ├── QrCodeDialog — QR code modal
│ ├── CopyButton — clipboard copy with feedback
│ └── ConfirmDialog — confirmation modal
├── LoginView.vue — local login form + OAuth/OIDC buttons
├── HomeView.vue — user dashboard (uploads/tokens/account)
│ └── CopyButton — clipboard copy for tokens
├── AdminView.vue — admin panel (stats/users/uploads)
└── ClientsView.vue — CLI client downloads (from embedded build info)Authenticated Pages
Auth State (authStore.js)
Reactive singleton holding auth.user (set on login, cleared on logout). Checked by main.js on app load via GET /me. The header shows user/admin links when auth.user is set.
LoginView (/#/login)
- Local login form (username + password →
POST /auth/local/login) — hidden whenconfig.localAuthenticationisfalse(i.e.DisableLocalLogin = trueon the server) - Conditional OAuth buttons (Google, OVH) based on
config.googleAuthentication/config.ovhAuthentication - OIDC button (label from
config.oidcProviderName) → callsGET /auth/oidc/loginto get the authorization URL, thenwindow.location.hrefredirects to the OIDC provider - "or continue with" divider only shown when both local login and at least one OAuth/OIDC provider are enabled
- Redirects to
/#/homeon success
HomeView (/#/home)
Sidebar + main content layout (same pattern as download view).
Sidebar: user avatar, login/provider, name, email, admin badge, stats (uploads/files/size). Buttons: Upload files, Uploads, Tokens, Sign out, Edit account, Delete uploads, Delete account.
Uploads tab: paginated user uploads via GET /me/uploads. Supports token-based filtering. Each upload shows files, date, size, with clickable token labels.
Tokens tab: list/create/revoke tokens via GET|POST|DELETE /me/token. Token comment displayed above UUID. Click token to filter uploads by it.
Edit Account modal: name, email, password (local only). Admin users additionally see maxFileSize, maxUserSize, maxTTL, admin toggle. Saves via POST /user/{id}.
Gotcha: Non-admin users cannot change quota fields or admin status — the server enforces this; the UI hides those fields.
AdminView (/#/admin)
Admin-only page. Redirects non-admins to / on mount.
Sidebar: server version/build info (release + mint badges), nav buttons (Stats, Uploads, Users), Create User button.
Stats tab: server config (maxFileSize, maxUserSize, defaultTTL, maxTTL) + server statistics (users, uploads, files, totalSize, anonymous counts).
Users tab: paginated user list via GET /users. Each row shows login, provider, name, email, quotas, admin badge. Actions: Impersonate (👤), Edit (opens modal with full quota controls), Delete (with confirmation). Delete disabled for self. Impersonate disabled for self.
Uploads tab: paginated all-uploads via GET /uploads. Sort by date/size, order asc/desc. Filter by user/token (clickable links in each row). Each row shows upload ID (link), dates, user, token, files with sizes, Remove button.
Create User modal: provider (select), login, password (local only), name, email, quotas (maxFileSize, maxUserSize, maxTTL), admin toggle. Creates via POST /user.
Edit User modal: same as HomeView edit but with full admin quota controls always visible.
Impersonation
Allows an admin to "become" another user to browse their uploads, test their quotas, or manage their account. The feature spans four files:
Flow:
- Admin clicks 👤 on a user row in AdminView
authStore.impersonate(user)stores the target user and callsapi.setImpersonateUser(userId)api.jsinjectsX-Plik-Impersonate: <userId>header on every subsequent API request- Server middleware (
server/middleware/impersonate.go) detects the header, verifies the caller is an admin, and switches the request context to the impersonated user GET /menow returns the impersonated user —authStore.userupdates accordingly- A yellow banner in
AppHeader.vueshows "⚠️ Impersonating username" with a Stop button
State management (authStore.js):
auth.originalUser— preserved real admin identity (never changes during impersonation)auth.impersonatedUser— the user object being impersonated (null when not impersonating)auth.user— switches to the impersonated user during impersonationclearImpersonate()— resets header, restoresauth.usertoauth.originalUser
API layer (api.js):
setImpersonateUser(userId)— sets/clears a module-level_impersonateUserIdapiCall()— if_impersonateUserIdis set, addsX-Plik-Impersonateheader
API Endpoints (Auth/Admin)
| Endpoint | Method | Purpose | Auth |
|---|---|---|---|
/auth/local/login | POST | Local login | — |
/auth/oidc/login | GET | Get OIDC authorization URL | — |
/auth/oidc/callback | GET | OIDC callback (sets session) | — |
/auth/google/login | GET | Get Google authorization URL | — |
/auth/ovh/login | GET | Get OVH authorization URL | — |
/auth/logout | GET | Logout | Session |
/me | GET | Get current user | Session |
/me | DELETE | Delete account | Session |
/me/uploads | GET | User uploads (paginated) | Session |
/me/uploads | DELETE | Delete all user uploads | Session |
/me/token | GET | List tokens | Session |
/me/token | POST | Create token | Session |
/me/token/{token} | DELETE | Revoke token | Session |
/user/{id} | POST | Update user | Session |
/stats | GET | Server statistics | Admin only |
/users | GET | List all users (paginated) | Admin only |
/user | POST | Create user | Admin only |
/user/{id} | DELETE | Delete user | Admin only |
/uploads | GET | All uploads (paginated, filterable) | Admin only |
Gotcha: XSRF token (from
plik-xsrfcookie) must be sent asX-XSRFTokenheader on all mutating requests (POST, DELETE). This is handled automatically inapiCall().
Responsive Layout
The layout uses a mobile-first stacking pattern:
Mobile (<768px): [Sidebar] (full width, stacked on top)
[Main Content] (full width, below)
Desktop (≥768px): [Sidebar | Main Content] (side by side)Key classes:
- Containers:
flex flex-col md:flex-row - Sidebars:
w-full md:w-72 md:shrink-0 - Outer wrapper:
overflow-x-hidden(prevents long URLs from causing horizontal scroll) - FileRow: inner container uses
flex-wrap, "Download" text hidden on mobile (hidden md:inline)
CSS Custom Utilities
The style.css file defines custom utility classes via @utility (Tailwind v4 syntax):
| Utility | Description |
|---|---|
glass-card | Semi-transparent card with backdrop blur |
btn | Base button styles |
btn-primary | Accent-colored button (cyan) |
btn-success | Green button |
btn-danger | Red button |
btn-ghost | Transparent hover button |
toggle-switch | Toggle switch base |
toggle-dot | Toggle switch dot (animated) |
input-field | Styled text input |
sidebar-section | Glass-card styled sidebar section |
file-row | Glass-card styled file row with hover effect |
Gotcha: These are
@utilityblocks, NOT traditional CSS classes or Tailwind@apply. They follow Tailwind v4's custom utility syntax and generate single utility classes.
Build & Release Process
Development
cd webapp && npm install && npm run dev # Vite dev server on :5173, proxies API to :8080
cd server && go run . --config ./plikd.cfg # Go backend on :8080Vite proxy is configured in vite.config.js — all /api, /auth, /file, /stream, /config, /me, etc. calls are forwarded to the Go backend.
Production Build
make frontend # cd webapp && npm ci && npm run build → webapp/dist/
make server # cd server && go build → server/plikdThe Go server serves webapp/dist/ via http.FileServer. Default config: WebappDirectory = "../webapp/dist".
Makefile Targets
| Target | Purpose |
|---|---|
all | clean clean-frontend frontend clients server |
frontend | npm ci && npm run build in webapp/ |
server | Build Go binary server/plikd |
client | Build Go CLI client client/plik |
clients | Cross-compile clients for all architectures |
docker | Build Docker image rootgg/plik:dev |
release | Create release archives via releaser/release.sh |
clean | Remove server/client binaries |
clean-frontend | Remove webapp/dist/ |
clean-all | Clean everything including node_modules |
Build Info & Client Downloads
The server binary embeds a JSON blob (via server/gen_build_info.sh) containing a client list discovered from the clients/ directory. The ClientsView page displays download links from this embedded build info.
For full details on the Docker multi-stage build and release packaging, see releaser/ARCHITECTURE.md.
Common Pitfalls
Don't call
resp.json()thenresp.text()— the body stream can only be read once. Always read as text first.File IDs are server-assigned — when adding files to existing uploads, don't pass a
fileIdin the URL. The server creates one.uploadTokenmust be inX-UploadTokenheader — not in the request body or URL query for API calls.Filter out
removed/deleted, show everything else — use a blacklist, not a whitelist. Files withmissingoruploadingstatus must be shown (they represent ongoing uploads).Refreshing the page loses admin access — tokens are in-memory only. The only way to regain access is to open the Admin URL again.
Delete responses are plain text
"ok"— don't try to parse.messagefrom them. AlwaysfetchUpload()after mutations.One-shot files disappear after download — their status changes server-side; re-fetching will show them as removed/missing.
The Admin URL sidebar truncation uses
overflow-hidden+min-w-0— without this, long URLs push the entire mobile layout wider than the viewport.generateRef()is for local tracking only — it creates monotonically increasing IDs that are never sent to the server.Vite dev server runs on port 5173/5174 — the Go backend runs on port 8080. During dev, Vite proxies API calls to the backend via
vite.config.js.webapp/dist/is gitignored — never commit build artifacts. The CI/Docker build produces them fresh.
