diff --git a/Dockerfile b/Dockerfile index e843188b0..afa3f9399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,49 +1,68 @@ -# Stage 1: Build application -FROM --platform=$BUILDPLATFORM node:18-alpine AS compile-image +# syntax=docker/dockerfile:1 +# ┬─┐┬ ┐o┬ ┬─┐ +# │─││ │││ │ │ +# ┘─┘┘─┘┘┘─┘┘─┘ + +FROM node:18-alpine AS builder WORKDIR /build ARG USE_RELEASE=false ARG RELEASE_VERSION=main - ENV PNPM_CACHE_FOLDER .cache/pnpm/ -ADD . ./ -RUN \ - if [ $USE_RELEASE = true ]; then \ - wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \ - unzip frontend-release.zip -d dist/ && \ - exit 0; \ - fi && \ - # https://pnpm.io/installation#using-corepack - corepack enable && \ - # we don't use corepack prepare here by intend since - # we have renovate to keep our dependencies up to date - # Build the frontend - pnpm install && \ - apk add --no-cache git && \ - echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \ - pnpm run build +COPY package.json ./ +COPY pnpm-lock.yaml ./ -# Stage 2: copy -FROM nginx:alpine +RUN if [ "$USE_RELEASE" != true ]; then \ + # https://pnpm.io/installation#using-corepack + corepack enable && \ + pnpm install; \ + fi -COPY nginx.conf /etc/nginx/nginx.conf -COPY scripts/run.sh /run.sh +COPY . ./ -# copy compiled files from stage 1 -COPY --from=compile-image /build/dist /usr/share/nginx/html +RUN if [ "$USE_RELEASE" != true ]; then \ + apk add --no-cache --virtual .build-deps git jq && \ + git describe --tags --always --abbrev=10 | sed 's/-/+/; s/^v//; s/-g/-/' | \ + xargs -0 -I{} jq -Mcnr --arg version {} '{VERSION:$version}' | \ + tee src/version.json && \ + apk del .build-deps; \ + fi -# Unprivileged user -ENV PUID 1000 -ENV PGID 1000 +RUN if [ "$USE_RELEASE" = true ]; then \ + wget "https://dl.vikunja.io/frontend/vikunja-frontend-${RELEASE_VERSION}.zip" -O frontend-release.zip && \ + unzip frontend-release.zip -d dist/; \ + else \ + # we don't use corepack prepare here by intend since + # we have renovate to keep our dependencies up to date + # Build the frontend + pnpm run build; \ + fi +# ┌┐┐┌─┐o┌┐┐┐ │ +# ││││ ┬││││┌┼┘ +# ┘└┘┘─┘┘┘└┘┘ └ + +FROM nginx:stable-alpine AS runner +WORKDIR /usr/share/nginx/html LABEL maintainer="maintainers@vikunja.io" -RUN apk add --no-cache \ - # for sh file - bash \ - # installs usermod and groupmod - shadow +ENV VIKUNJA_HTTP_PORT 80 +ENV VIKUNJA_HTTP2_PORT 81 +ENV VIKUNJA_LOG_FORMAT main +ENV VIKUNJA_API_URL http://localhost:3456/api/v1 +ENV VIKUNJA_SENTRY_ENABLED false +ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480 -CMD "/run.sh" +COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY docker/templates/. /etc/nginx/templates/ +# copy compiled files from stage 1 +COPY --from=builder /build/dist ./ +# manage permissions +RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \ + chmod -R 0644 /etc/nginx/nginx.conf && \ + chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates +# unprivileged user +USER nginx diff --git a/README.md b/README.md index a62b837db..6b1871dbe 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,14 @@ If you find any security-related issues you don't want to disclose publicly, ple ## Docker There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled. +In order to build it from sources run the command below. (Docker >= v19.03) + +```shell +export DOCKER_BUILDKIT=1 +docker build -t vikunja/frontend . +``` + +Refer to Refer [to multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for the different platform. ## Project setup diff --git a/docker/injector.sh b/docker/injector.sh new file mode 100644 index 000000000..1ce7f3a4a --- /dev/null +++ b/docker/injector.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -e + +echo "info: API URL is $VIKUNJA_API_URL" +echo "info: Sentry enabled: $VIKUNJA_SENTRY_ENABLED" + +# Escape the variable to prevent sed from complaining +VIKUNJA_API_URL="$(echo "$VIKUNJA_API_URL" | sed -r 's/([:;])/\\\1/g')" +VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')" + +sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html +sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html +sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html + +date -uIseconds | xargs echo 'info: started at' diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 000000000..60468ee56 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,112 @@ +# Generated by nginxconfig.io +# https://www.digitalocean.com/community/tools/nginx?domains.0.server.domain=localhost&domains.0.server.documentRoot=%2Fusr%2Fshare%2Fnginx%2Fhtml&domains.0.server.cdnSubdomain=true&domains.0.https.https=false&domains.0.php.php=false&domains.0.routing.index=index.html&domains.0.routing.fallbackHtml=true&domains.0.routing.fallbackPhp=false&global.performance.assetsExpiration=1d&global.performance.mediaExpiration=1d&global.performance.svgExpiration=1d&global.performance.fontsExpiration=1d&global.logging.accessLog=%2Fdev%2Fstdout&global.logging.errorLog=%2Fdev%2Fstderr%20warn&global.logging.logNotFound=true&global.nginx.user=nginx&global.nginx.pid=%2Fvar%2Frun%2Fnginx.pid&global.nginx.clientMaxBodySize=50&global.docker.dockerfile=true&global.tools.modularizedStructure=false&global.tools.symlinkVhost=false +# and then edited manually ;) + +pid /tmp/nginx.pid; +worker_processes auto; +worker_rlimit_nofile 65535; + +events { + multi_accept on; + worker_connections 1024; +} + +http { + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + types_hash_max_size 2048; + types_hash_bucket_size 64; + + # rootless + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp_path; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + # MIME + include mime.types; + default_type application/octet-stream; + types { + application/manifest+json webmanifest; + } + + # Logging + log_format json escape=json + '{' + '"bytes_sent": "$bytes_sent",' + '"http_user_agent": "$http_user_agent",' + '"nginx_version": "$nginx_version",' + '"query_string": "$query_string",' + '"realip_remote_addr": "$realip_remote_addr",' + '"remote_addr": "$remote_addr",' + '"remote_user": "$remote_user",' + '"request_length": "$request_length",' + '"request_method": "$request_method",' + '"request_time": "$request_time",' + '"server_addr": "$server_addr",' + '"server_port": "$server_port",' + '"server_protocol": "$server_protocol",' + '"status": "$status",' + '"time_local": "$time_local",' + '"uri": "$uri"' + '}'; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + error_log /dev/stderr warn; + + keepalive_timeout 65; + + # compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_min_length 256; + gzip_types + text/plain + text/css + application/json + application/x-javascript + application/javascript + text/xml + application/xml + application/xml+rss + text/javascript + application/vnd.ms-fontobject + application/x-font-ttf + font/opentype + image/svg+xml + image/x-icon + audio/wav; + + map_hash_max_size 128; + map_hash_bucket_size 128; + + map $sent_http_content_type $expires { + default off; + text/css max; + application/javascript max; + text/javascript max; + application/vnd.ms-fontobject max; + application/x-font-ttf max; + font/opentype max; + font/woff2 max; + image/svg+xml max; + image/x-icon max; + audio/wav max; + ~images/ max; + ~font/ max; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/docker/templates/default.conf.template b/docker/templates/default.conf.template new file mode 100644 index 000000000..b68dfe2bb --- /dev/null +++ b/docker/templates/default.conf.template @@ -0,0 +1,71 @@ +server { + listen ${VIKUNJA_HTTP_PORT}; + listen [::]:${VIKUNJA_HTTP_PORT}; + ## Needed when behind HAProxy with SSL termination + HTTP/2 support + listen ${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol; + listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol; + + server_name _; + expires $expires; + root /usr/share/nginx/html; + access_log /dev/stdout ${VIKUNJA_LOG_FORMAT}; + # security headers + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; + add_header Permissions-Policy "interest-cohort=()" always; + + # . files + location ~ /\.(?!well-known) { + deny all; + } + + # assume that everything else is handled by the application router, by injecting the index.html. + location / { + autoindex off; + expires off; + add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always; + try_files $uri /index.html =404; + } + + # favicon.ico + location = /favicon.ico { + log_not_found off; + access_log off; + } + + # robots.txt + location = /robots.txt { + log_not_found off; + access_log off; + expires -1; # no-cache + } + + location = /ready { + return 200 ""; + access_log off; + expires -1; # no-cache + } + + # all assets contain hash in filename, cache forever + location ^~ /assets/ { + add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable"; + try_files $uri =404; + } + + # all workbox scripts are compiled with hash in filename, cache forever3 + location ^~ /workbox- { + add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable"; + try_files $uri =404; + } + + # assets, media + location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ { + try_files $uri $uri/ =404; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { } + +} diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index d2206bfec..000000000 --- a/nginx.conf +++ /dev/null @@ -1,117 +0,0 @@ -user nginx; -worker_processes 1; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - - -events { - worker_connections 1024; -} - - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - types { - application/manifest+json webmanifest; - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - #tcp_nopush on; - - keepalive_timeout 65; - - gzip on; - - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_buffers 16 8k; - gzip_http_version 1.1; - gzip_min_length 256; - gzip_types - text/plain - text/css - application/json - application/x-javascript - application/javascript - text/xml - application/xml - application/xml+rss - text/javascript - application/vnd.ms-fontobject - application/x-font-ttf - font/opentype - image/svg+xml - image/x-icon - audio/wav; - - map_hash_max_size 128; - map_hash_bucket_size 128; - - # Expires map - map $sent_http_content_type $expires { - default off; - text/css max; - application/javascript max; - text/javascript max; - application/vnd.ms-fontobject max; - application/x-font-ttf max; - font/opentype max; - font/woff2 max; - image/svg+xml max; - image/x-icon max; - audio/wav max; - ~images/ max; - ~font/ max; - } - - server { - listen 80; - listen [::]:80; - listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support - listen [::]:81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support - - server_name _; - - expires $expires; - - root /usr/share/nginx/html; - - # all assets contain hash in filename, cache forever - location ^~ /assets/ { - add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable"; - try_files $uri =404; - } - - # all workbox scripts are compiled with hash in filename, cache forever3 - location ^~ /workbox- { - add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable"; - try_files $uri =404; - } - - # assume that everything else is handled by the application router, by injecting the index.html. - location / { - autoindex off; - expires off; - add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always; - try_files $uri /index.html =404; - } - - location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ { - try_files $uri $uri/ =404; - } - - error_page 500 502 503 504 /50x.html; - location = /50x.html { - } - } -} diff --git a/scripts/run.sh b/scripts/run.sh deleted file mode 100755 index 40c4bc1cf..000000000 --- a/scripts/run.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -# This shell script sets the api url based on an environment variable and starts nginx in foreground. - -VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}" -VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}" -VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}" -VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}" -VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}" - -echo "Using $VIKUNJA_API_URL as default api url" - -# Escape the variable to prevent sed from complaining -VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g') - -sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well -sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html -sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html -sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html - -sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf -sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf - -# Set the uid and gid of the nginx run user -usermod --non-unique --uid ${PUID} nginx -groupmod --non-unique --gid ${PGID} nginx - -nginx -g "daemon off;"