forked from akai/readest
sync: support quota of cloud storage (#261)
This commit is contained in:
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Buch heruntergeladen: {{title}}",
|
||||
"Failed to download book: {{title}}": "Fehler beim Herunterladen des Buches: {{title}}",
|
||||
"Upload Book": "Buch hochladen",
|
||||
"Auto Upload Books to Cloud": "Bücher automatisch in die Cloud hochladen"
|
||||
"Auto Upload Books to Cloud": "Bücher automatisch in die Cloud hochladen",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% des Cloud-Speichers verwendet.",
|
||||
"Storage": "Speicher"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Το βιβλίο με τίτλο {{title}} κατέβηκε",
|
||||
"Failed to download book: {{title}}": "Αποτυχία λήψης βιβλίου: {{title}}",
|
||||
"Upload Book": "Ανέβασμα βιβλίου",
|
||||
"Auto Upload Books to Cloud": "Αυτόματο ανέβασμα βιβλίων στο cloud"
|
||||
"Auto Upload Books to Cloud": "Αυτόματο ανέβασμα βιβλίων στο cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% της αποθήκευσης στο cloud χρησιμοποιήθηκε.",
|
||||
"Storage": "Αποθήκευση"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Libro descargado: {{title}}",
|
||||
"Failed to download book: {{title}}": "Error al descargar libro: {{title}}",
|
||||
"Upload Book": "Subir libro",
|
||||
"Auto Upload Books to Cloud": "Subir libros automáticamente a la nube"
|
||||
"Auto Upload Books to Cloud": "Subir libros automáticamente a la nube",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% del almacenamiento en la nube utilizado.",
|
||||
"Storage": "Almacenamiento"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Livre téléchargé : {{title}}",
|
||||
"Failed to download book: {{title}}": "Échec du téléchargement du livre : {{title}}",
|
||||
"Upload Book": "Télécharger un livre",
|
||||
"Auto Upload Books to Cloud": "Télécharger automatiquement les livres dans le cloud"
|
||||
"Auto Upload Books to Cloud": "Télécharger automatiquement les livres dans le cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% de l'espace de stockage dans le cloud utilisé.",
|
||||
"Storage": "Stockage"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "पुस्तक डाउनलोड की गई: {{title}}",
|
||||
"Failed to download book: {{title}}": "पुस्तक डाउनलोड करने में विफल: {{title}}",
|
||||
"Upload Book": "पुस्तक अपलोड करें",
|
||||
"Auto Upload Books to Cloud": "पुस्तकें बादल में स्वचालित रूप से अपलोड करें"
|
||||
"Auto Upload Books to Cloud": "पुस्तकें बादल में स्वचालित रूप से अपलोड करें",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% क्लाउड स्टोरेज का उपयोग किया गया है।",
|
||||
"Storage": "स्टोरेज"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Buku diunduh: {{title}}",
|
||||
"Failed to download book: {{title}}": "Gagal mengunduh buku: {{title}}",
|
||||
"Upload Book": "Unggah Buku",
|
||||
"Auto Upload Books to Cloud": "Unggah Buku Otomatis ke Cloud"
|
||||
"Auto Upload Books to Cloud": "Unggah Buku Otomatis ke Cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% dari Penyimpanan Cloud Digunakan.",
|
||||
"Storage": "Penyimpanan"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Libro scaricato: {{title}}",
|
||||
"Failed to download book: {{title}}": "Download libro non riuscito: {{title}}",
|
||||
"Upload Book": "Carica libro",
|
||||
"Auto Upload Books to Cloud": "Carica libri automaticamente su cloud"
|
||||
"Auto Upload Books to Cloud": "Carica libri automaticamente su cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% dello spazio di archiviazione cloud utilizzato.",
|
||||
"Storage": "Archiviazione"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "書籍がダウンロードされました:{{title}}",
|
||||
"Failed to download book: {{title}}": "書籍のダウンロードに失敗しました:{{title}}",
|
||||
"Upload Book": "書籍をアップロード",
|
||||
"Auto Upload Books to Cloud": "書籍を自動的にクラウドにアップロード"
|
||||
"Auto Upload Books to Cloud": "書籍を自動的にクラウドにアップロード",
|
||||
"{{percentage}}% of Cloud Storage Used.": "クラウドストレージの{{percentage}}%が使用されています。",
|
||||
"Storage": "ストレージ"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "책 다운로드됨: {{title}}",
|
||||
"Failed to download book: {{title}}": "책 다운로드 실패: {{title}}",
|
||||
"Upload Book": "책 업로드",
|
||||
"Auto Upload Books to Cloud": "책 자동으로 클라우드에 업로드"
|
||||
"Auto Upload Books to Cloud": "책 자동으로 클라우드에 업로드",
|
||||
"{{percentage}}% of Cloud Storage Used.": "클라우드 저장소 사용량 {{percentage}}%.",
|
||||
"Storage": "저장소"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Książka pobrana: {{title}}",
|
||||
"Failed to download book: {{title}}": "Nie udało się pobrać książki: {{title}}",
|
||||
"Upload Book": "Prześlij książkę",
|
||||
"Auto Upload Books to Cloud": "Automatycznie przesyłaj książki do chmury"
|
||||
"Auto Upload Books to Cloud": "Automatycznie przesyłaj książki do chmury",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% przestrzeni dyskowej w chmurze użytej.",
|
||||
"Storage": "Przechowywanie"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Livro baixado: {{title}}",
|
||||
"Failed to download book: {{title}}": "Falha ao baixar livro: {{title}}",
|
||||
"Upload Book": "Enviar Livro",
|
||||
"Auto Upload Books to Cloud": "Enviar Livros para a Nuvem Automaticamente"
|
||||
"Auto Upload Books to Cloud": "Enviar Livros para a Nuvem Automaticamente",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% do Armazenamento na Nuvem Usado.",
|
||||
"Storage": "Armazenamento"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Книга скачана: {{title}}",
|
||||
"Failed to download book: {{title}}": "Не удалось скачать книгу: {{title}}",
|
||||
"Upload Book": "Загрузить книгу",
|
||||
"Auto Upload Books to Cloud": "Автоматическая загрузка книг в облако"
|
||||
"Auto Upload Books to Cloud": "Автоматическая загрузка книг в облако",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% использовано облачного хранилища.",
|
||||
"Storage": "Хранилище"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Kitap indirildi: {{title}}",
|
||||
"Failed to download book: {{title}}": "Kitap indirilemedi: {{title}}",
|
||||
"Upload Book": "Kitap Yükle",
|
||||
"Auto Upload Books to Cloud": "Kitapları Otomatik Olarak Buluta Yükle"
|
||||
"Auto Upload Books to Cloud": "Kitapları Otomatik Olarak Buluta Yükle",
|
||||
"{{percentage}}% of Cloud Storage Used.": "Bulut Depolamanın %{{percentage}}'si kullanıldı.",
|
||||
"Storage": "Depolama"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Книгу завантажено: {{title}}",
|
||||
"Failed to download book: {{title}}": "Не вдалося завантажити книгу: {{title}}",
|
||||
"Upload Book": "Завантажити книгу",
|
||||
"Auto Upload Books to Cloud": "Автоматично завантажувати книги в хмару"
|
||||
"Auto Upload Books to Cloud": "Автоматично завантажувати книги в хмару",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% використано хмарного сховища.",
|
||||
"Storage": "Сховище"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "Sách đã tải về: {{title}}",
|
||||
"Failed to download book: {{title}}": "Không thể tải về sách: {{title}}",
|
||||
"Upload Book": "Tải lên sách",
|
||||
"Auto Upload Books to Cloud": "Tự động tải sách lên đám mây"
|
||||
"Auto Upload Books to Cloud": "Tự động tải sách lên đám mây",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% dung lượng lưu trữ đám mây đã sử dụng.",
|
||||
"Storage": "Lưu trữ"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "书籍已下载:{{title}}",
|
||||
"Failed to download book: {{title}}": "下载书籍失败:{{title}}",
|
||||
"Upload Book": "上传书籍",
|
||||
"Auto Upload Books to Cloud": "自动上传书籍到云端"
|
||||
"Auto Upload Books to Cloud": "自动上传书籍到云端",
|
||||
"{{percentage}}% of Cloud Storage Used.": "已使用 {{percentage}}% 云存储空间",
|
||||
"Storage": "存储空间"
|
||||
}
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
"Book downloaded: {{title}}": "書籍已下載:{{title}}",
|
||||
"Failed to download book: {{title}}": "書籍下載失敗:{{title}}",
|
||||
"Upload Book": "上傳書籍",
|
||||
"Auto Upload Books to Cloud": "自動上傳書籍到雲端"
|
||||
"Auto Upload Books to Cloud": "自動上傳書籍到雲端",
|
||||
"{{percentage}}% of Cloud Storage Used.": "已使用 {{percentage}}% 雲存儲空間",
|
||||
"Storage": "存儲空間"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const UNGROUPED_NAME = 'ungrouped';
|
||||
|
||||
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
|
||||
if (book.deletedAt) return acc;
|
||||
book.group = book.group || UNGROUPED_NAME;
|
||||
const groupIndex = acc.findIndex((group) => group.name === book.group);
|
||||
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
@@ -11,7 +11,10 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
|
||||
interface BookMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
@@ -21,8 +24,9 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { user, logout } = useAuth();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
@@ -46,6 +50,22 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
const storageQuota: QuotaType = {
|
||||
name: _('Storage'),
|
||||
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
|
||||
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(storagPlan.usage / 1024 / 1024),
|
||||
total: Math.round(storagPlan.quota / 1024 / 1024),
|
||||
unit: 'MB',
|
||||
};
|
||||
setQuotas([storageQuota]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'];
|
||||
@@ -54,7 +74,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-60 shadow-2xl'
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-72 shadow-2xl'
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
@@ -80,6 +100,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<Quota quotas={quotas} />
|
||||
<MenuItem label={_('Sign Out')} noIcon onClick={handleUserLogout} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
type QuotaProps = {
|
||||
quotas: {
|
||||
name: string;
|
||||
tooltip: string;
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas }) => {
|
||||
return (
|
||||
<div className='w-full max-w-lg rounded-md pl-4 pr-2 text-base sm:text-sm'>
|
||||
<div>
|
||||
{quotas.map((quota) => (
|
||||
<div key={quota.name} className='flex h-10 items-center justify-between'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<div className='flex max-w-28 items-center gap-x-2'>
|
||||
<span className='truncate'>{quota.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='py-3 text-right text-xs'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Quota;
|
||||
@@ -1,19 +1,38 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { getAccessToken, getUserID } from '@/utils/access';
|
||||
|
||||
const UPLOAD_API_ENDPOINT = getAPIBaseUrl() + '/storage/upload';
|
||||
const DOWNLOAD_API_ENDPOINT = getAPIBaseUrl() + '/storage/download';
|
||||
const API_ENDPOINTS = {
|
||||
upload: getAPIBaseUrl() + '/storage/upload',
|
||||
download: getAPIBaseUrl() + '/storage/download',
|
||||
delete: getAPIBaseUrl() + '/storage/delete',
|
||||
};
|
||||
|
||||
export const uploadFile = async (file: File, bookHash?: string) => {
|
||||
const fetchWithAuth = async (url: string, options: RequestInit) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error:', errorData.error || response.statusText);
|
||||
throw new Error(errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const uploadFile = async (file: File, bookHash?: string) => {
|
||||
try {
|
||||
const response = await fetch(UPLOAD_API_ENDPOINT, {
|
||||
const response = await fetchWithAuth(API_ENDPOINTS.upload, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -23,14 +42,8 @@ export const uploadFile = async (file: File, bookHash?: string) => {
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.log('Error generating presigned post:', errorData.error);
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
const { uploadUrl } = await response.json();
|
||||
|
||||
const urlResponse = await response.json();
|
||||
const { uploadUrl } = urlResponse;
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
@@ -46,30 +59,21 @@ export const uploadFile = async (file: File, bookHash?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFile = async (fp: string) => {
|
||||
const token = await getAccessToken();
|
||||
const userId = await getUserID();
|
||||
if (!token || !userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
export const downloadFile = async (filePath: string) => {
|
||||
try {
|
||||
const fileKey = `${userId}/${fp}`;
|
||||
const response = await fetch(
|
||||
`${DOWNLOAD_API_ENDPOINT}?fileKey=${encodeURIComponent(fileKey)}`,
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const fileKey = `${userId}/${filePath}`;
|
||||
const response = await fetchWithAuth(
|
||||
`${API_ENDPOINTS.download}?fileKey=${encodeURIComponent(fileKey)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error generating download URL:', errorData.error);
|
||||
throw new Error('File download failed');
|
||||
}
|
||||
|
||||
const { downloadUrl } = await response.json();
|
||||
|
||||
const downloadResponse = await fetch(downloadUrl);
|
||||
@@ -84,3 +88,20 @@ export const downloadFile = async (fp: string) => {
|
||||
throw new Error('File download failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFile = async (filePath: string) => {
|
||||
try {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const fileKey = `${userId}/${filePath}`;
|
||||
await fetchWithAuth(`${API_ENDPOINTS.delete}?fileKey=${encodeURIComponent(fileKey)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('File deletion failed:', error);
|
||||
throw new Error('File deletion failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { createSupabaseClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'DELETE') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
try {
|
||||
const { user, token } = await validateUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileKey } = req.query;
|
||||
|
||||
if (!fileKey || typeof fileKey !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing or invalid fileKey' });
|
||||
}
|
||||
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: fileRecord, error: fileError } = await supabase
|
||||
.from('files')
|
||||
.select('user_id, id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_key', fileKey)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fileError || !fileRecord) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
if (fileRecord.user_id !== user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized access to the file' });
|
||||
}
|
||||
|
||||
const deleteCommand = new DeleteObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await s3Client.send(deleteCommand);
|
||||
const { error: deleteError } = await supabase.from('files').delete().eq('id', fileRecord.id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error updating file record:', deleteError);
|
||||
return res.status(500).json({ error: 'Could not update file record' });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'File deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting file from S3:', error);
|
||||
res.status(500).json({ error: 'Could not delete file from storage' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getPlanData } from '@/utils/access';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
const getUserAndToken = async (authHeader: string | undefined) => {
|
||||
@@ -37,36 +37,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ error: 'Missing file info' });
|
||||
}
|
||||
|
||||
const { usage, quota } = getPlanData(token);
|
||||
const { usage, quota } = getStoragePlanData(token);
|
||||
if (usage + fileSize > quota) {
|
||||
return res.status(403).json({ error: 'Insufficient storage quota', usage });
|
||||
}
|
||||
|
||||
const objectKey = `${user.id}/${fileName}`;
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: existingRecord, error: fetchError } = await supabase
|
||||
.from('files')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_key', objectKey)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
return res.status(500).json({ error: fetchError.message });
|
||||
}
|
||||
let objSize = fileSize;
|
||||
if (existingRecord) {
|
||||
objSize = existingRecord.file_size;
|
||||
} else {
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('files')
|
||||
.insert([
|
||||
{
|
||||
user_id: user.id,
|
||||
book_hash: bookHash,
|
||||
file_key: objectKey,
|
||||
file_size: fileSize,
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
console.log('Inserted record:', inserted);
|
||||
if (insertError) return res.status(500).json({ error: insertError.message });
|
||||
}
|
||||
|
||||
const signableHeaders = new Set<string>();
|
||||
signableHeaders.add('content-length');
|
||||
const putCommand = new PutObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: objectKey,
|
||||
ContentLength: fileSize,
|
||||
ContentLength: objSize,
|
||||
});
|
||||
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('files')
|
||||
.insert([
|
||||
{
|
||||
user_id: user.id,
|
||||
book_hash: bookHash,
|
||||
file_key: objectKey,
|
||||
file_size: fileSize,
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
console.log('Inserted record:', inserted);
|
||||
if (insertError) return res.status(500).json({ error: insertError.message });
|
||||
|
||||
try {
|
||||
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
|
||||
expiresIn: 1800,
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from './constants';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { downloadFile, uploadFile } from '@/libs/storage';
|
||||
import { downloadFile, uploadFile, deleteFile } from '@/libs/storage';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
localBooksDir: string = '';
|
||||
@@ -190,16 +190,20 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBook(book: Book): Promise<void> {
|
||||
async deleteBook(book: Book, includingUploaded = false): Promise<void> {
|
||||
for (const fp of [getFilename(book), getCoverFilename(book)]) {
|
||||
if (await this.fs.exists(fp, 'Books')) {
|
||||
await this.fs.removeFile(fp, 'Books');
|
||||
}
|
||||
if (includingUploaded) {
|
||||
console.log('Deleting uploaded file:', fp);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
await deleteFile(cfp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBook(book: Book): Promise<void> {
|
||||
console.log('Uploading book:', book.title);
|
||||
let file: File;
|
||||
let uploaded = false;
|
||||
for (const fp of [getFilename(book), getCoverFilename(book)]) {
|
||||
@@ -211,6 +215,7 @@ export abstract class BaseAppService implements AppService {
|
||||
} else {
|
||||
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
|
||||
}
|
||||
console.log('Uploading file:', fp);
|
||||
await uploadFile(file, book.hash);
|
||||
uploaded = true;
|
||||
}
|
||||
@@ -223,7 +228,6 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async downloadBook(book: Book, onlyCover = false): Promise<void> {
|
||||
console.log('Downloading book:', book.title);
|
||||
const fps = [getCoverFilename(book)];
|
||||
if (!onlyCover) {
|
||||
fps.push(getFilename(book));
|
||||
@@ -235,6 +239,7 @@ export abstract class BaseAppService implements AppService {
|
||||
if (existed) {
|
||||
downloaded = true;
|
||||
} else {
|
||||
console.log('Downloading file:', fp);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
const fileobj = (await downloadFile(cfp)) as Blob;
|
||||
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
|
||||
|
||||
@@ -21,8 +21,10 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
const bookIndex = library.findIndex((b) => b.hash === book.hash);
|
||||
if (bookIndex !== -1) {
|
||||
if (isDelete) {
|
||||
library.splice(bookIndex, 1);
|
||||
appService.deleteBook(book);
|
||||
appService.deleteBook(book, !!book.uploadedAt);
|
||||
book.deletedAt = Date.now();
|
||||
book.uploadedAt = null;
|
||||
book.downloadedAt = null;
|
||||
} else {
|
||||
library[bookIndex] = book;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface AppService {
|
||||
saveCover?: boolean,
|
||||
overwrite?: boolean,
|
||||
): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
deleteBook(book: Book, includingUploaded?: boolean): Promise<void>;
|
||||
uploadBook(book: Book): Promise<void>;
|
||||
downloadBook(book: Book, onlyCover?: boolean): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
|
||||
@@ -5,3 +5,11 @@ export type UserStorageQuota = {
|
||||
};
|
||||
|
||||
export type UserPlan = keyof UserStorageQuota;
|
||||
|
||||
export type QuotaType = {
|
||||
name: string;
|
||||
tooltip: string;
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Token {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export const getPlanData = (token: string) => {
|
||||
export const getStoragePlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan = data['plan'] || 'free';
|
||||
const usage = data['storage_usage_bytes'] || 0;
|
||||
@@ -42,3 +42,16 @@ export const getUserID = async (): Promise<string | null> => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.user?.id ?? null;
|
||||
};
|
||||
|
||||
export const validateUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
|
||||
progress: progress && JSON.stringify(progress),
|
||||
search_config: searchConfig && JSON.stringify(searchConfig),
|
||||
view_settings: viewSettings && JSON.stringify(viewSettings),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -60,8 +60,8 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
group,
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
uploaded_at: uploadedAt ? new Date(uploadedAt).toISOString() : null,
|
||||
};
|
||||
@@ -92,8 +92,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
progress: progress,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
uploadedAt: uploaded_at ? new Date(uploaded_at!).getTime() : null,
|
||||
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
||||
uploadedAt: uploaded_at ? new Date(uploaded_at).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,8 +111,8 @@ export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBook
|
||||
style,
|
||||
color,
|
||||
note,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
// note that only null deleted_at is updated to the database, undefined is not
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
};
|
||||
@@ -133,6 +133,6 @@ export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
note,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user