Compare commits

...

6 Commits

Author SHA1 Message Date
diced
d49afe60c8 fix: #924 2025-11-14 23:52:10 -08:00
diced
3370d4b663 fix: remove random logs 2025-11-14 23:50:35 -08:00
diced
1f1bcd3a47 feat: export folder as zip file 2025-11-14 23:48:50 -08:00
diced
d9df04bac5 fix: transactions not working for current user 2025-11-14 23:36:03 -08:00
diced
2bf2809269 fix: metrics erroring with null usernames 2025-11-14 23:18:01 -08:00
diced
9bb9e7e399 feat: add copy raw file link button to file modal 2025-11-14 23:08:05 -08:00
13 changed files with 109 additions and 20 deletions

View File

@@ -157,7 +157,6 @@ export default function Login() {
}, [user]);
useEffect(() => {
console.log({ willRedirect, config });
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,

View File

@@ -29,6 +29,7 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
@@ -403,6 +404,11 @@ export default function FileModal({
tooltip='View file in a new tab'
color='blue'
/>
<ActionButton
Icon={IconClipboardTypography}
onClick={() => copyFile(file, clipboard, true)}
tooltip='Copy raw file link'
/>
<ActionButton
Icon={IconCopy}
onClick={() => copyFile(file, clipboard)}

View File

@@ -27,10 +27,14 @@ export function downloadFile(file: File) {
window.open(`/raw/${file.name}?download=true`, '_blank');
}
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
const url = raw
? `${domain}/raw/${file.name}`
: file.url
? `${domain}${file.url}`
: `${domain}/view/${file.name}`;
clipboard.copy(url);

View File

@@ -16,6 +16,7 @@ import {
IconShare,
IconShareOff,
IconTrashFilled,
IconZip,
} from '@tabler/icons-react';
import ViewFilesModal from '../ViewFilesModal';
import EditFolderNameModal from '../EditFolderNameModal';
@@ -169,6 +170,14 @@ export default function FolderTableView() {
<IconPencil size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Export folder as ZIP'>
<ActionIcon
color='blue'
onClick={() => window.open(`/api/user/folders/${folder.id}/export`, '_blank')}
>
<IconZip size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete Folder'>
<ActionIcon
color='red'

View File

@@ -99,8 +99,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
const recent = data[0]; // it is sorted by desc so 0 is the first one.
if (recent.data.filesUsers.length === 0) return null;
if (recent.data.urlsUsers.length === 0) return null;
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
return (
<>
@@ -121,7 +120,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{bytes(count.storage)}</Table.Td>
<Table.Td>{count.views}</Table.Td>
@@ -147,7 +146,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{count.views}</Table.Td>
</Table.Tr>

View File

@@ -32,7 +32,6 @@ export default function DashboardServerSettings() {
const scrollToSetting = useMemo(() => {
return (setting: string) => {
console.log('scrolling to setting:', setting);
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
if (input) {
const observer = new IntersectionObserver(

View File

@@ -20,8 +20,6 @@ export default function DashboardSettings() {
const config = useConfig();
const user = useUserStore((state) => state.user);
console.log(config.oauthEnabled);
return (
<>
<Group gap='sm'>

View File

@@ -232,7 +232,7 @@ export default function GeneratorButton({
{name === 'ShareX' && (
<Switch
label='Xshare Compatibility'
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The genereated config will not work with ShareX.'
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The generated config will not work with ShareX.'
checked={options.sharex_xshareCompatibility ?? false}
onChange={(event) => setOption({ sharex_xshareCompatibility: event.currentTarget.checked })}
disabled={!onlyFile}

View File

@@ -19,7 +19,7 @@ export const metricDataSchema = z.object({
filesUsers: z.array(
z.object({
username: z.string(),
username: z.string().nullable(),
sum: z.number(),
storage: z.number(),
views: z.number(),
@@ -27,7 +27,7 @@ export const metricDataSchema = z.object({
),
urlsUsers: z.array(
z.object({
username: z.string(),
username: z.string().nullable(),
sum: z.number(),
views: z.number(),
}),

View File

@@ -45,7 +45,9 @@ async function vitePlugin(fastify: FastifyInstance) {
return;
}
await new Promise<void>((resolve, reject) => {
reply.hijack();
return new Promise<void>((resolve, reject) => {
vite!.middlewares(req.raw, reply.raw, (err: any) => {
if (err) reject(err);
else resolve();

View File

@@ -24,11 +24,16 @@ type Body = {
const logger = log('api').c('user').c('files').c('transaction');
function checkInteraction(current: Role, roles: Role[]) {
function checkInteraction(
current: { id: string; role: Role },
roles: { id: string; role: Role }[],
): number[] {
const indices: number[] = [];
for (let i = 0; i !== roles.length; ++i) {
if (!canInteract(current, roles[i])) {
if (roles[i].id === current.id) continue;
if (!canInteract(current.role, roles[i].role)) {
indices.push(i);
}
}
@@ -58,8 +63,8 @@ export default fastifyPlugin(
});
const invalids = checkInteraction(
req.user.role,
toFavoriteFiles.map((f) => f.User?.role ?? 'USER'),
{ id: req.user.id, role: req.user.role },
toFavoriteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
);
if (invalids.length > 0)
return res.forbidden(`You don't have the permission to modify files[${invalids.join(', ')}]`);
@@ -147,8 +152,8 @@ export default fastifyPlugin(
});
const invalids = checkInteraction(
req.user.role,
toDeleteFiles.map((f) => f.User?.role ?? 'USER'),
{ id: req.user.id, role: req.user.role },
toDeleteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
);
if (invalids.length > 0)
return res.forbidden(`You don't have the permission to delete files[${invalids.join(', ')}]`);

View File

@@ -0,0 +1,68 @@
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { userMiddleware } from '@/server/middleware/user';
import archiver from 'archiver';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFoldersIdExportResponse = null;
type Params = {
id: string;
};
const logger = log('api').c('user').c('folders').c('[id]').c('export');
export const PATH = '/api/user/folders/:id/export';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const { id } = req.params;
const folder = await prisma.folder.findUnique({
where: {
id,
},
include: {
files: true,
},
});
if (!folder) return res.notFound('Folder not found');
if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder');
if (!folder.files.length) return res.badRequest("Can't export an empty folder.");
logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id });
res.hijack();
const zip = archiver('zip', {
zlib: { level: 9 },
});
zip.pipe(res.raw);
for (const file of folder.files) {
const stream = await datasource.get(file.name);
if (!stream) {
logger.warn('failed to get file stream for folder export', { file: file.id, folder: folder.id });
continue;
}
zip.append(stream, { name: file.name });
}
zip.on('error', (err) => {
logger.error('error during folder export zip creation', { folder: folder.id }).error(err as Error);
});
zip.on('finish', () => {
logger.info(`folder export completed: ${folder.name}`, { user: req.user.id, folder: folder.id });
});
await zip.finalize();
});
done();
},
{ name: PATH },
);