From 21f66b9e6231a00227c754ce5008ce461a7846bc Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Mon, 13 Jul 2020 17:51:53 +0200
Subject: [PATCH] Use cookie based auth for shares with password

---
 api/routes/authenticate_media.go              |  8 ++-
 api/routes/videos.go                          |  1 -
 ui/src/Pages/SharePage/MediaSharePage.js      | 18 ++---
 ui/src/Pages/SharePage/SharePage.js           |  9 +--
 ui/src/authentication.js                      | 13 +++-
 ui/src/components/albumGallery/AlbumBox.js    |  2 +-
 ui/src/components/header/Searchbar.js         |  2 +-
 .../components/photoGallery/MediaThumbnail.js |  2 +-
 .../{ProtectedImage.js => ProtectedMedia.js}  | 71 ++++++++++---------
 .../photoGallery/presentView/PresentMedia.js  | 17 +----
 ui/src/components/sidebar/MediaSidebar.js     | 15 +---
 11 files changed, 75 insertions(+), 83 deletions(-)
 rename ui/src/components/photoGallery/{ProtectedImage.js => ProtectedMedia.js} (56%)

diff --git a/api/routes/authenticate_media.go b/api/routes/authenticate_media.go
index 7b707c9..0659ca0 100644
--- a/api/routes/authenticate_media.go
+++ b/api/routes/authenticate_media.go
@@ -2,6 +2,7 @@ package routes
 
 import (
 	"database/sql"
+	"fmt"
 	"net/http"
 
 	"github.com/viktorstrate/photoview/api/graphql/auth"
@@ -39,7 +40,12 @@ func authenticateMedia(media *models.Media, db *sql.DB, r *http.Request) (succes
 
 		// Validate share token password, if set
 		if shareToken.Password != nil {
-			tokenPassword := r.Header.Get("TokenPassword")
+			tokenPasswordCookie, err := r.Cookie(fmt.Sprintf("share-token-pw-%s", shareToken.Value))
+			if err != nil {
+				return false, "unauthorized", http.StatusForbidden, nil
+			}
+			// tokenPassword := r.Header.Get("TokenPassword")
+			tokenPassword := tokenPasswordCookie.Value
 
 			if err := bcrypt.CompareHashAndPassword([]byte(*shareToken.Password), []byte(tokenPassword)); err != nil {
 				if err == bcrypt.ErrMismatchedHashAndPassword {
diff --git a/api/routes/videos.go b/api/routes/videos.go
index 240cfc1..2cb27cd 100644
--- a/api/routes/videos.go
+++ b/api/routes/videos.go
@@ -37,7 +37,6 @@ func RegisterVideoRoutes(db *sql.DB, router *mux.Router) {
 			w.Write([]byte("internal server error"))
 		}
 
-		// TODO: Make sure user is authorized to access video
 		if success, response, status, err := authenticateMedia(media, db, r); !success {
 			if err != nil {
 				log.Printf("WARN: error authenticating video: %s\n", err)
diff --git a/ui/src/Pages/SharePage/MediaSharePage.js b/ui/src/Pages/SharePage/MediaSharePage.js
index 197fb25..cad90b6 100644
--- a/ui/src/Pages/SharePage/MediaSharePage.js
+++ b/ui/src/Pages/SharePage/MediaSharePage.js
@@ -2,7 +2,10 @@ import React, { useContext, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import Layout from '../../Layout'
-import ProtectedImage from '../../components/photoGallery/ProtectedImage'
+import {
+  ProtectedImage,
+  ProtectedVideo,
+} from '../../components/photoGallery/ProtectedMedia'
 import { SidebarContext } from '../../components/sidebar/Sidebar'
 import MediaSidebar from '../../components/sidebar/MediaSidebar'
 
@@ -12,7 +15,7 @@ const DisplayPhoto = styled(ProtectedImage)`
   object-fit: contain;
 `
 
-const DisplayVideo = styled.video`
+const DisplayVideo = styled(ProtectedVideo)`
   width: 100%;
   max-height: calc(80vh);
 `
@@ -29,16 +32,7 @@ const MediaView = ({ media }) => {
   }
 
   if (media.type == 'video') {
-    return (
-      <DisplayVideo
-        controls
-        key={media.id}
-        crossorigin="use-credentials"
-        poster={media.thumbnail.url}
-      >
-        <source src={media.videoWeb.url} type="video/mp4" />
-      </DisplayVideo>
-    )
+    return <DisplayVideo media={media} />
   }
 
   throw new Error(`Unsupported media type: ${media.type}`)
diff --git a/ui/src/Pages/SharePage/SharePage.js b/ui/src/Pages/SharePage/SharePage.js
index ebd3540..b52f104 100644
--- a/ui/src/Pages/SharePage/SharePage.js
+++ b/ui/src/Pages/SharePage/SharePage.js
@@ -16,6 +16,7 @@ import {
   Icon,
   Message,
 } from 'semantic-ui-react'
+import { saveSharePassword, getSharePassword } from '../../authentication'
 
 const shareTokenQuery = gql`
   query SharePageToken($token: String!, $password: String) {
@@ -97,7 +98,7 @@ const AuthorizedTokenRoute = ({ match }) => {
   const { loading, error, data } = useQuery(shareTokenQuery, {
     variables: {
       token,
-      password: sessionStorage.getItem(`share-token-pw-${token}`),
+      password: getSharePassword(token),
     },
   })
 
@@ -185,7 +186,7 @@ const TokenRoute = ({ match }) => {
     {
       variables: {
         token: match.params.token,
-        password: sessionStorage.getItem(`share-token-pw-${token}`),
+        password: getSharePassword(match.params.token),
       },
     }
   )
@@ -208,8 +209,8 @@ const TokenRoute = ({ match }) => {
       <ProtectedTokenEnterPassword
         match={match}
         refetchWithPassword={password => {
-          sessionStorage.setItem(`share-token-pw-${token}`, password)
-          refetch({ variables: { password: password } })
+          saveSharePassword(token, password)
+          refetch({ variables: { password } })
         }}
         loading={loading}
       />
diff --git a/ui/src/authentication.js b/ui/src/authentication.js
index bfa2c42..3a60643 100644
--- a/ui/src/authentication.js
+++ b/ui/src/authentication.js
@@ -5,10 +5,21 @@ export function saveTokenCookie(token) {
 }
 
 export function clearTokenCookie() {
-  document.cookie = 'auth-token= ;max-age=0'
+  document.cookie = 'auth-token= ;max-age=0 ;path=/ ;sameSite=Lax'
 }
 
 export function authToken() {
   const match = document.cookie.match(/auth-token=([\d\w]+)/)
   return match && match[1]
 }
+
+export function saveSharePassword(shareToken, password) {
+  document.cookie = `share-token-pw-${shareToken}=${password} ;path=/ ;sameSite=Lax`
+}
+
+export function getSharePassword(shareToken) {
+  const match = document.cookie.match(
+    `share-token-pw-${shareToken}=([\\d\\w]+)`
+  )
+  return match && match[1]
+}
diff --git a/ui/src/components/albumGallery/AlbumBox.js b/ui/src/components/albumGallery/AlbumBox.js
index 561a600..ce2b787 100644
--- a/ui/src/components/albumGallery/AlbumBox.js
+++ b/ui/src/components/albumGallery/AlbumBox.js
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import { Link } from 'react-router-dom'
-import ProtectedImage from '../photoGallery/ProtectedImage'
+import { ProtectedImage } from '../photoGallery/ProtectedMedia'
 
 const AlbumBoxLink = styled(Link)`
   width: 240px;
diff --git a/ui/src/components/header/Searchbar.js b/ui/src/components/header/Searchbar.js
index 3f1895d..ed55875 100644
--- a/ui/src/components/header/Searchbar.js
+++ b/ui/src/components/header/Searchbar.js
@@ -4,7 +4,7 @@ import styled from 'styled-components'
 import { useLazyQuery } from '@apollo/react-hooks'
 import gql from 'graphql-tag'
 import debounce from '../../debounce'
-import ProtectedImage from '../photoGallery/ProtectedImage'
+import { ProtectedImage } from '../photoGallery/ProtectedMedia'
 import { NavLink } from 'react-router-dom'
 
 const Container = styled.div`
diff --git a/ui/src/components/photoGallery/MediaThumbnail.js b/ui/src/components/photoGallery/MediaThumbnail.js
index c127983..db168b0 100644
--- a/ui/src/components/photoGallery/MediaThumbnail.js
+++ b/ui/src/components/photoGallery/MediaThumbnail.js
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import LazyLoad from 'react-lazyload'
 import { Icon } from 'semantic-ui-react'
-import ProtectedImage from './ProtectedImage'
+import { ProtectedImage } from './ProtectedMedia'
 
 const markFavoriteMutation = gql`
   mutation markMediaFavorite($mediaId: Int!, $favorite: Boolean!) {
diff --git a/ui/src/components/photoGallery/ProtectedImage.js b/ui/src/components/photoGallery/ProtectedMedia.js
similarity index 56%
rename from ui/src/components/photoGallery/ProtectedImage.js
rename to ui/src/components/photoGallery/ProtectedMedia.js
index a9194a8..6d41e2a 100644
--- a/ui/src/components/photoGallery/ProtectedImage.js
+++ b/ui/src/components/photoGallery/ProtectedMedia.js
@@ -1,43 +1,26 @@
-import React, { useEffect, useState } from 'react'
+import React from 'react'
 import PropTypes from 'prop-types'
 
-// let imageCache = {}
+const getProtectedUrl = url => {
+  const imgUrl = new URL(url)
 
-// export async function fetchProtectedImage(
-//   src,
-//   { signal, headers: customHeaders } = { signal: null, headers: null }
-// ) {
-//   if (src) {
-//     if (imageCache[src]) {
-//       return imageCache[src]
-//     }
+  if (localStorage.getItem('token') == null) {
+    // Get share token if not authorized
 
-//     let headers = {
-//       ...customHeaders,
-//     }
-//     if (localStorage.getItem('token')) {
-//       headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
-//     }
+    const tokenRegex = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
+    if (tokenRegex) {
+      const token = tokenRegex[1]
+      imgUrl.searchParams.set('token', token)
+    }
+  }
 
-//     let image = await fetch(src, {
-//       headers,
-//       signal,
-//     })
-
-//     image = await image.blob()
-//     const url = URL.createObjectURL(image)
-
-//     // eslint-disable-next-line require-atomic-updates
-//     imageCache[src] = url
-
-//     return url
-//   }
-// }
+  return imgUrl.href
+}
 
 /**
- * An image that needs a authorization header to load
+ * An image that needs authorization to load
  */
-const ProtectedImage = ({ src, ...props }) => {
+export const ProtectedImage = ({ src, ...props }) => {
   // const [imgSrc, setImgSrc] = useState(null)
 
   // useEffect(() => {
@@ -85,11 +68,29 @@ const ProtectedImage = ({ src, ...props }) => {
   //   }
   // }, [src])
 
-  return <img {...props} src={src} crossOrigin="use-credentials" />
+  return (
+    <img {...props} src={getProtectedUrl(src)} crossOrigin="use-credentials" />
+  )
 }
 
 ProtectedImage.propTypes = {
-  src: PropTypes.string,
+  src: PropTypes.string.isRequired,
 }
 
-export default ProtectedImage
+export const ProtectedVideo = ({ media, ...props }) => {
+  return (
+    <video
+      {...props}
+      controls
+      key={media.id}
+      crossOrigin="use-credentials"
+      poster={getProtectedUrl(media.thumbnail.url)}
+    >
+      <source src={getProtectedUrl(media.videoWeb.url)} type="video/mp4" />
+    </video>
+  )
+}
+
+ProtectedVideo.propTypes = {
+  media: PropTypes.object.isRequired,
+}
diff --git a/ui/src/components/photoGallery/presentView/PresentMedia.js b/ui/src/components/photoGallery/presentView/PresentMedia.js
index 4fa55e2..0001dfc 100644
--- a/ui/src/components/photoGallery/presentView/PresentMedia.js
+++ b/ui/src/components/photoGallery/presentView/PresentMedia.js
@@ -1,7 +1,7 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
-import ProtectedImage from '../ProtectedImage'
+import { ProtectedImage, ProtectedVideo } from '../ProtectedMedia'
 
 const StyledPhoto = styled(ProtectedImage)`
   position: absolute;
@@ -13,7 +13,7 @@ const StyledPhoto = styled(ProtectedImage)`
   object-position: center;
 `
 
-const StyledVideo = styled.video`
+const StyledVideo = styled(ProtectedVideo)`
   position: absolute;
   top: 0;
   left: 0;
@@ -39,18 +39,7 @@ const PresentMedia = ({ media, imageLoaded, ...otherProps }) => {
   }
 
   if (media.type == 'video') {
-    return (
-      <div {...otherProps}>
-        <StyledVideo
-          controls
-          key={media.id}
-          crossorigin="use-credentials"
-          poster={media.thumbnail.url}
-        >
-          <source src={media.videoWeb.url} type="video/mp4" />
-        </StyledVideo>
-      </div>
-    )
+    return <StyledVideo media={media} />
   }
 
   throw new Error(`Unknown media type '${media.type}'`)
diff --git a/ui/src/components/sidebar/MediaSidebar.js b/ui/src/components/sidebar/MediaSidebar.js
index da44bbe..5fd1c64 100644
--- a/ui/src/components/sidebar/MediaSidebar.js
+++ b/ui/src/components/sidebar/MediaSidebar.js
@@ -4,7 +4,7 @@ import styled from 'styled-components'
 import { useLazyQuery } from 'react-apollo'
 import gql from 'graphql-tag'
 import SidebarItem from './SidebarItem'
-import ProtectedImage from '../photoGallery/ProtectedImage'
+import { ProtectedImage, ProtectedVideo } from '../photoGallery/ProtectedMedia'
 import SidebarShare from './Sharing'
 import SidebarDownload from './SidebarDownload'
 import { authToken } from '../../authentication'
@@ -73,7 +73,7 @@ const PreviewImage = styled(ProtectedImage)`
   object-fit: contain;
 `
 
-const PreviewVideo = styled.video`
+const PreviewVideo = styled(ProtectedVideo)`
   position: absolute;
   width: 100%;
   height: 100%;
@@ -87,16 +87,7 @@ const PreviewMedia = ({ media, previewImage }) => {
   }
 
   if (media.type == 'video') {
-    return (
-      <PreviewVideo
-        controls
-        key={media.id}
-        crossorigin="use-credentials"
-        poster={media.thumbnail.url}
-      >
-        <source src={media.videoWeb.url} type="video/mp4" />
-      </PreviewVideo>
-    )
+    return <PreviewVideo media={media} />
   }
 
   throw new Error('Unknown media type')
