From 4661bae48d93253a25d3d3eccc40ac7f0348773e Mon Sep 17 00:00:00 2001
From: Brandon Davis <git@subdavis.com>
Date: Sat, 29 Feb 2020 20:06:18 -0500
Subject: [PATCH] One dockerfile to rule them all

---
 Dockerfile                      | 34 ++++++++++++++++++++
 api/Dockerfile                  |  9 ------
 api/server.go                   | 55 ++++++++++++++++++++++++++++++---
 docker-compose.example.yml      | 36 +++------------------
 docker/nginx-proxy/default.conf | 36 ---------------------
 ui/Dockerfile                   | 22 -------------
 ui/docker-nginx.conf            |  9 ------
 ui/package.json                 |  6 +---
 8 files changed, 91 insertions(+), 116 deletions(-)
 create mode 100644 Dockerfile
 delete mode 100644 api/Dockerfile
 delete mode 100644 docker/nginx-proxy/default.conf
 delete mode 100644 ui/Dockerfile
 delete mode 100644 ui/docker-nginx.conf

diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..712ac81
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# Build UI
+FROM node:10 as ui
+
+ARG GRAPHQL_ENDPOINT
+ENV GRAPHQL_ENDPOINT=${GRAPHQL_ENDPOINT}
+
+RUN mkdir -p /app
+WORKDIR /app
+
+COPY ui/package*.json ./
+RUN npm install
+COPY ui /app
+
+RUN npm run build
+
+# Build API
+FROM golang:alpine AS api
+
+WORKDIR /app
+COPY api /app
+
+RUN go get -d -v ./...
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o photoview .
+
+# Copy built app to nginx environment
+FROM alpine:latest
+
+COPY --from=ui /app/dist /ui
+COPY --from=api /app/database/migrations /database/migrations
+COPY --from=api /app/photoview /app/photoview
+
+EXPOSE 80
+
+ENTRYPOINT ["/app/photoview"]
diff --git a/api/Dockerfile b/api/Dockerfile
deleted file mode 100644
index f9e4db8..0000000
--- a/api/Dockerfile
+++ /dev/null
@@ -1,9 +0,0 @@
-FROM golang:latest
-
-WORKDIR /app
-COPY . .
-
-RUN go get -d -v ./...
-RUN go build -o photoview .
-
-CMD ["/app/photoview"]
\ No newline at end of file
diff --git a/api/server.go b/api/server.go
index 6493836..788f7be 100644
--- a/api/server.go
+++ b/api/server.go
@@ -6,6 +6,7 @@ import (
 	"net/url"
 	"os"
 	"path"
+	"path/filepath"
 
 	"github.com/gorilla/mux"
 
@@ -23,6 +24,49 @@ import (
 
 const defaultPort = "4001"
 
+// spaHandler implements the http.Handler interface, so we can use it
+// to respond to HTTP requests. The path to the static directory and
+// path to the index file within that static directory are used to
+// serve the SPA in the given static directory.
+type spaHandler struct {
+	staticPath string
+	indexPath  string
+}
+
+// ServeHTTP inspects the URL path to locate a file within the static dir
+// on the SPA handler. If a file is found, it will be served. If not, the
+// file located at the index path on the SPA handler will be served. This
+// is suitable behavior for serving an SPA (single page application).
+func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// get the absolute path to prevent directory traversal
+	path, err := filepath.Abs(r.URL.Path)
+	if err != nil {
+		// if we failed to get the absolute path respond with a 400 bad request
+		// and stop
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// prepend the path with the path to the static directory
+	path = filepath.Join(h.staticPath, path)
+
+	// check whether a file exists at the given path
+	_, err = os.Stat(path)
+	if os.IsNotExist(err) {
+		// file does not exist, serve index.html
+		http.ServeFile(w, r, filepath.Join(h.staticPath, h.indexPath))
+		return
+	} else if err != nil {
+		// if we got an error (that wasn't that the file doesn't exist) stating the
+		// file, return a 500 internal server error and stop
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// otherwise, use http.FileServer to serve the static dir
+	http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
+}
+
 func main() {
 
 	if err := godotenv.Load(); err != nil {
@@ -68,14 +112,14 @@ func main() {
 	endpointRouter := rootRouter.PathPrefix(endpointURL.Path).Subrouter()
 
 	if devMode {
-		endpointRouter.Handle("/", handler.Playground("GraphQL playground", path.Join(endpointURL.Path, "/graphql")))
+		endpointRouter.Handle("/api", handler.Playground("GraphQL playground", path.Join(endpointURL.Path, "/graphql")))
 	} else {
-		endpointRouter.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
+		endpointRouter.HandleFunc("/api", func(w http.ResponseWriter, req *http.Request) {
 			w.Write([]byte("photoview api endpoint"))
 		})
 	}
 
-	endpointRouter.Handle("/graphql",
+	endpointRouter.Handle("/api/graphql",
 		handler.GraphQL(photoview_graphql.NewExecutableSchema(graphqlConfig),
 			handler.IntrospectionEnabled(devMode),
 			handler.WebsocketUpgrader(server.WebsocketUpgrader(devMode)),
@@ -83,9 +127,12 @@ func main() {
 		),
 	)
 
-	photoRouter := endpointRouter.PathPrefix("/photo").Subrouter()
+	photoRouter := endpointRouter.PathPrefix("/api/photo").Subrouter()
 	routes.RegisterPhotoRoutes(db, photoRouter)
 
+	spa := spaHandler{staticPath: "/ui", indexPath: "index.html"}
+	endpointRouter.PathPrefix("/").Handler(spa)
+
 	if devMode {
 		log.Printf("🚀 Graphql playground ready at %s", endpointURL.String())
 	} else {
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index 107d9ae..72bd31f 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -12,11 +12,11 @@ services:
     volumes:
       - db_data:/var/lib/mysql
 
-  api:
-    build: ./api
+  photoview:
+    build: .
     restart: always
-    expose:
-      - 80
+    ports:
+      - "8000:80"
     depends_on:
       - db
     environment:
@@ -25,7 +25,7 @@ services:
       # Change This: The publicly exposed url for the api
       # For example if the server is available from the domain example.com,
       # change this value to http://example.com/api
-      - API_ENDPOINT=http://localhost:8080/api
+      - API_ENDPOINT=http://localhost:8080/
       - PUBLIC_ENDPOINT=http://localhost:8080/
       - API_LISTEN_PORT=80
     volumes:
@@ -37,32 +37,6 @@ services:
       - ./photos_path:/photos:ro
       - api_cache:/app/cache
 
-  ui:
-    build:
-      context: ./ui
-      args:
-        # Change This: The publicly exposed url for the graphql api
-        # For example if the server is available from the domain example.com,
-        # change this value to http://example.com/api/graphql
-        GRAPHQL_ENDPOINT: http://localhost:8080/api/graphql
-    restart: always
-    expose:
-      - 80
-    depends_on:
-      - api
-
-  proxy:
-    image: nginx
-    restart: always
-    volumes:
-      - ./docker/nginx-proxy/default.conf:/etc/nginx/conf.d/default.conf
-    ports:
-      # Change This: Replace 8080 with the port you want photoview to be accessible at
-      - 8080:80
-    depends_on:
-      - api
-      - ui
-
 volumes:
   db_data:
   api_cache:
diff --git a/docker/nginx-proxy/default.conf b/docker/nginx-proxy/default.conf
deleted file mode 100644
index 37b3571..0000000
--- a/docker/nginx-proxy/default.conf
+++ /dev/null
@@ -1,36 +0,0 @@
-server {
-  listen 80 default_server;
-
-  location /api {
-      proxy_pass http://api;
-      proxy_set_header Host $host;
-      proxy_set_header X-Real-IP $remote_addr;
-      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-      proxy_set_header X-Forwarded-Proto $scheme;
-
-      # Required for Websocket
-      proxy_set_header Upgrade $http_upgrade;
-      proxy_set_header Connection "upgrade";
-
-      #add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
-      client_max_body_size 0;
-
-      access_log /var/log/nginx/photoview-api.access.log;
-      error_log /var/log/nginx/photoview-api.error.log;
-  }
-
-  location / {
-        proxy_pass http://ui;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-
-        #add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
-        client_max_body_size 0;
-
-        access_log /var/log/nginx/photoview-ui.access.log;
-        error_log /var/log/nginx/photoview-ui.error.log;
-    }
-
-}
\ No newline at end of file
diff --git a/ui/Dockerfile b/ui/Dockerfile
deleted file mode 100644
index bf6b65f..0000000
--- a/ui/Dockerfile
+++ /dev/null
@@ -1,22 +0,0 @@
-# Build the app
-FROM node:10
-
-ARG GRAPHQL_ENDPOINT
-ENV GRAPHQL_ENDPOINT=${GRAPHQL_ENDPOINT}
-
-RUN mkdir -p /app
-WORKDIR /app
-
-COPY package*.json ./
-RUN npm install
-COPY . .
-
-RUN npm run build
-
-# Copy built app to nginx environment
-FROM nginx:stable
-
-COPY --from=0 /app/dist /usr/share/nginx/html
-COPY ./docker-nginx.conf /etc/nginx/conf.d/default.conf
-
-EXPOSE 80
\ No newline at end of file
diff --git a/ui/docker-nginx.conf b/ui/docker-nginx.conf
deleted file mode 100644
index 2ff0b30..0000000
--- a/ui/docker-nginx.conf
+++ /dev/null
@@ -1,9 +0,0 @@
-server {
-	listen 80 default_server;
-
-	root /usr/share/nginx/html;
-
-	location / {
-		try_files $uri /index.html;
-	}
-}
\ No newline at end of file
diff --git a/ui/package.json b/ui/package.json
index b4e09ca..d05fd36 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -53,11 +53,7 @@
     "react-router-prop-types": "^1.0.4"
   },
   "cache": {
-    "swDest": "service-worker.js",
-    "modifyURLPrefix": {
-      "/": "/ui",
-      "": "/ui/"
-    }
+    "swDest": "service-worker.js"
   },
   "husky": {
     "hooks": {
