diff --git a/Dockerfile b/Dockerfile index 7320f29..9498af7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM denoland/deno:ubuntu-2.5.6 EXPOSE 8000 -RUN apt-get update && apt-get install -y make zip +RUN apt-get update && apt-get install -y make zip coreutils WORKDIR /app diff --git a/components/files/ListFiles.tsx b/components/files/ListFiles.tsx index b4077a9..7b002c7 100644 --- a/components/files/ListFiles.tsx +++ b/components/files/ListFiles.tsx @@ -158,7 +158,7 @@ export default function ListFiles( {isShowingNotes || isShowingPhotos ? null : ( - - + {humanFileSize(directory.size_in_bytes)} )} {isShowingPhotos || fileShareId ? null : ( diff --git a/components/files/ListPhotos.tsx b/components/files/ListPhotos.tsx index 40b00b4..7a15153 100644 --- a/components/files/ListPhotos.tsx +++ b/components/files/ListPhotos.tsx @@ -79,7 +79,7 @@ export default function ListPhotos( {isShowingNotes ? null : ( - - + {humanFileSize(directory.size_in_bytes)} )} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9f337cb..a89cdce 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -18,7 +18,7 @@ services: # NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service. radicale: - image: tomsquest/docker-radicale:3.5.8.2 + image: tomsquest/docker-radicale:3.6.0.0 ports: - 5232:5232 init: true diff --git a/docker-compose.yml b/docker-compose.yml index b60fe71..58b0212 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: website: - image: ghcr.io/bewcloud/bewcloud:v3.5.1 + image: ghcr.io/bewcloud/bewcloud:v3.6.0 restart: always ports: - 127.0.0.1:8000:8000 @@ -32,7 +32,7 @@ services: # NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service. radicale: - image: tomsquest/docker-radicale:3.5.8.2 + image: tomsquest/docker-radicale:3.6.0.0 # NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container # ports: # - 127.0.0.1:5232:5232 diff --git a/lib/models/files.ts b/lib/models/files.ts index ee96ab3..beec564 100644 --- a/lib/models/files.ts +++ b/lib/models/files.ts @@ -4,7 +4,13 @@ import { Cookie, getCookies, setCookie } from '@std/http'; import { AppConfig } from '/lib/config.ts'; import { Directory, DirectoryFile, FileShare } from '/lib/types.ts'; -import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; +import { + bytesFromHumanFileSize, + sortDirectoriesByName, + sortEntriesByName, + sortFilesByName, + TRASH_PATH, +} from '/lib/utils/files.ts'; import Database, { sql } from '/lib/interfaces/database.ts'; import { COOKIE_NAME as AUTH_COOKIE_NAME, @@ -39,12 +45,14 @@ export class DirectoryModel { for (const entry of directoryEntries) { const stat = await Deno.stat(join(rootPath, entry.name)); + const directorySize = await getDirectorySize(join(rootPath, entry.name)); + const directory: Directory = { user_id: userId, parent_path: path, directory_name: entry.name, has_write_access: true, - size_in_bytes: stat.size, + size_in_bytes: directorySize || stat.size, file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null, updated_at: stat.mtime || new Date(), created_at: stat.birthtime || new Date(), @@ -141,12 +149,14 @@ export class DirectoryModel { parentPath = '/'; } + const directorySize = await getDirectorySize(join(rootPath, relativeDirectoryPath)); + const directory: Directory = { user_id: userId, parent_path: parentPath, directory_name: directoryName, has_write_access: true, - size_in_bytes: stat.size, + size_in_bytes: directorySize || stat.size, file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/` )?.id || null, @@ -764,3 +774,45 @@ export async function getPathInfo(userId: string, path: string): Promise<{ isDir isFile: stat.isFile, }; } + +// NOTE: We're using `-h` (human readable) and parsing the output because that's more stable than `-B 1B` across different systems for a reliable byte size. +async function getDirectorySize(path: string): Promise { + try { + const controller = new AbortController(); + const commandTimeout = setTimeout(() => controller.abort(), 5_000); + + const command = new Deno.Command(`du`, { + args: [ + `-sh`, + path, + ], + signal: controller.signal, + }); + + const { code, stdout, stderr } = await command.output(); + + if (commandTimeout) { + clearTimeout(commandTimeout); + } + + if (code !== 0) { + if (stderr) { + throw new Error(new TextDecoder().decode(stderr)); + } + + throw new Error(`Unknown error running "du"`); + } + + const output = new TextDecoder().decode(stdout); + + const value = output.split('\t')[0].trim(); + + const number = Number.parseFloat(value.match(/\d+(\.\d+)?/)?.[0] || '0'); + const unit = value.match(/[A-Z]+/)?.[0] || 'B'.toUpperCase(); + + return bytesFromHumanFileSize(`${number} ${unit}B`); + } catch (error) { + console.error(error); + return 0; + } +} diff --git a/lib/utils/files.ts b/lib/utils/files.ts index f555901..3972e3d 100644 --- a/lib/utils/files.ts +++ b/lib/utils/files.ts @@ -17,9 +17,21 @@ export function humanFileSize(bytes: number) { ++unitIndex; } while (Math.round(Math.abs(bytes) * roundedPower) / roundedPower >= 1024 && unitIndex < units.length - 1); + if (bytes.toFixed(2).split('.')[1] === '00') { + return `${bytes.toFixed(0)} ${units[unitIndex]}`; + } + return `${bytes.toFixed(2)} ${units[unitIndex]}`; } +export function bytesFromHumanFileSize(humanFileSize: string) { + const [numberString, unit] = humanFileSize.split(' '); + const number = Number.parseFloat(numberString); + + return number * + (unit === 'B' ? 1 : 1024 ** (['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'].indexOf(unit) + 1)); +} + export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) { const nameA = entryA.name.toLocaleLowerCase(); const nameB = entryB.name.toLocaleLowerCase(); diff --git a/lib/utils/files_test.ts b/lib/utils/files_test.ts index c9f3cb3..0faf087 100644 --- a/lib/utils/files_test.ts +++ b/lib/utils/files_test.ts @@ -1,6 +1,6 @@ import { assertEquals } from '@std/assert'; -import { humanFileSize } from './files.ts'; +import { bytesFromHumanFileSize, humanFileSize } from './files.ts'; Deno.test('that humanFileSize works', () => { const tests: { input: number; expected: string }[] = [ @@ -10,7 +10,11 @@ Deno.test('that humanFileSize works', () => { }, { input: 1024, - expected: '1.00 KB', + expected: '1 KB', + }, + { + input: 1100, + expected: '1.07 KB', }, { input: 10000, @@ -22,16 +26,70 @@ Deno.test('that humanFileSize works', () => { }, { input: 1048576, - expected: '1.00 MB', + expected: '1 MB', }, { input: 1073741824, - expected: '1.00 GB', + expected: '1 GB', + }, + { + input: 1150976, + expected: '1.10 MB', + }, + { + input: 1085276160, + expected: '1.01 GB', }, ]; for (const test of tests) { const output = humanFileSize(test.input); - assertEquals(output, test.expected); + assertEquals(output, test.expected, `Expected ${test.input} to be ${test.expected} but got ${output}`); + } +}); + +Deno.test('that bytesFromHumanFileSize works', () => { + const tests: { input: string; expected: number }[] = [ + { + input: '1000 B', + expected: 1000, + }, + { + input: '1024 KB', + expected: 1024 * 1024, + }, + { + input: '10000 KB', + expected: 10000 * 1024, + }, + { + input: '1 B', + expected: 1, + }, + { + input: '1048576 MB', + expected: 1048576 * 1024 * 1024, + }, + { + input: '1073741824 GB', + expected: 1073741824 * 1024 * 1024 * 1024, + }, + { + input: '1.01 B', + expected: 1.01, + }, + { + input: '1048576.15 MB', + expected: 1048576.15 * 1024 * 1024, + }, + { + input: '1073741824.37 GB', + expected: 1073741824.37 * 1024 * 1024 * 1024, + }, + ]; + + for (const test of tests) { + const output = bytesFromHumanFileSize(test.input); + assertEquals(output, test.expected, `Expected ${test.input} to be ${test.expected} but got ${output}`); } });