[WEB-310] dev: private bucket implementation (#5793)

* chore: migrations and backmigration to move attachments to file asset

* chore: move attachments to file assets

* chore: update migration file to include created by and updated by and size

* chore: remove uninmport errors

* chore: make size as float field

* fix: file asset uploads

* chore: asset uploads migration changes

* chore: v2 assets endpoint

* chore: remove unused imports

* chore: issue attachments

* chore: issue attachments

* chore: workspace logo endpoints

* chore: private bucket changes

* chore: user asset endpoint

* chore: add logo_url validation

* chore: cover image urlk

* chore: change asset max length

* chore: pages endpoint

* chore: store the storage_metadata only when none

* chore: attachment asset apis

* chore: update create private bucket

* chore: make bucket private

* chore: fix response of user uploads

* fix: response of user uploads

* fix: job to fix file asset uploads

* fix: user asset endpoints

* chore: avatar for user profile

* chore: external apis user url endpoint

* chore: upload workspace and user asset actions updated

* chore: analytics endpoint

* fix: analytics export

* chore: avatar urls

* chore: update user avatar instances

* chore: avatar urls for assignees and creators

* chore: bucket permission script

* fix: all user avatr instances in the web app

* chore: update project cover image logic

* fix: issue attachment endpoint

* chore: patch endpoint for issue attachment

* chore: attachments

* chore: change attachment storage class

* chore: update issue attachment endpoints

* fix: issue attachment

* chore: update issue attachment implementation

* chore: page asset endpoints

* fix: web build errors

* chore: attachments

* chore: page asset urls

* chore: comment and issue asset endpoints

* chore: asset endpoints

* chore: attachment endpoints

* chore: bulk asset endpoint

* chore: restore endpoint

* chore: project assets endpoints

* chore: asset url

* chore: add delete asset endpoints

* chore: fix asset upload endpoint

* chore: update patch endpoints

* chore: update patch endpoint

* chore: update editor image handling

* chore: asset restore endpoints

* chore: avatar url for space assets

* chore: space app assets migration

* fix: space app urls

* chore: space endpoints

* fix: old editor images rendering logic

* fix: issue archive and attachment activity

* chore: asset deletes

* chore: attachment delete

* fix: issue attachment

* fix: issue attachment get

* chore: cover image url for projects

* chore: remove duplicate py file

* fix: url check function

* chore: chore project cover asset delete

* fix: migrations

* chore: delete migration files

* chore: update bucket

* fix: build errors

* chore: add asset url in intake attachment

* chore: project cover fix

* chore: update next.config

* chore: delete old workspace logos

* chore: workspace assets

* chore: asset get for space

* chore: update project modal

* chore: remove unused imports

* fix: space app editor helper

* chore: update rich-text read-only editor

* chore: create multiple column for entity identifiers

* chore: update migrations

* chore: remove entity identifier

* fix: issue assets

* chore: update maximum file size logic

* chore: update editor max file size logic

* fix: close modal after removing workspace logo

* chore: update uploaded asstes' status post issue creation

* chore: added file size limit to the space app

* dev: add file size limit restriction on all endpoints

* fix: remove old workspace logo and user avatar

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-10-11 20:13:38 +05:30 committed by GitHub
parent c9580ab794
commit 7e334203f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 5326 additions and 2518 deletions

View file

@ -7,16 +7,18 @@ import { useParams } from "next/navigation";
import { useDropzone } from "react-dropzone";
import { Control, Controller } from "react-hook-form";
import useSWR from "swr";
// headless ui
import { Tab, Popover } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// plane types
import { EFileAssetType } from "@plane/types/src/enums";
// ui
import { Button, Input, Loader } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
import { MAX_STATIC_FILE_SIZE } from "@/constants/common";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useWorkspace, useInstance } from "@/hooks/store";
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
// services
import { FileService } from "@/services/file.service";
@ -44,13 +46,14 @@ type Props = {
disabled?: boolean;
tabIndex?: number;
isProfileCover?: boolean;
projectId?: string | null;
};
// services
const fileService = new FileService();
export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false } = props;
const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false, projectId } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
@ -63,9 +66,6 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const ref = useRef<HTMLDivElement>(null);
// router params
const { workspaceSlug } = useParams();
// store hooks
const { config } = useInstance();
const { currentWorkspace } = useWorkspace();
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
@ -92,52 +92,42 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".webp"],
},
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
maxSize: MAX_STATIC_FILE_SIZE,
});
const handleSubmit = async () => {
if (!image) return;
setIsImageUploading(true);
if (!image) return;
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
const oldValue = value;
const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
const uploadCallback = (res: any) => {
const imageUrl = res.asset;
onChange(imageUrl);
const uploadCallback = (url: string) => {
onChange(url);
setIsImageUploading(false);
setImage(null);
setIsOpen(false);
};
if (isProfileCover) {
fileService
.uploadUserFile(formData)
.then((res) => {
uploadCallback(res);
if (isUnsplashImage) return;
if (oldValue && currentWorkspace) fileService.deleteUserFile(oldValue);
})
.catch((err) => {
console.error(err);
});
await fileService
.uploadUserAsset(
{
entity_identifier: "",
entity_type: EFileAssetType.USER_COVER,
},
image
)
.then((res) => uploadCallback(res.asset_url));
} else {
if (!workspaceSlug) return;
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
uploadCallback(res);
if (isUnsplashImage) return;
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
})
.catch((err) => {
console.error(err);
});
await fileService
.uploadWorkspaceAsset(
workspaceSlug.toString(),
{
entity_identifier: projectId?.toString() ?? "",
entity_type: EFileAssetType.PROJECT_COVER,
},
image
)
.then((res) => uploadCallback(res.asset_url));
}
};
@ -332,7 +322,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
<Image
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""}
src={image ? URL.createObjectURL(image) : value ? (getFileURL(value) ?? "") : ""}
alt="image"
className="rounded-lg"
/>

View file

@ -2,7 +2,6 @@
import React, { useEffect, useState, useRef, Fragment, Ref } from "react";
import { Placement } from "@popperjs/core";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form"; // services
import { usePopper } from "react-popper";
import { AlertCircle } from "lucide-react";
@ -23,6 +22,8 @@ type Props = {
prompt?: string;
button: JSX.Element;
className?: string;
workspaceSlug: string;
projectId: string;
};
type FormData = {
@ -33,7 +34,18 @@ type FormData = {
const aiService = new AIService();
export const GptAssistantPopover: React.FC<Props> = (props) => {
const { isOpen, handleClose, onResponse, onError, placement, prompt, button, className = "" } = props;
const {
isOpen,
handleClose,
onResponse,
onError,
placement,
prompt,
button,
className = "",
workspaceSlug,
projectId,
} = props;
// states
const [response, setResponse] = useState("");
const [invalidResponse, setInvalidResponse] = useState(false);
@ -41,8 +53,6 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const editorRef = useRef<any>(null);
const responseRef = useRef<any>(null);
// router
const { workspaceSlug } = useParams();
// popper
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
@ -208,6 +218,8 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
initialValue={prompt}
containerClassName="-m-3"
ref={editorRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
)}
@ -218,6 +230,8 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
id="ai-assistant-response"
initialValue={`<p>${response}</p>`}
ref={responseRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
)}

View file

@ -5,34 +5,33 @@ import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { UserCircle2 } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react";
// plane types
import { EFileAssetType } from "@plane/types/src/enums";
// hooks
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
// hooks
import { useInstance } from "@/hooks/store";
import { MAX_STATIC_FILE_SIZE } from "@/constants/common";
// helpers
import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper";
import { checkURLValidity } from "@/helpers/string.helper";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
type Props = {
handleDelete?: () => void;
handleRemove: () => Promise<void>;
isOpen: boolean;
isRemoving: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const UserImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props;
const { handleRemove, isOpen, onClose, onSuccess, value } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// store hooks
const { config } = useInstance();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
@ -41,7 +40,7 @@ export const UserImageUploadModal: React.FC<Props> = observer((props) => {
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".webp"],
},
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
maxSize: MAX_STATIC_FILE_SIZE,
multiple: false,
});
@ -53,31 +52,46 @@ export const UserImageUploadModal: React.FC<Props> = observer((props) => {
const handleSubmit = async () => {
if (!image) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
try {
const { asset_url } = await fileService.uploadUserAsset(
{
entity_identifier: "",
entity_type: EFileAssetType.USER_AVATAR,
},
image
);
onSuccess(asset_url);
setImage(null);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.toString() ?? "Something went wrong. Please try again.",
});
throw new Error("Error in uploading file.");
} finally {
setIsImageUploading(false);
}
};
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setImage(null);
if (value) fileService.deleteUserFile(value);
})
.catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsImageUploading(false));
const handleImageRemove = async () => {
if (!value) return;
setIsRemoving(true);
try {
if (checkURLValidity(value)) {
await fileService.deleteOldUserAsset(value);
} else {
const assetId = getAssetIdFromUrl(value);
await fileService.deleteUserAsset(assetId);
}
await handleRemove();
} catch (error) {
console.log("Error in uploading user asset:", error);
} finally {
setIsRemoving(false);
}
};
return (
@ -130,7 +144,7 @@ export const UserImageUploadModal: React.FC<Props> = observer((props) => {
Edit
</button>
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
src={image ? URL.createObjectURL(image) : value ? getFileURL(value) : ""}
alt="image"
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
/>
@ -158,11 +172,9 @@ export const UserImageUploadModal: React.FC<Props> = observer((props) => {
</div>
<p className="my-4 text-sm text-custom-text-200">File formats supported- .jpeg, .jpg, .png, .webp</p>
<div className="flex items-center justify-between">
{handleDelete && (
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
)}
<Button variant="danger" size="sm" onClick={handleImageRemove} disabled={!value}>
{isRemoving ? "Removing" : "Remove"}
</Button>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
@ -174,7 +186,7 @@ export const UserImageUploadModal: React.FC<Props> = observer((props) => {
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
{isImageUploading ? "Uploading" : "Upload & Save"}
</Button>
</div>
</div>

View file

@ -1,23 +1,27 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useParams } from "next/navigation";
import { useDropzone } from "react-dropzone";
import { UserCircle2 } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react";
// plane types
import { EFileAssetType } from "@plane/types/src/enums";
// hooks
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
import { Button } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
import { MAX_STATIC_FILE_SIZE } from "@/constants/common";
// helpers
import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper";
import { checkURLValidity } from "@/helpers/string.helper";
// hooks
import { useWorkspace, useInstance } from "@/hooks/store";
import { useWorkspace } from "@/hooks/store";
// services
import { FileService } from "@/services/file.service";
type Props = {
handleRemove?: () => void;
handleRemove: () => Promise<void>;
isOpen: boolean;
isRemoving: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
@ -27,16 +31,15 @@ type Props = {
const fileService = new FileService();
export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props;
const { handleRemove, isOpen, onClose, onSuccess, value } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
// store hooks
const { config } = useInstance();
const { currentWorkspace } = useWorkspace();
const { currentWorkspace, updateWorkspaceLogo } = useWorkspace();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
@ -45,45 +48,58 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".webp"],
},
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
maxSize: MAX_STATIC_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
setTimeout(() => {
setImage(null);
}, 300);
};
const handleSubmit = async () => {
if (!image || (!workspaceSlug && pathname !== "/onboarding")) return;
if (!image || !workspaceSlug || !currentWorkspace) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
try {
const { asset_url } = await fileService.uploadWorkspaceAsset(
workspaceSlug.toString(),
{
entity_identifier: currentWorkspace.id,
entity_type: EFileAssetType.WORKSPACE_LOGO,
},
image
);
updateWorkspaceLogo(workspaceSlug.toString(), asset_url);
onSuccess(asset_url);
} catch (error) {
console.log("error", error);
throw new Error("Error in uploading file.");
} finally {
setIsImageUploading(false);
}
};
if (!workspaceSlug) return;
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setImage(null);
if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value);
})
.catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsImageUploading(false));
const handleImageRemove = async () => {
if (!workspaceSlug || !value) return;
setIsRemoving(true);
try {
if (checkURLValidity(value)) {
await fileService.deleteOldWorkspaceAsset(currentWorkspace?.id ?? "", value);
} else {
const assetId = getAssetIdFromUrl(value);
await fileService.deleteWorkspaceAsset(workspaceSlug.toString(), assetId);
}
await handleRemove();
handleClose();
} catch (error) {
console.log("Error in removing workspace asset:", error);
} finally {
setIsRemoving(false);
}
};
return (
@ -115,7 +131,7 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Upload Image
Upload image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
@ -136,7 +152,7 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
Edit
</button>
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
src={image ? URL.createObjectURL(image) : value ? getFileURL(value) : ""}
alt="image"
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
/>
@ -164,11 +180,9 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
</div>
<p className="my-4 text-sm text-custom-text-200">File formats supported- .jpeg, .jpg, .png, .webp</p>
<div className="flex items-center justify-between">
{handleRemove && (
<Button variant="danger" size="sm" onClick={handleRemove} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
)}
<Button variant="danger" size="sm" onClick={handleImageRemove} disabled={!value} loading={isRemoving}>
{isRemoving ? "Removing" : "Remove"}
</Button>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
@ -180,7 +194,7 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
{isImageUploading ? "Uploading" : "Upload & Save"}
</Button>
</div>
</div>