diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a21f178 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..619a22c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json /app/ +RUN npm ci + +COPY . /app +RUN npm run build + +FROM nginx:1.27-alpine + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 5173 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 6592f74..cfa6e1a 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,19 @@ Then open the Vite dev server URL (usually `http://localhost:5173`). - Embeddings are computed client-side via `@huggingface/transformers` using `Xenova/clip-vit-base-patch32`. - PCA is computed client-side with `ml-pca`. +- Input hardening: + - text input is capped at 280 characters, + - image input only accepts `jpg/jpeg/png`, + - image file size is limited to 3 MB. - This is a prototype UI: no backend, no persistence (refresh clears lilies), no auth. - Water background uses a tileable image: `public/water_tileable.jpg`. +## Deployment Notes + +- The production Docker image builds static assets (`npm run build`) and serves them via Nginx. +- Security headers (including CSP, frame restrictions, and content type hardening) are set in `nginx/default.conf`. +- Inputs stay browser-local: uploaded images are represented as object URLs and are not sent to a backend by this app. + ## Project Structure - `src/App.vue` main state + orchestration (create/grow/plant/select) diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..95692c1 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,17 @@ +server { + listen 5173; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob: https:; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; font-src 'self' data: https:; worker-src 'self' blob: https:; connect-src 'self' blob: data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/src/components/LilyCreator.vue b/src/components/LilyCreator.vue index 97bc7ba..a9a05e0 100644 --- a/src/components/LilyCreator.vue +++ b/src/components/LilyCreator.vue @@ -43,10 +43,14 @@ v-if="mode === 'text'" v-model="textInput" :placeholder="t('creator.placeholder')" + :maxlength="TEXT_MAX_CHARS" rows="6" > +

{{ t('creator.textLimit', { count: textLength, max: TEXT_MAX_CHARS }) }}

- + +

{{ t('creator.imageLimit', { maxSizeMb: IMAGE_MAX_SIZE_MB, formats: ALLOWED_IMAGE_EXTENSIONS_LABEL }) }}

+
@@ -117,7 +121,7 @@ diff --git a/src/locales/de.ts b/src/locales/de.ts index 8718530..8c337c2 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -23,9 +23,15 @@ export default { imagePreviewAlt: 'Vorschau', helperStart: 'Zum Start wähle', helperOr: 'oder', + textLimit: '{count}/{max} Zeichen', + imageLimit: 'Erlaubt: {formats} bis {maxSizeMb} MB', growButton: 'Wachsen lassen! 🪷', growingButton: 'Wächst ... 🪷', plantButton: 'Einpflanzen! 🪷', + errors: { + invalidImageType: 'Ungültiges Bildformat. Bitte nutze {formats}.', + imageTooLarge: 'Das Bild ist zu groß. Maximale Dateigröße: {maxSizeMb} MB.', + }, idle: { title: 'Lass aus deinen Daten eine Seerose wachsen', condensedMeaning: 'Liste aus Zahlen, die die Bedeutung deiner Daten beinhaltet', diff --git a/src/locales/en.ts b/src/locales/en.ts index c97f444..19ee777 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -23,9 +23,15 @@ export default { imagePreviewAlt: 'Preview', helperStart: 'To begin, choose', helperOr: 'or', + textLimit: '{count}/{max} characters', + imageLimit: 'Allowed: {formats} up to {maxSizeMb} MB', growButton: 'Grow it! 🪷', growingButton: 'Growing... 🪷', plantButton: 'Plant it! 🪷', + errors: { + invalidImageType: 'Invalid image format. Please use {formats}.', + imageTooLarge: 'Image is too large. Maximum file size is {maxSizeMb} MB.', + }, idle: { title: 'Grow a waterlily from your data', condensedMeaning: '"condensed meaning"', diff --git a/src/style.css b/src/style.css index ae89417..97e5dc9 100644 --- a/src/style.css +++ b/src/style.css @@ -1082,6 +1082,19 @@ button { margin-bottom: 12px; } +.input-meta { + margin: 2px 0 10px; + font-size: 0.84rem; + color: rgba(10, 45, 65, 0.72); +} + +.input-error { + margin: 0 0 12px; + color: #8a1f1f; + font-weight: 600; + font-size: 0.88rem; +} + .image-input .preview { display: grid; place-items: center;