feat: add support for download resource from link (#1800)

* Add support for download resource from link

* Parse external link and add file ext name from mime info

* Add zh-Hans locale for `download-link`

* fix typo on code and comments

* Update server/resource.go

---------

Co-authored-by: Athurg Feng <athurg@gooth.org>
Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
Athurg Gooth 2023-06-08 22:35:33 +08:00 committed by GitHub
parent 5d06c8093c
commit e6c9f2a00e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 82 additions and 12 deletions

View File

@ -26,13 +26,14 @@ type ResourceCreate struct {
CreatorID int `json:"-"` CreatorID int `json:"-"`
// Domain specific fields // Domain specific fields
Filename string `json:"filename"` Filename string `json:"filename"`
Blob []byte `json:"-"` Blob []byte `json:"-"`
InternalPath string `json:"internalPath"` InternalPath string `json:"internalPath"`
ExternalLink string `json:"externalLink"` ExternalLink string `json:"externalLink"`
Type string `json:"type"` Type string `json:"type"`
Size int64 `json:"-"` Size int64 `json:"-"`
PublicID string `json:"publicId"` PublicID string `json:"publicId"`
DownloadToLocal bool `json:"downloadToLocal"`
} }
type ResourceFind struct { type ResourceFind struct {

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -55,9 +56,46 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
} }
resourceCreate.CreatorID = userID resourceCreate.CreatorID = userID
// Only allow those external links with http prefix. if resourceCreate.ExternalLink != "" {
if resourceCreate.ExternalLink != "" && !strings.HasPrefix(resourceCreate.ExternalLink, "http") { // Only allow those external links scheme with http/https
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link") linkURL, err := url.Parse(resourceCreate.ExternalLink)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
}
if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
}
if resourceCreate.DownloadToLocal {
resp, err := http.Get(linkURL.String())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to request "+resourceCreate.ExternalLink)
}
defer resp.Body.Close()
blob, err := io.ReadAll(resp.Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to read "+resourceCreate.ExternalLink)
}
resourceCreate.Blob = blob
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to read mime from "+resourceCreate.ExternalLink)
}
resourceCreate.Type = mediaType
filename := path.Base(linkURL.Path)
if path.Ext(filename) == "" {
extensions, _ := mime.ExtensionsByType(mediaType)
if len(extensions) > 0 {
filename += extensions[0]
}
}
resourceCreate.Filename = filename
resourceCreate.PublicID = common.GenUUID()
resourceCreate.ExternalLink = ""
}
} }
resource, err := s.Store.CreateResource(ctx, resourceCreate) resource, err := s.Store.CreateResource(ctx, resourceCreate)

View File

@ -13,7 +13,7 @@ interface Props extends DialogProps {
onConfirm?: (resourceList: Resource[]) => void; onConfirm?: (resourceList: Resource[]) => void;
} }
type SelectedMode = "local-file" | "external-link"; type SelectedMode = "local-file" | "external-link" | "download-link";
interface State { interface State {
selectedMode: SelectedMode; selectedMode: SelectedMode;
@ -32,6 +32,7 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
filename: "", filename: "",
externalLink: "", externalLink: "",
type: "", type: "",
downloadToLocal: false,
}); });
const [fileList, setFileList] = useState<File[]>([]); const [fileList, setFileList] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -70,7 +71,7 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
destroy(); destroy();
}; };
const handleSelectedModeChanged = (mode: "local-file" | "external-link") => { const handleSelectedModeChanged = (mode: "local-file" | "external-link" | "download-link") => {
setState((state) => { setState((state) => {
return { return {
...state, ...state,
@ -129,6 +130,10 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
if (resourceCreate.filename === "" || resourceCreate.externalLink === "" || resourceCreate.type === "") { if (resourceCreate.filename === "" || resourceCreate.externalLink === "" || resourceCreate.type === "") {
return false; return false;
} }
} else if (state.selectedMode === "download-link") {
if (resourceCreate.externalLink === "") {
return false;
}
} }
return true; return true;
}; };
@ -161,6 +166,9 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
createdResourceList.push(resource); createdResourceList.push(resource);
} }
} else { } else {
if (state.selectedMode === "download-link") {
resourceCreate.downloadToLocal = true;
}
const resource = await resourceStore.createResource(resourceCreate); const resource = await resourceStore.createResource(resourceCreate);
createdResourceList.push(resource); createdResourceList.push(resource);
} }
@ -195,6 +203,7 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
> >
<Option value="local-file">{t("resource.create-dialog.local-file.option")}</Option> <Option value="local-file">{t("resource.create-dialog.local-file.option")}</Option>
<Option value="external-link">{t("resource.create-dialog.external-link.option")}</Option> <Option value="external-link">{t("resource.create-dialog.external-link.option")}</Option>
<Option value="download-link">{t("resource.create-dialog.download-link.option")}</Option>
</Select> </Select>
{state.selectedMode === "local-file" && ( {state.selectedMode === "local-file" && (
@ -279,6 +288,21 @@ const CreateResourceDialog: React.FC<Props> = (props: Props) => {
</> </>
)} )}
{state.selectedMode === "download-link" && (
<>
<Typography className="!mb-1" level="body2">
{t("resource.create-dialog.external-link.link")}
</Typography>
<Input
className="mb-2"
placeholder={t("resource.create-dialog.external-link.link-placeholder")}
value={resourceCreate.externalLink}
onChange={handleExternalLinkChanged}
fullWidth
/>
</>
)}
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1"> <div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
<Button variant="plain" color="neutral" onClick={handleCloseDialog}> <Button variant="plain" color="neutral" onClick={handleCloseDialog}>
{t("common.cancel")} {t("common.cancel")}

View File

@ -140,6 +140,9 @@
"file-name-placeholder": "File name", "file-name-placeholder": "File name",
"type": "Type", "type": "Type",
"type-placeholder": "File type" "type-placeholder": "File type"
},
"download-link": {
"option": "Download link"
} }
} }
}, },

View File

@ -257,6 +257,9 @@
"type": "类型", "type": "类型",
"type-placeholder": "文件类型" "type-placeholder": "文件类型"
}, },
"download-link": {
"option": "从外部链接下载"
},
"local-file": { "local-file": {
"choose": "选择文件...", "choose": "选择文件...",
"option": "本地文件" "option": "本地文件"

View File

@ -19,6 +19,7 @@ interface ResourceCreate {
filename: string; filename: string;
externalLink: string; externalLink: string;
type: string; type: string;
downloadToLocal: boolean;
} }
interface ResourcePatch { interface ResourcePatch {