diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index d4e991ee..db042c8c 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 3a65c3af..22b4bf4b 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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": "Αποθήκευση" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 2fd132ed..de63bf04 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 68a9fe6c..6f4befc4 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 5f5a4918..1a83da99 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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": "स्टोरेज" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 81db118a..f1ee528d 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 8d24e765..e8b4d1d5 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 80be321c..f1d0d941 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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": "ストレージ" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 72f93d0d..154951e4 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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": "저장소" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 79ddfe63..7f55a6e1 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index cb91efb8..fac1d7be 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 7bf89e78..d6a3a56a 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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": "Хранилище" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 55460828..30c9f1e2 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 1aa530a3..f1884cbf 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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": "Сховище" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 991cc924..d43ad166 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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ữ" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index b5cec4cb..f9b6e2f0 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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": "存储空间" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 7d52c350..93b9073a 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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": "存儲空間" } diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 41de25eb..5b5eafb2 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -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)]; diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index ec67f03f..9216e596 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -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 = ({ 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([]); const showAboutReadest = () => { setAboutDialogVisible(true); @@ -46,6 +50,22 @@ const SettingsMenu: React.FC = ({ 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 = ({ setIsDropdownOpen }) => { return (
{user ? ( = ({ setIsDropdownOpen }) => { } >
    +
diff --git a/apps/readest-app/src/components/Quota.tsx b/apps/readest-app/src/components/Quota.tsx new file mode 100644 index 00000000..0008345e --- /dev/null +++ b/apps/readest-app/src/components/Quota.tsx @@ -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 = ({ quotas }) => { + return ( +
+
+ {quotas.map((quota) => ( +
+
+
+ {quota.name} +
+
+ +
+ {quota.used} / {quota.total} {quota.unit} +
+
+ ))} +
+
+ ); +}; + +export default Quota; diff --git a/apps/readest-app/src/libs/storage.ts b/apps/readest-app/src/libs/storage.ts index fcab83c7..150dfa67 100644 --- a/apps/readest-app/src/libs/storage.ts +++ b/apps/readest-app/src/libs/storage.ts @@ -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'); + } +}; diff --git a/apps/readest-app/src/pages/api/storage/delete.ts b/apps/readest-app/src/pages/api/storage/delete.ts new file mode 100644 index 00000000..a25a866f --- /dev/null +++ b/apps/readest-app/src/pages/api/storage/delete.ts @@ -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' }); + } +} diff --git a/apps/readest-app/src/pages/api/storage/upload.ts b/apps/readest-app/src/pages/api/storage/upload.ts index 9aaef4a2..0b2b9483 100644 --- a/apps/readest-app/src/pages/api/storage/upload.ts +++ b/apps/readest-app/src/pages/api/storage/upload.ts @@ -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(); 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, diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index ad403e6a..edaf9e13 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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 { + async deleteBook(book: Book, includingUploaded = false): Promise { 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 { - 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 { - 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()); diff --git a/apps/readest-app/src/store/libraryStore.ts b/apps/readest-app/src/store/libraryStore.ts index 853c7889..133adb77 100644 --- a/apps/readest-app/src/store/libraryStore.ts +++ b/apps/readest-app/src/store/libraryStore.ts @@ -21,8 +21,10 @@ export const useLibraryStore = create((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; } diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 8355c9c9..d2f1b375 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -38,7 +38,7 @@ export interface AppService { saveCover?: boolean, overwrite?: boolean, ): Promise; - deleteBook(book: Book): Promise; + deleteBook(book: Book, includingUploaded?: boolean): Promise; uploadBook(book: Book): Promise; downloadBook(book: Book, onlyCover?: boolean): Promise; loadBookConfig(book: Book, settings: SystemSettings): Promise; diff --git a/apps/readest-app/src/types/user.ts b/apps/readest-app/src/types/user.ts index 05d27da9..4f793aca 100644 --- a/apps/readest-app/src/types/user.ts +++ b/apps/readest-app/src/types/user.ts @@ -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; +}; diff --git a/apps/readest-app/src/utils/access.ts b/apps/readest-app/src/utils/access.ts index a32a5351..fe46ff1d 100644 --- a/apps/readest-app/src/utils/access.ts +++ b/apps/readest-app/src/utils/access.ts @@ -10,7 +10,7 @@ interface Token { [key: string]: string | number; } -export const getPlanData = (token: string) => { +export const getStoragePlanData = (token: string) => { const data = jwtDecode(token) || {}; const plan = data['plan'] || 'free'; const usage = data['storage_usage_bytes'] || 0; @@ -42,3 +42,16 @@ export const getUserID = async (): Promise => { 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 }; +}; diff --git a/apps/readest-app/src/utils/transform.ts b/apps/readest-app/src/utils/transform.ts index 051b14f7..b2482953 100644 --- a/apps/readest-app/src/utils/transform.ts +++ b/apps/readest-app/src/utils/transform.ts @@ -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, }; };