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" > +
{{ inputError }}