Upload Image from url (#1193)

This commit is contained in:
WithoutPants 2021-03-11 12:56:34 +11:00 committed by GitHub
parent b3966b3c76
commit 55aee21cff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 205 additions and 26 deletions

View File

@ -26,7 +26,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
// Process the base 64 encoded image string // Process the base 64 encoded image string
if input.FrontImage != nil { if input.FrontImage != nil {
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage) frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -34,7 +34,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
// Process the base 64 encoded image string // Process the base 64 encoded image string
if input.BackImage != nil { if input.BackImage != nil {
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage) backimageData, err = utils.ProcessImageInput(*input.BackImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -126,7 +126,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
var frontimageData []byte var frontimageData []byte
frontImageIncluded := translator.hasField("front_image") frontImageIncluded := translator.hasField("front_image")
if input.FrontImage != nil { if input.FrontImage != nil {
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage) frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -134,7 +134,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
backImageIncluded := translator.hasField("back_image") backImageIncluded := translator.hasField("back_image")
var backimageData []byte var backimageData []byte
if input.BackImage != nil { if input.BackImage != nil {
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage) backimageData, err = utils.ProcessImageInput(*input.BackImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -189,7 +189,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
// HACK - if front image is null and back image is not null, then set the front image // HACK - if front image is null and back image is not null, then set the front image
// to the default image since we can't have a null front image and a non-null back image // to the default image since we can't have a null front image and a non-null back image
if frontimageData == nil && backimageData != nil { if frontimageData == nil && backimageData != nil {
_, frontimageData, _ = utils.ProcessBase64Image(models.DefaultMovieImage) frontimageData, _ = utils.ProcessImageInput(models.DefaultMovieImage)
} }
if err := qb.UpdateImages(movie.ID, frontimageData, backimageData); err != nil { if err := qb.UpdateImages(movie.ID, frontimageData, backimageData); err != nil {

View File

@ -18,7 +18,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
var err error var err error
if input.Image != nil { if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image) imageData, err = utils.ProcessImageInput(*input.Image)
} }
if err != nil { if err != nil {
@ -139,7 +139,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
var err error var err error
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
if input.Image != nil { if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image) imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -80,7 +80,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
if input.CoverImage != nil && *input.CoverImage != "" { if input.CoverImage != nil && *input.CoverImage != "" {
var err error var err error
_, coverImageData, err = utils.ProcessBase64Image(*input.CoverImage) coverImageData, err = utils.ProcessImageInput(*input.CoverImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -20,7 +20,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
// Process the base 64 encoded image string // Process the base 64 encoded image string
if input.Image != nil { if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image) imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -96,7 +96,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
if input.Image != nil { if input.Image != nil {
var err error var err error
_, imageData, err = utils.ProcessBase64Image(*input.Image) imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -24,7 +24,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
var err error var err error
if input.Image != nil { if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image) imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil { if err != nil {
return nil, err return nil, err
@ -82,7 +82,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
if input.Image != nil { if input.Image != nil {
_, imageData, err = utils.ProcessBase64Image(*input.Image) imageData, err = utils.ProcessImageInput(*input.Image)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -4,11 +4,66 @@ import (
"crypto/md5" "crypto/md5"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
"time"
) )
// Timeout to get the image. Includes transfer time. May want to make this
// configurable at some point.
const imageGetTimeout = time.Second * 60
const base64RE = `^data:.+\/(.+);base64,(.*)$`
// ProcessImageInput transforms an image string either from a base64 encoded
// string, or from a URL, and returns the image as a byte slice
func ProcessImageInput(imageInput string) ([]byte, error) {
regex := regexp.MustCompile(base64RE)
if regex.MatchString(imageInput) {
_, d, err := ProcessBase64Image(imageInput)
return d, err
}
// assume input is a URL. Read it.
return ReadImageFromURL(imageInput)
}
// ReadImageFromURL returns image data from a URL
func ReadImageFromURL(url string) ([]byte, error) {
client := &http.Client{
Timeout: imageGetTimeout,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
// assume is a URL for now
// set the host of the URL as the referer
if req.URL.Scheme != "" {
req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/")
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
// ProcessBase64Image transforms a base64 encoded string from a form post and returns the MD5 hash of the data and the // ProcessBase64Image transforms a base64 encoded string from a form post and returns the MD5 hash of the data and the
// image itself as a byte slice. // image itself as a byte slice.
func ProcessBase64Image(imageString string) (string, []byte, error) { func ProcessBase64Image(imageString string) (string, []byte, error) {
@ -16,7 +71,7 @@ func ProcessBase64Image(imageString string) (string, []byte, error) {
return "", nil, fmt.Errorf("empty image string") return "", nil, fmt.Errorf("empty image string")
} }
regex := regexp.MustCompile(`^data:.+\/(.+);base64,(.*)$`) regex := regexp.MustCompile(base64RE)
matches := regex.FindStringSubmatch(imageString) matches := regex.FindStringSubmatch(imageString)
var encodedString string var encodedString string
if len(matches) > 2 { if len(matches) > 2 {

View File

@ -2,6 +2,7 @@
* Added Performer tags. * Added Performer tags.
### 🎨 Improvements ### 🎨 Improvements
* Allow scene/performer/studio image upload via URL.
* Add button to hide unmatched scenes in Tagger view. * Add button to hide unmatched scenes in Tagger view.
* Hide create option in dropdowns when searching in filters. * Hide create option in dropdowns when searching in filters.
* Add scrape gallery from fragment to UI * Add scrape gallery from fragment to UI

View File

@ -571,8 +571,10 @@ export const Movie: React.FC = () => {
onToggleEdit={onToggleEdit} onToggleEdit={onToggleEdit}
onSave={onSave} onSave={onSave}
onImageChange={onFrontImageChange} onImageChange={onFrontImageChange}
onImageChangeURL={onFrontImageLoad}
onClearImage={onClearFrontImage} onClearImage={onClearFrontImage}
onBackImageChange={onBackImageChange} onBackImageChange={onBackImageChange}
onBackImageChangeURL={onBackImageLoad}
onClearBackImage={onClearBackImage} onClearBackImage={onClearBackImage}
onDelete={onDelete} onDelete={onDelete}
/> />

View File

@ -418,6 +418,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
function onImageChangeURL(url: string) {
formik.setFieldValue("image", url);
}
function onDisplayScrapeDialog(scraper: GQL.Scraper) { function onDisplayScrapeDialog(scraper: GQL.Scraper) {
setIsDisplayingScraperDialog(scraper); setIsDisplayingScraperDialog(scraper);
} }
@ -533,7 +537,12 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
); );
return ( return (
<OverlayTrigger trigger="click" placement="top" overlay={popover}> <OverlayTrigger
trigger="click"
placement="top"
overlay={popover}
rootClose
>
<Button variant="secondary" className="mr-2"> <Button variant="secondary" className="mr-2">
Scrape with... Scrape with...
</Button> </Button>
@ -636,7 +645,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
"" ""
)} )}
{renderScraperMenu()} {renderScraperMenu()}
<ImageInput isEditing onImageChange={onImageChangeHandler} /> <ImageInput
isEditing
onImageChange={onImageChangeHandler}
onImageURL={onImageChangeURL}
/>
<Button <Button
className="mx-2" className="mx-2"
variant="danger" variant="danger"

View File

@ -723,7 +723,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
alt="Scene cover" alt="Scene cover"
/> />
)} )}
<ImageInput isEditing onImageChange={onCoverImageChange} /> <ImageInput
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
/>
</Form.Group> </Form.Group>
</div> </div>
</div> </div>

View File

@ -12,6 +12,8 @@ interface IProps {
onAutoTag?: () => void; onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void; onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void; onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
onImageChangeURL?: (url: string) => void;
onBackImageChangeURL?: (url: string) => void;
onClearImage?: () => void; onClearImage?: () => void;
onClearBackImage?: () => void; onClearBackImage?: () => void;
acceptSVG?: boolean; acceptSVG?: boolean;
@ -65,6 +67,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
isEditing={props.isEditing} isEditing={props.isEditing}
text="Back image..." text="Back image..."
onImageChange={props.onBackImageChange} onImageChange={props.onBackImageChange}
onImageURL={props.onBackImageChangeURL}
/> />
); );
} }
@ -116,6 +119,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
isEditing={props.isEditing} isEditing={props.isEditing}
text={props.onBackImageChange ? "Front image..." : undefined} text={props.onBackImageChange ? "Front image..." : undefined}
onImageChange={props.onImageChange} onImageChange={props.onImageChange}
onImageURL={props.onImageChangeURL}
acceptSVG={props.acceptSVG ?? false} acceptSVG={props.acceptSVG ?? false}
/> />
{props.isEditing && props.onClearImage ? ( {props.isEditing && props.onClearImage ? (

View File

@ -1,10 +1,20 @@
import React from "react"; import React, { useState } from "react";
import { Button, Form } from "react-bootstrap"; import {
Button,
Col,
Form,
OverlayTrigger,
Popover,
Row,
} from "react-bootstrap";
import { Modal } from ".";
import Icon from "./Icon";
interface IImageInput { interface IImageInput {
isEditing: boolean; isEditing: boolean;
text?: string; text?: string;
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void; onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onImageURL?: (url: string) => void;
acceptSVG?: boolean; acceptSVG?: boolean;
} }
@ -12,18 +22,107 @@ export const ImageInput: React.FC<IImageInput> = ({
isEditing, isEditing,
text, text,
onImageChange, onImageChange,
onImageURL,
acceptSVG = false, acceptSVG = false,
}) => { }) => {
const [isShowDialog, setIsShowDialog] = useState(false);
const [url, setURL] = useState("");
if (!isEditing) return <div />; if (!isEditing) return <div />;
if (!onImageURL) {
// just return the file input
return (
<Form.Label className="image-input">
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
/>
</Form.Label>
);
}
function onConfirmURL() {
if (!onImageURL) {
return;
}
setIsShowDialog(false);
onImageURL(url);
}
function renderDialog() {
return (
<Modal
show={!!isShowDialog}
onHide={() => setIsShowDialog(false)}
header="Image URL"
accept={{ onClick: onConfirmURL, text: "Confirm" }}
>
<div className="dialog-content">
<Form.Group controlId="url" as={Row}>
<Form.Label column xs={3}>
URL
</Form.Label>
<Col xs={9}>
<Form.Control
className="text-input"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setURL(event.currentTarget.value)
}
value={url}
placeholder="URL"
/>
</Col>
</Form.Group>
</div>
</Modal>
);
}
const popover = (
<Popover id="set-image-popover">
<Popover.Content>
<>
<div>
<Form.Label className="image-input">
<Button variant="secondary">
<Icon icon="file" className="fa-fw" />
<span>From file...</span>
</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
/>
</Form.Label>
</div>
<div>
<Button className="minimal" onClick={() => setIsShowDialog(true)}>
<Icon icon="link" className="fa-fw" />
<span>From URL...</span>
</Button>
</div>
</>
</Popover.Content>
</Popover>
);
return ( return (
<Form.Label className="image-input"> <>
<Button variant="secondary">{text ?? "Browse for image..."}</Button> {renderDialog()}
<Form.Control <OverlayTrigger
type="file" trigger="click"
onChange={onImageChange} placement="top"
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`} overlay={popover}
/> rootClose
</Form.Label> >
<Button variant="secondary" className="mr-2">
{text ?? "Set image..."}
</Button>
</OverlayTrigger>
</>
); );
}; };

View File

@ -319,6 +319,7 @@ export const Studio: React.FC = () => {
onToggleEdit={onToggleEdit} onToggleEdit={onToggleEdit}
onSave={onSave} onSave={onSave}
onImageChange={onImageChangeHandler} onImageChange={onImageChangeHandler}
onImageChangeURL={onImageLoad}
onClearImage={() => { onClearImage={() => {
onClearImage(); onClearImage();
}} }}