From e00a5553f7085908f640cb4d3155b173c56e557d Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 21 Feb 2020 22:42:39 +0100 Subject: [PATCH] Add download support --- api/graphql/generated.go | 106 +++++++++++- api/graphql/models/generated.go | 6 +- api/graphql/models/photo.go | 14 ++ api/graphql/resolvers/photo.go | 33 +++- api/graphql/schema.graphql | 2 + api/server/cors_middleware.go | 4 +- ui/src/Pages/SharePage/SharePage.js | 6 + ui/src/apolloClient.js | 2 +- ui/src/components/messages/Messages.js | 26 ++- .../components/messages/SubscriptionsHook.js | 32 +--- .../components/photoGallery/ProtectedImage.js | 1 - ui/src/components/sidebar/PhotoSidebar.js | 2 +- ui/src/components/sidebar/SidebarDownload.js | 151 +++++++++++++++--- 13 files changed, 313 insertions(+), 72 deletions(-) diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 2f55bff5..c6aafd1a 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -106,8 +106,10 @@ type ComplexityRoot struct { } PhotoDownload struct { - Title func(childComplexity int) int - URL func(childComplexity int) int + Height func(childComplexity int) int + Title func(childComplexity int) int + URL func(childComplexity int) int + Width func(childComplexity int) int } PhotoExif struct { @@ -571,6 +573,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Photo.Title(childComplexity), true + case "PhotoDownload.height": + if e.complexity.PhotoDownload.Height == nil { + break + } + + return e.complexity.PhotoDownload.Height(childComplexity), true + case "PhotoDownload.title": if e.complexity.PhotoDownload.Title == nil { break @@ -585,6 +594,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.PhotoDownload.URL(childComplexity), true + case "PhotoDownload.width": + if e.complexity.PhotoDownload.Width == nil { + break + } + + return e.complexity.PhotoDownload.Width(childComplexity), true + case "PhotoEXIF.aperture": if e.complexity.PhotoExif.Aperture == nil { break @@ -1151,6 +1167,8 @@ type PhotoURL { type PhotoDownload { title: String! + width: Int! + height: Int! url: String! } @@ -3252,6 +3270,80 @@ func (ec *executionContext) _PhotoDownload_title(ctx context.Context, field grap return ec.marshalNString2string(ctx, field.Selections, res) } +func (ec *executionContext) _PhotoDownload_width(ctx context.Context, field graphql.CollectedField, obj *models.PhotoDownload) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "PhotoDownload", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Width, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _PhotoDownload_height(ctx context.Context, field graphql.CollectedField, obj *models.PhotoDownload) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "PhotoDownload", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Height, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNInt2int(ctx, field.Selections, res) +} + func (ec *executionContext) _PhotoDownload_url(ctx context.Context, field graphql.CollectedField, obj *models.PhotoDownload) (ret graphql.Marshaler) { ctx = ec.Tracer.StartFieldExecution(ctx, field) defer func() { @@ -6402,6 +6494,16 @@ func (ec *executionContext) _PhotoDownload(ctx context.Context, sel ast.Selectio if out.Values[i] == graphql.Null { invalids++ } + case "width": + out.Values[i] = ec._PhotoDownload_width(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "height": + out.Values[i] = ec._PhotoDownload_height(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } case "url": out.Values[i] = ec._PhotoDownload_url(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go index 0b68552a..2f54781e 100644 --- a/api/graphql/models/generated.go +++ b/api/graphql/models/generated.go @@ -33,8 +33,10 @@ type Notification struct { } type PhotoDownload struct { - Title string `json:"title"` - URL string `json:"url"` + Title string `json:"title"` + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` } // EXIF metadata from the camera diff --git a/api/graphql/models/photo.go b/api/graphql/models/photo.go index e8c1bde8..b502552a 100644 --- a/api/graphql/models/photo.go +++ b/api/graphql/models/photo.go @@ -79,3 +79,17 @@ func NewPhotoURLFromRow(row *sql.Row) (*PhotoURL, error) { return &url, nil } + +func NewPhotoURLFromRows(rows *sql.Rows) ([]*PhotoURL, error) { + urls := make([]*PhotoURL, 0) + + for rows.Next() { + var url PhotoURL + if err := rows.Scan(&url.UrlID, &url.PhotoId, &url.PhotoName, &url.Width, &url.Height, &url.Purpose, &url.ContentType); err != nil { + return nil, err + } + urls = append(urls, &url) + } + + return urls, nil +} diff --git a/api/graphql/resolvers/photo.go b/api/graphql/resolvers/photo.go index 44441672..31238701 100644 --- a/api/graphql/resolvers/photo.go +++ b/api/graphql/resolvers/photo.go @@ -69,8 +69,39 @@ func (r *photoResolver) Shares(ctx context.Context, obj *models.Photo) ([]*model } func (r *photoResolver) Downloads(ctx context.Context, obj *models.Photo) ([]*models.PhotoDownload, error) { - log.Println("Photo: downloads not implemented") + + rows, err := r.Database.Query("SELECT * FROM photo_url WHERE photo_id = ?", obj.PhotoID) + if err != nil { + return nil, err + } + + photoUrls, err := models.NewPhotoURLFromRows(rows) + if err != nil { + return nil, err + } + downloads := make([]*models.PhotoDownload, 0) + + for _, url := range photoUrls { + + var title string + switch { + case url.Purpose == models.PhotoOriginal: + title = "Original" + case url.Purpose == models.PhotoThumbnail: + title = "Small" + case url.Purpose == models.PhotoHighRes: + title = "Large" + } + + downloads = append(downloads, &models.PhotoDownload{ + Title: title, + Width: url.Width, + Height: url.Height, + URL: url.URL(), + }) + } + return downloads, nil } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 92d9804c..81b6e83b 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -177,6 +177,8 @@ type PhotoURL { type PhotoDownload { title: String! + width: Int! + height: Int! url: String! } diff --git a/api/server/cors_middleware.go b/api/server/cors_middleware.go index 39fa9c43..e131b0d5 100644 --- a/api/server/cors_middleware.go +++ b/api/server/cors_middleware.go @@ -14,11 +14,13 @@ import ( func CORSMiddleware(devMode bool) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + methods := []string{http.MethodGet, http.MethodPost, http.MethodOptions} - headers := []string{"authorization", "content-type"} + headers := []string{"authorization", "content-type", "content-length"} w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ",")) w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ",")) + w.Header().Set("Access-Control-Expose-Headers", "content-length") endpoint, err := url.Parse(os.Getenv("API_ENDPOINT")) if err != nil { diff --git a/ui/src/Pages/SharePage/SharePage.js b/ui/src/Pages/SharePage/SharePage.js index 5f600586..1b4c228a 100644 --- a/ui/src/Pages/SharePage/SharePage.js +++ b/ui/src/Pages/SharePage/SharePage.js @@ -46,6 +46,12 @@ const tokenQuery = gql` width height } + downloads { + title + url + width + height + } highRes { url } diff --git a/ui/src/apolloClient.js b/ui/src/apolloClient.js index ccbc25cf..d8b73c63 100644 --- a/ui/src/apolloClient.js +++ b/ui/src/apolloClient.js @@ -59,7 +59,7 @@ const linkError = onError(({ graphQLErrors, networkError }) => { if (graphQLErrors.length == 1) { errorMessages.push({ header: 'Something went wrong', - content: graphQLErrors[0].message, + content: `Server error: ${graphQLErrors[0].message} at (${graphQLErrors[0].path})`, }) } else if (graphQLErrors.length > 1) { errorMessages.push({ diff --git a/ui/src/components/messages/Messages.js b/ui/src/components/messages/Messages.js index 62a73bd9..38a8be88 100644 --- a/ui/src/components/messages/Messages.js +++ b/ui/src/components/messages/Messages.js @@ -17,17 +17,22 @@ export let MessageState = { set: null, get: null, add: message => { - MessageState.set(messages => [...messages, message]) + MessageState.set(messages => { + const newMessages = messages.filter(msg => msg.key != message.key) + newMessages.push(message) + + return newMessages + }) + }, + removeKey: key => { + MessageState.set(messages => { + const newMessages = messages.filter(msg => msg.key != key) + return newMessages + }) }, } const Messages = () => { - if (!localStorage.getItem('token')) { - return null - } - - console.log('Rendering messages') - const [messages, setMessages] = useState([]) MessageState.set = setMessages MessageState.get = messages @@ -76,7 +81,6 @@ const Messages = () => { resolveFunc = resolve }) - console.log(resolveFunc, waitPromise) refHooks.set(message.key, { done: resolveFunc, promise: waitPromise, @@ -89,13 +93,8 @@ const Messages = () => { height: '0px', }, enter: item => async next => { - console.log('HERE', refMap, item) - const refPromise = refHooks.get(item.key).promise - console.log('promise', refPromise) - await refPromise - console.log('AFTER PROMISE', refMap, item) await next({ opacity: 1, @@ -109,7 +108,6 @@ const Messages = () => { {transitions.map(({ item, props: style, key }) => { const getRef = ref => { - console.log('GET REF', refMap, refHooks, item.key) refMap.set(item, ref) if (refHooks.has(item.key)) { refHooks.get(item.key).done() diff --git a/ui/src/components/messages/SubscriptionsHook.js b/ui/src/components/messages/SubscriptionsHook.js index 7292372d..beffa8f3 100644 --- a/ui/src/components/messages/SubscriptionsHook.js +++ b/ui/src/components/messages/SubscriptionsHook.js @@ -18,6 +18,10 @@ const notificationSubscription = gql` ` const SubscriptionsHook = ({ messages, setMessages }) => { + if (!localStorage.getItem('token')) { + return null + } + const { data, error } = useSubscription(notificationSubscription) useEffect(() => { @@ -62,34 +66,6 @@ const SubscriptionsHook = ({ messages, setMessages }) => { newMessages.push(newNotification) } - // const update = data.scannerStatusUpdate - - // if (update.success) { - // newMessages[0] = { - // key: 'primary', - // type: 'progress', - // props: { - // header: update.finished ? 'Synced' : 'Syncing', - // content: update.message, - // percent: update.progress, - // positive: update.finished, - // }, - // } - - // if (!update.finished) newMessages[0].props.onDismiss = null - // } else { - // const key = Math.random().toString(26) - // newMessages.push({ - // key, - // type: 'message', - // props: { - // header: 'Sync error', - // content: update.message, - // negative: true, - // }, - // }) - // } - setMessages(newMessages) }, [data, error]) diff --git a/ui/src/components/photoGallery/ProtectedImage.js b/ui/src/components/photoGallery/ProtectedImage.js index dcb089e3..2111a8e5 100644 --- a/ui/src/components/photoGallery/ProtectedImage.js +++ b/ui/src/components/photoGallery/ProtectedImage.js @@ -48,7 +48,6 @@ const ProtectedImage = ({ src, ...props }) => { // Get share token if not authorized const token = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/) - console.log('share token', location.pathname, token) if (token) { imgUrl.searchParams.set('token', token[1]) } diff --git a/ui/src/components/sidebar/PhotoSidebar.js b/ui/src/components/sidebar/PhotoSidebar.js index 614accff..394d4c1e 100644 --- a/ui/src/components/sidebar/PhotoSidebar.js +++ b/ui/src/components/sidebar/PhotoSidebar.js @@ -93,7 +93,7 @@ const SidebarContent = ({ photo, hidePreview }) => { {!hidePreview && } {photo && photo.title}
{exifItems}
- + ) diff --git a/ui/src/components/sidebar/SidebarDownload.js b/ui/src/components/sidebar/SidebarDownload.js index 868796e0..1574e760 100644 --- a/ui/src/components/sidebar/SidebarDownload.js +++ b/ui/src/components/sidebar/SidebarDownload.js @@ -1,7 +1,8 @@ import React from 'react' import PropTypes from 'prop-types' import { Menu, Dropdown, Button } from 'semantic-ui-react' -import { Query } from 'react-apollo' +import { MessageState } from '../messages/Messages' +import { Query, useLazyQuery } from 'react-apollo' import gql from 'graphql-tag' import download from 'downloadjs' @@ -12,49 +13,157 @@ const downloadQuery = gql` downloads { title url + width + height } } } ` +function formatBytes(bytes) { + var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + if (bytes == 0) return '0 Byte' + var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))) + return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i] +} + const downloadPhoto = async url => { - const request = await fetch(url, { - headers: { - Authorization: `Bearer ${localStorage.getItem('token')}`, + const imgUrl = new URL(url) + let headers = { + Authorization: `Bearer ${localStorage.getItem('token')}`, + } + + if (localStorage.getItem('token') == null) { + // Get share token if not authorized + const token = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/) + if (token) { + imgUrl.searchParams.set('token', token[1]) + } + + headers = {} + } + + const response = await fetch(imgUrl.href, { + headers, + }) + + const totalBytes = Number(response.headers.get('content-length')) + console.log(totalBytes) + + if (totalBytes == 0) { + MessageState.add({ + key: Math.random().toString(26), + type: 'message', + props: { + header: 'Error downloading photo', + content: `Could not get size of photo from server`, + negative: true, + }, + }) + return + } + + const notifKey = Math.random().toString(26) + MessageState.add({ + key: notifKey, + type: 'progress', + props: { + header: 'Downloading photo', + content: `Starting download`, + progress: 0, }, }) - const content = await request.blob() + const reader = response.body.getReader() + let data = new Uint8Array(totalBytes) + + let receivedBytes = 0 + let result + do { + result = await reader.read() + + if (result.value) data.set(result.value, receivedBytes) + + receivedBytes += result.value ? result.value.length : 0 + + MessageState.add({ + key: notifKey, + type: 'progress', + props: { + header: 'Downloading photo', + percent: (receivedBytes / totalBytes) * 100, + content: `${formatBytes(receivedBytes)} of ${formatBytes( + totalBytes + )} bytes downloaded`, + }, + }) + } while (!result.done) + + MessageState.add({ + key: notifKey, + type: 'progress', + props: { + header: 'Downloading photo completed', + content: `The photo has been downloaded`, + percent: 100, + positive: true, + }, + }) + + setTimeout(() => { + MessageState.removeKey(notifKey) + }, 2000) + + const content = new Blob([data.buffer], { + type: response.headers.get('content-type'), + }) const filename = url.match(/[^/]*$/)[0] download(content, filename) } -const SidebarDownload = ({ photoId }) => { - if (!photoId) return null +const SidebarDownload = ({ photo }) => { + if (!photo || !photo.id) return null + + const [ + loadPhotoDownloads, + { called, loading, data }, + ] = useLazyQuery(downloadQuery, { variables: { photoId: photo.id } }) + + let downloads = [] + + if (called) { + if (!loading) { + downloads = data && data.photo.downloads + } + } else { + if (!photo.downloads) { + loadPhotoDownloads() + } else { + downloads = photo.downloads + } + } + + let buttons = downloads.map(x => ( + + )) return (

Download

- - {({ loading, error, data }) => { - if (error) return
Error {error.message}
- if (!data || !data.photo) return null - - let buttons = data.photo.downloads.map(x => ( - - )) - return {buttons} - }} -
+
{buttons}
) } SidebarDownload.propTypes = { - photoId: PropTypes.number, + photo: PropTypes.object, } export default SidebarDownload