feat: init

This commit is contained in:
purifetchi 2025-06-08 17:59:19 +02:00
commit a458a8a103
61 changed files with 7998 additions and 0 deletions

1
.env Normal file
View file

@ -0,0 +1 @@
JWT_SECRET=supersekretnyklucz123

174
.gitignore vendored Normal file
View file

@ -0,0 +1,174 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
*.azurePubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
packages/
## TODO: If the tool you use requires repositories.config, also uncomment the next line
!packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
![Ss]tyle[Cc]op.targets
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
_NCrunch*
.idea/
appsettings.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
PinkSea.Gateway/wwwroot/
mizuki.db*
uploads/

12
Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

View file

@ -0,0 +1,60 @@
const express = require('express');
const router = express.Router();
const userService = require('../services/userService');
const { generateToken, authMiddleware, getActiveUser } = require('../middleware/auth');
const { loginSchema } = require('../validators/login.validator');
const { passwordChangeSchema } = require('../validators/password.validator');
router.post('/login', async (req, res) => {
const { error } = loginSchema.validate(req.body);
if (error) return res.status(400).json({ reason: error.message });
const isValid = await userService.checkPassword(req.body.username, req.body.password);
if (!isValid) return res.status(401).json({ reason: 'Invalid username or password' });
const user = await userService.getUserByUsername(req.body.username);
const token = generateToken(user);
res.json({ token });
});
router.post('/register', async (req, res) => {
const { error } = loginSchema.validate(req.body);
if (error) return res.status(400).json({ reason: error.message });
if (await userService.usernameTaken(req.body.username))
return res.status(409).json({ reason: 'This user already exists.' });
const user = await userService.createUser(req.body.username, req.body.password);
if (!user) return res.status(500).json({ reason: 'Failed to register user' });
const token = generateToken(user);
res.json({ token });
});
router.get('/check', authMiddleware, async (req, res) => {
const user = await getActiveUser(req);
if (user) return res.sendStatus(200);
return res.sendStatus(403);
});
router.post('/change_password', authMiddleware, async (req, res) => {
const { error } = passwordChangeSchema.validate(req.body);
if (error) return res.status(400).json({ reason: error.message });
const user = await getActiveUser(req);
if (!user) return res.sendStatus(401);
const validOld = await userService.checkPassword(user.username, req.body.oldPassword);
if (!validOld) return res.sendStatus(409);
await userService.updatePassword(user._id, req.body.newPassword);
res.sendStatus(200);
});
router.get('/info', authMiddleware, async (req, res) => {
const user = await getActiveUser(req);
if (!user) return res.sendStatus(401);
res.json({ username: user.username, avatar: null });
});
module.exports = router;

View file

@ -0,0 +1,67 @@
const express = require('express');
const multer = require('multer');
const upload = multer();
const router = express.Router();
const uploadService = require('../services/uploadService');
const { getActiveUser, authMiddleware } = require('../middleware/auth');
const { validateFile } = require('../validators/upload.validator');
const { toFileDto, fileCreationErrorDto, fileCreationResultDto } = require('../dtos/file.dto');
router.post('/', authMiddleware, upload.single('file'), async (req, res) => {
const validation = validateFile(req.file);
if (!validation.valid) {
return res.status(400).json(fileCreationErrorDto(validation.reason));
}
const user = await getActiveUser(req);
const result = await uploadService.createUpload(user, req.file);
if (!result) {
return res.status(500).json(fileCreationErrorDto('Error processing upload'));
}
res.status(200).json(fileCreationResultDto(result.filename));
});
router.get('/', authMiddleware, async (req, res) => {
const user = await getActiveUser(req);
const uploads = await uploadService.getUserUploads(user);
res.json(uploads.map(toFileDto));
});
router.delete('/:filename', authMiddleware, async (req, res) => {
const user = await getActiveUser(req);
const { filename } = req.params;
try {
await uploadService.deleteUpload(user, filename);
res.sendStatus(200);
} catch (err) {
console.error(err);
res.status(500).send('Error deleting upload');
}
});
router.put('/:filename', authMiddleware, async (req, res) => {
const user = await getActiveUser(req);
const { filename } = req.params;
const { newFilename } = req.body;
if (!newFilename || typeof newFilename !== 'string') {
return res.status(400).json({ message: 'New filename is required and must be a string' });
}
try {
const success = await uploadService.renameUpload(user, filename, newFilename);
if (!success) {
return res.status(404).json({ message: 'File not found or not authorized' });
}
res.status(200).json({ message: 'File renamed successfully' });
} catch (err) {
console.error('Error renaming file:', err);
res.status(500).json({ message: 'Internal server error' });
}
});
module.exports = router;

View file

@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const uploadService = require('../services/uploadService');
const driveService = require('../services/driveService');
router.get('/:filename', async (req, res) => {
const file = await uploadService.getUploadByFilename(req.params.filename);
if (!file) return res.sendStatus(404);
const stream = driveService.openFile(req.params.filename);
if (!stream) return res.sendStatus(404);
res.setHeader('Content-Disposition', `attachment; filename="${file.originalFilename}"`);
stream.pipe(res);
});
module.exports = router;

14
db/db.js Normal file
View file

@ -0,0 +1,14 @@
const mongoose = require('mongoose');
const connectDB = async () => {
try {
mongoose.connect('mongodb://root:example@localhost:27017/your-db-name?authSource=admin')
console.log('✅ MongoDB connected');
} catch (err) {
console.error('❌ MongoDB connection error:', err.message);
process.exit(1);
}
};
module.exports = connectDB;

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
version: '3.8'
services:
mongo:
image: mongo:6.0
container_name: mongodb
restart: always
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
mongo-data:

21
dtos/file.dto.js Normal file
View file

@ -0,0 +1,21 @@
function toFileDto(upload) {
return {
filename: upload.filename,
originalFilename: upload.originalFilename,
sizeInBytes: upload.sizeInBytes
};
}
function fileCreationResultDto(filename) {
return { filename };
}
function fileCreationErrorDto(reason) {
return { reason };
}
module.exports = {
toFileDto,
fileCreationResultDto,
fileCreationErrorDto
};

8
dtos/login.dto.js Normal file
View file

@ -0,0 +1,8 @@
const Joi = require('joi');
const loginSchema = Joi.object({
username: Joi.string().required(),
password: Joi.string().required()
});
module.exports = { loginSchema };

8
dtos/password.dto.js Normal file
View file

@ -0,0 +1,8 @@
const Joi = require('joi');
const passwordChangeSchema = Joi.object({
oldPassword: Joi.string().required(),
newPassword: Joi.string().required()
});
module.exports = { passwordChangeSchema };

8
dtos/user.dto.js Normal file
View file

@ -0,0 +1,8 @@
function userInfoDto(user, avatar = null) {
return {
username: user.username,
avatar: avatar || null
};
}
module.exports = { userInfoDto };

30
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

3
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

12
frontend/Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

33
frontend/README.md Normal file
View file

@ -0,0 +1,33 @@
# mizuki-frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4381
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
frontend/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "mizuki-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"axios": "^1.9.0",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"tailwindcss-primeui": "^0.4.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"npm-run-all2": "^7.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.1.10"
}
}

BIN
frontend/package.zip Normal file

Binary file not shown.

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

15
frontend/src/App.vue Normal file
View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import ConfirmDialog from 'primevue/confirmdialog';
import Toast from 'primevue/toast';
</script>
<template>
<Toast />
<ConfirmDialog></ConfirmDialog>
<RouterView />
</template>
<style scoped>
</style>

View file

@ -0,0 +1,42 @@
import axios from "axios";
import router from "@/router";
const axiosClient = axios.create({
baseURL: "http://localhost:3000",
headers: {
"Content-Type": "application/json",
},
});
axiosClient.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, (error) => {
return Promise.reject(error);
});
axiosClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 || error.response?.status === 403) {
const currentRoute = router.currentRoute.value;
const requiresAuth = currentRoute.matched.some(
r => r.meta.requiresAuth
);
if (requiresAuth) {
localStorage.removeItem("token");
router.push("/login");
}
}
return Promise.reject(error);
}
);
export default axiosClient;

16
frontend/src/api/user.ts Normal file
View file

@ -0,0 +1,16 @@
import axiosClient from "@/api/axiosClient";
import type { UserInfoDto } from "@/dto/user-info-dto";
export const checkIfLoggedIn = async (): Promise<boolean> => {
try {
const resp = await axiosClient.get("/api/user/check");
return resp.status === 200;
} catch {
return false;
}
};
export const getUserInfo = async (): Promise<UserInfoDto> => {
const resp = await axiosClient.get("/api/user/info");
return resp.data as UserInfoDto;
};

View file

@ -0,0 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@import 'primeicons/primeicons.css';
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
html, body, #app {
width: 100%;
height: 100%;
}
* {
font-family: "Inter", serif;
}

View file

@ -0,0 +1 @@
@import './base.css';

View file

@ -0,0 +1,63 @@
<script setup lang="ts">
import { onBeforeMount, ref } from "vue";
import Panel from 'primevue/panel';
import ProgressSpinner from 'primevue/progressspinner';
import type FileDto from "@/dto/file-dto.ts";
import FileListItem from "@/components/FileListItem.vue";
import { useToast } from "primevue/usetoast";
import axiosClient from "@/api/axiosClient";
const files = ref<FileDto[] | null>(null);
const toast = useToast();
onBeforeMount(async () => {
await refresh();
});
const refresh = async () => {
files.value = [];
try {
const response = await axiosClient.get<FileDto[]>('/api/file/');
files.value = response.data;
} catch (error) {
console.error("Failed to load files", error);
toast.add({ severity: "error", detail: "Failed to load files." });
files.value = [];
}
};
const onDeleted = async () => {
toast.add({ severity: "success", detail: "File successfully deleted." });
await refresh();
};
const onRenamed = async () => {
toast.add({ severity: "success", detail: "File successfully renamed." });
await refresh();
};
defineExpose({ refresh });
</script>
<template>
<Panel header="Uploaded Files" class="mb-4">
<ProgressSpinner v-if="files === null" class="block mx-auto my-6" />
<div v-else-if="files.length < 1" class="text-center text-gray-500 py-6">
No files uploaded yet.
</div>
<div v-else class="grid gap-5 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<FileListItem
v-for="file of files"
:key="file.filename"
:item="file"
@deleted="onDeleted"
@renamed="onRenamed"
/>
</div>
</Panel>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,106 @@
<script setup lang="ts">
import type FileDto from "@/dto/file-dto.ts";
import Card from 'primevue/card';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Dialog from 'primevue/dialog';
import { ref, computed } from "vue";
import { useConfirm } from "primevue/useconfirm";
import { useToast } from "primevue/usetoast";
import axiosClient from "@/api/axiosClient";
const isImage = computed(() =>
/\.(jpe?g|png|webp|gif|bmp|svg)$/i.test(props.item.originalFilename)
);
const confirm = useConfirm();
const toast = useToast();
const props = defineProps<{
item: FileDto
}>();
const emit = defineEmits(["deleted", "renamed"]);
const url = computed(() => `${axiosClient.defaults.baseURL}/f/${props.item.filename}`);
const showRenameDialog = ref(false);
const newFilename = ref(props.item.originalFilename);
const removeItem = async () => {
confirm.require({
message: `Are you sure you want to delete ${props.item.originalFilename}?`,
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete'
},
accept: async () => {
try {
await axiosClient.delete(`/api/file/${props.item.filename}`);
emit("deleted");
} catch (err) {
console.error("Failed to delete file", err);
}
},
reject: () => { }
});
};
const download = () => {
window.location.href = url.value;
};
const renameFile = async () => {
try {
await axiosClient.put(`/api/file/${props.item.filename}`, {
newFilename: newFilename.value
});
emit("renamed");
showRenameDialog.value = false;
} catch (err) {
console.error("Failed to rename file", err);
toast.add({ severity: 'error', summary: 'Error', detail: 'Could not rename file', life: 3000 });
}
};
</script>
<template>
<Card style="width: 25rem; overflow: hidden;">
<template #title>{{ item.originalFilename }}</template>
<template #content>
<p>Uploaded as {{ item.filename }}</p>
<p>Size in bytes: {{ item.sizeInBytes }}B</p>
<div v-if="isImage" class="mt-2">
<img :src="url" alt="Image preview"
class="w-full max-h-64 object-contain border border-gray-200 rounded shadow" />
</div>
</template>
<template #footer>
<div class="flex justify-between flex-wrap gap-2">
<Button @click.prevent="download" icon="pi pi-download" label="Download" class="flex-1" />
<Button @click.prevent="showRenameDialog = true" icon="pi pi-pencil" label="Rename" severity="warning" outlined
class="flex-1" />
<Button @click.prevent="removeItem" icon="pi pi-trash" label="Delete" severity="danger" outlined
class="flex-1" />
</div>
</template>
</Card>
<Dialog v-model:visible="showRenameDialog" modal header="Rename File" :style="{ width: '30rem' }">
<div class="flex flex-col gap-3">
<label for="newFilename">New Filename</label>
<InputText id="newFilename" v-model="newFilename" class="w-full" />
</div>
<template #footer>
<Button label="Cancel" severity="secondary" outlined @click="showRenameDialog = false" />
<Button label="Save" @click="renameFile" />
</template>
</Dialog>
</template>

View file

@ -0,0 +1,91 @@
<script setup lang="ts">
import Menubar from "primevue/menubar";
import Popover from "primevue/popover";
import Button from "primevue/button";
import Avatar from "primevue/avatar";
import Divider from "primevue/divider";
import { computed, onMounted, ref } from "vue";
import type { MenuItem } from "primevue/menuitem";
import type { UserInfoDto } from "@/dto/user-info-dto.ts";
import { getUserInfo } from "@/api/user.ts";
const info = ref<UserInfoDto>({
username: "[loading]",
avatar: null
});
onMounted(async () => {
info.value = await getUserInfo();
});
const letter = computed(() => {
return info.value.username[0].toLowerCase();
});
const logout = () => {
try {
localStorage.removeItem("token");
window.location.href = "/login";
} catch (err) {
console.error("Logout failed", err);
}
};
const onAvatarClicked = (event: MouseEvent) => {
op.value.toggle(event);
};
const op = ref();
const items: MenuItem[] = [
{
label: "Home",
url: "/",
icon: "pi pi-cloud-upload"
},
{
label: "Settings",
url: "/settings",
icon: "pi pi-folder-open"
}
];
</script>
<template>
<Menubar :model="items" class="rounded-xl border border-surface-200 px-4 py-2">
<template #start>
<div class="text-xl font-black text-primary">Mizuki</div>
</template>
<template #end>
<div class="flex items-center gap-3">
<Avatar
@click="onAvatarClicked"
shape="circle"
:label="letter"
class="cursor-pointer bg-primary text-white font-bold"
size="large"
/>
<Popover ref="op">
<div class="p-4 min-w-[200px] space-y-3">
<p class="text-sm text-gray-500">Zalogowany jako:</p>
<div class="font-semibold">{{ info.username }}</div>
<Divider />
<Button
@click="logout"
severity="secondary"
label="Wyloguj się"
icon="pi pi-sign-out"
class="w-full"
/>
</div>
</Popover>
</div>
</template>
</Menubar>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,159 @@
<script setup lang="ts">
import Message from "primevue/message";
import Password from "primevue/password";
import { Form, type FormResolverOptions, type FormSubmitEvent } from "@primevue/forms";
import Button from "primevue/button";
import Divider from "primevue/divider";
import { useToast } from "primevue/usetoast";
import axiosClient from "@/api/axiosClient";
const toast = useToast();
const formResolver = (ev: FormResolverOptions): Record<string, any> => {
const resp: Record<string, any> = {
errors: {
CurrentPassword: [],
Password: [],
RepeatPassword: []
}
};
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!])[A-Za-z\d@#$%^&*!]{8,}$/;
if (!ev.values["CurrentPassword"]?.match(regex)) {
resp.errors["Password"] = ["Current password doesn't meet requirements."];
}
if (!ev.values["Password"]?.match(regex)) {
resp.errors["Password"] = ["New password doesn't meet requirements."];
}
if (ev.values["RepeatPassword"] !== ev.values["Password"]) {
resp.errors["RepeatPassword"] = ["Passwords do not match."];
}
return resp;
};
const onFormSubmit = async (event: FormSubmitEvent) => {
if (!event.valid) {
toast.add({
severity: "error",
detail: "Invalid data supplied."
});
return;
}
try {
await axiosClient.post("/api/user/change_password", {
oldPassword: event.states.CurrentPassword.value,
newPassword: event.states.Password.value
});
toast.add({
severity: "success",
detail: "Password changed successfully!"
});
} catch (err: any) {
if (err.response?.status === 400) {
toast.add({
severity: "error",
detail: "Password didn't pass security validation."
});
} else if (err.response?.status === 419) {
toast.add({
severity: "error",
detail: "The old password was incorrect."
});
} else {
toast.add({
severity: "error",
detail: "An unexpected error occurred."
});
}
}
};
</script>
<template>
<div class="flex justify-center items-center bg-gradient-to-br from-slate-100 to-white">
<Form
v-slot="$form"
:resolver="formResolver"
:validateOnValueUpdate="true"
:validateOnBlur="true"
@submit="onFormSubmit"
class="w-full max-w-lg space-y-6 bg-white p-10"
>
<div class="space-y-1">
<label for="CurrentPassword" class="block text-sm font-semibold text-gray-700">Current Password</label>
<Password
name="CurrentPassword"
id="CurrentPassword"
placeholder="Enter current password"
toggleMask
class="w-full"
inputClass="w-full"
/>
<Message v-if="$form.CurrentPassword?.invalid" severity="error" size="small">
{{ $form.CurrentPassword.error }}
</Message>
</div>
<div class="space-y-1">
<label for="Password" class="block text-sm font-semibold text-gray-700">New Password</label>
<Password
name="Password"
id="Password"
placeholder="Enter new password"
toggleMask
class="w-full"
inputClass="w-full"
>
<template #header>
<h6 class="text-sm font-semibold text-gray-800">Choose a strong password</h6>
</template>
<template #footer>
<Divider />
<ul class="mt-2 text-xs text-gray-600 list-disc pl-5">
<li>At least one lowercase letter</li>
<li>At least one uppercase letter</li>
<li>At least one digit</li>
<li>At least one special character (@#$%^&*!)</li>
<li>Minimum 8 characters</li>
</ul>
</template>
</Password>
<Message v-if="$form.Password?.invalid" severity="error" size="small">
{{ $form.Password.error }}
</Message>
</div>
<div class="space-y-1">
<label for="RepeatPassword" class="block text-sm font-semibold text-gray-700">Repeat New Password</label>
<Password
name="RepeatPassword"
id="RepeatPassword"
placeholder="Repeat new password"
toggleMask
class="w-full"
inputClass="w-full"
/>
<Message v-if="$form.RepeatPassword?.invalid" severity="error" size="small">
{{ $form.RepeatPassword.error }}
</Message>
</div>
<Button
type="submit"
label="Change Password"
class="w-full bg-blue-600 hover:bg-blue-700 transition duration-150 ease-in-out text-white py-2 rounded-md font-semibold"
/>
</Form>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import FileUpload, { type FileUploadUploadEvent } from 'primevue/fileupload';
import Button from 'primevue/button';
import Badge from 'primevue/badge';
import Message from 'primevue/message';
import Dialog from 'primevue/dialog';
import ProgressBar from 'primevue/progressbar';
import axiosClient from '@/api/axiosClient';
const emit = defineEmits(['uploaded']);
const toast = useToast();
const totalSize = ref(0);
const totalSizePercent = ref(0);
const visible = ref(false);
const filenames = ref<string[]>([]);
const baseUrl = axiosClient.defaults.baseURL;
const formatSize = (bytes: number) => {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return `0 ${sizes[0]}`;
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
};
const updateProgress = () => {
totalSizePercent.value = Math.min((totalSize.value / 1048576) * 100, 100);
};
const resetUploadState = () => {
filenames.value = [];
totalSize.value = 0;
totalSizePercent.value = 0;
};
watch(visible, (val) => {
if (!val) resetUploadState();
});
const onRemoveTemplatingFile = (
file: File,
removeFileCallback: (index: number) => void,
index: number
) => {
removeFileCallback(index);
totalSize.value -= file.size;
updateProgress();
};
const uploadEvent = (callback: () => void) => {
updateProgress();
callback();
};
const copySingleLink = async (filename: string) => {
try {
await navigator.clipboard.writeText(`${baseUrl}/f/${filename}`);
toast.add({ severity: 'success', detail: 'Copied to clipboard!' });
} catch {
toast.add({ severity: 'error', detail: 'Clipboard copy failed' });
}
};
const uploadWithAxios = async (event: FileUploadUploadEvent) => {
const files = event.files;
if (!files?.length) return;
let successfulUpload = false;
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await axiosClient.post('/api/file/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if ('filename' in data) {
filenames.value.push(data.filename);
successfulUpload = true;
} else {
toast.add({
severity: 'error',
detail: `Error uploading file: ${data.reason || 'Unknown error'}`,
});
}
} catch (err) {
toast.add({ severity: 'error', detail: 'Upload failed' });
console.error('Upload error', err);
}
}
if (successfulUpload) {
visible.value = true;
emit('uploaded');
}
};
</script>
<template>
<FileUpload
name="file"
:multiple="true"
:maxFileSize="52428800"
:customUpload="true"
:auto="false"
@uploader="uploadWithAxios"
@select="(e) => {
totalSize.value = e.files.reduce((sum, f) => sum + f.size, 0);
updateProgress();
}"
>
<template #header="{ chooseCallback, uploadCallback, clearCallback, files }">
<div class="flex flex-wrap justify-between items-center w-full gap-4">
<div class="flex gap-2 items-center">
<Button @click="chooseCallback()" icon="pi pi-file" rounded outlined severity="secondary" v-tooltip="'Choose file(s)'" />
<Button @click="uploadEvent(uploadCallback)" icon="pi pi-cloud-upload" rounded outlined severity="success" :disabled="!files?.length" v-tooltip="'Upload file(s)'" />
<Button @click="clearCallback()" icon="pi pi-times" rounded outlined severity="danger" :disabled="!files?.length" v-tooltip="'Clear list'" />
</div>
<div class="flex items-center gap-2 ml-auto">
<span class="text-sm text-color-secondary whitespace-nowrap">{{ formatSize(totalSize) }} / 1MB</span>
<ProgressBar :value="totalSizePercent" :showValue="false" class="w-40 h-2" />
</div>
</div>
</template>
<template #content="{ files, uploadedFiles, removeUploadedFileCallback, removeFileCallback, messages }">
<div class="flex flex-col gap-6 pt-4">
<Message
v-for="message of messages"
:key="message"
severity="error"
class="mb-4"
>
{{ message }}
</Message>
<div v-if="files.length > 0">
<h5 class="text-lg font-semibold mb-2">📤 Pending</h5>
<div class="flex flex-wrap gap-4">
<div
v-for="(file, index) of files"
:key="file.name + file.type + file.size"
class="p-4 border rounded-xl flex flex-col items-center gap-2 w-48 text-center"
>
<span class="font-semibold truncate w-full">{{ file.name }}</span>
<span class="text-sm">{{ formatSize(file.size) }}</span>
<Badge value="Pending" severity="warn" />
<Button icon="pi pi-times" @click="onRemoveTemplatingFile(file, removeFileCallback, index)" outlined rounded severity="danger" />
</div>
</div>
</div>
<div v-if="uploadedFiles.length > 0">
<h5 class="text-lg font-semibold mb-2"> Completed</h5>
<div class="flex flex-wrap gap-4">
<div
v-for="(file, index) of uploadedFiles"
:key="file.name + file.type + file.size"
class="p-4 border rounded-xl flex flex-col items-center gap-2 w-48 text-center"
>
<span class="font-semibold truncate w-full">{{ file.name }}</span>
<span class="text-sm">{{ formatSize(file.size) }}</span>
<Badge value="Completed" severity="success" />
<Button icon="pi pi-times" @click="removeUploadedFileCallback(index)" outlined rounded severity="danger" />
</div>
</div>
</div>
</div>
</template>
<template #empty>
<div class="flex flex-col items-center justify-center p-8">
<i class="pi pi-cloud-upload text-4xl text-primary border-2 rounded-full p-4" />
<p class="mt-4 text-sm">Drag files here or use the button above.</p>
</div>
</template>
</FileUpload>
<Dialog v-model:visible="visible" modal header="Success!" :style="{ width: '30rem' }">
<div class="space-y-4">
<p>Your file(s) have been uploaded. Access links:</p>
<div v-for="file in filenames" :key="file" class="space-y-2">
<div class="bg-gray-900 text-white rounded-lg p-4 break-words text-sm">
{{ baseUrl }}/f/{{ file }}
</div>
<Button label="Copy link" class="w-full" @click.prevent="copySingleLink(file)" />
</div>
</div>
</Dialog>
</template>
<style scoped>
.text-color-secondary {
color: var(--text-color-secondary);
}
</style>

View file

@ -0,0 +1,3 @@
export interface FileCreationErrorDto {
reason: string
}

View file

@ -0,0 +1,3 @@
export interface FileCreationResultDto {
filename: string
}

View file

@ -0,0 +1,5 @@
export default interface FileDto {
filename: string,
originalFilename: string,
sizeInBytes: number
}

View file

@ -0,0 +1,4 @@
export interface PasswordChangeDto {
oldPassword: string;
newPassword: string;
}

View file

@ -0,0 +1,4 @@
export interface UserInfoDto {
username: string,
avatar: string | null
}

25
frontend/src/main.ts Normal file
View file

@ -0,0 +1,25 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import Aura from '@primevue/themes/aura';
import ConfirmationService from 'primevue/confirmationservice';
const app = createApp(App)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.my-app-dark',
}
}
});
app.use(ToastService);
app.use(ConfirmationService);
app.mount('#app')

View file

@ -0,0 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from "@/views/LoginView.vue";
import RegisterView from "@/views/RegisterView.vue";
import SettingsView from "@/views/SettingsView.vue";
import { checkIfLoggedIn } from "@/api/user";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: { requiresAuth: true }
},
{
path: '/settings',
name: 'settings',
component: SettingsView,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginView,
meta: { redirectIfLoggedIn: true }
},
{
path: '/register',
name: 'register',
component: RegisterView,
meta: { redirectIfLoggedIn: true }
}
],
});
router.beforeEach(async (to, from, next) => {
const requiresAuth = to.matched.some(r => r.meta.requiresAuth);
const redirectIfLoggedIn = to.matched.some(r => r.meta.redirectIfLoggedIn);
let loggedIn = false;
try {
loggedIn = await checkIfLoggedIn();
} catch {
}
if (requiresAuth && !loggedIn) {
return next({ name: 'login', query: { error: 'You must be logged in' } });
}
if (redirectIfLoggedIn && loggedIn) {
return next({ name: 'home' });
}
next();
});
export default router;

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import {onBeforeMount, ref} from "vue";
import { useRouter } from "vue-router";
import Uploader from "@/components/Uploader.vue";
import FileList from "@/components/FileList.vue";
import Menu from "@/components/Menu.vue"
const router = useRouter();
const fileListRef = ref<InstanceType<typeof FileList>>();
const onUploaded = async () => {
await fileListRef.value!.refresh();
};
</script>
<template>
<div class="p-5 space-y-5 h-full">
<Menu />
<div>
<Uploader @uploaded="onUploaded" />
</div>
<div>
<FileList ref="fileListRef" />
</div>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import Button from "primevue/button";
import Password from "primevue/password";
import InputText from "primevue/inputtext";
import { Form, type FormSubmitEvent } from "@primevue/forms";
import { useToast } from "primevue/usetoast";
import axiosClient from "@/api/axiosClient";
const router = useRouter();
const route = useRoute();
const toast = useToast();
const username = ref("");
const password = ref("");
onMounted(() => {
if ("error" in route.query) {
toast.add({
severity: "error",
detail: route.query.error
});
}
});
const onFormSubmit = async (ev: FormSubmitEvent) => {
if (!ev.valid) return;
try {
const resp = await axiosClient.post("/api/user/login", {
username: username.value,
password: password.value
});
localStorage.setItem("token", resp.data.token);
toast.add({ severity: "success", detail: "Logged in!" });
await router.push("/");
} catch (err: any) {
const msg = err.response?.data?.reason || "Login failed";
toast.add({ severity: "error", detail: msg });
}
};
</script>
<template>
<div class="min-h-screen bg-gray-100 flex flex-col justify-center items-center px-4 py-12">
<div class="bg-white shadow-lg rounded-2xl p-10 w-full max-w-md">
<h1 class="text-4xl font-bold text-center text-amber-500 mb-6">Mizuki</h1>
<Form @submit="onFormSubmit" class="space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<InputText v-model="username" name="Username" placeholder="Enter your username" required class="w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<Password v-model="password" name="Password" placeholder="Enter your password" :feedback="false" toggleMask required class="w-full" />
</div>
<Button type="submit" label="Login" class="w-full" severity="secondary" />
</Form>
<div class="mt-6 text-center text-sm text-gray-600">
Dont have an account?
<a href="/register" class="text-amber-600 hover:underline">Register here!</a>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import Password from "primevue/password";
import { Form, type FormResolverOptions, type FormSubmitEvent } from "@primevue/forms";
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Message from "primevue/message";
import Divider from "primevue/divider";
import { useToast } from 'primevue/usetoast';
import axiosClient from "@/api/axiosClient";
const toast = useToast();
const route = useRoute();
const router = useRouter();
const username = ref("");
const password = ref("");
const repeatPassword = ref("");
onMounted(() => {
if ("error" in route.query) {
toast.add({
severity: "error",
detail: route.query.error
});
}
});
const onFormSubmit = async (ev: FormSubmitEvent) => {
if (!ev.valid) {
toast.add({ severity: "error", detail: "Invalid data supplied." });
return;
}
try {
const resp = await axiosClient.post("/api/user/register", {
username: username.value,
password: password.value
});
localStorage.setItem("token", resp.data.token);
toast.add({ severity: "success", detail: "Registered successfully!" });
await router.push("/");
} catch (err: any) {
const msg = err.response?.data?.reason || "Registration failed";
toast.add({ severity: "error", detail: msg });
}
};
const formResolver = (ev: FormResolverOptions): Record<string, any> => {
const resp: Record<string, any> = {
errors: {
Username: [],
Password: [],
RepeatPassword: []
}
};
const usernameRegex = /^[a-zA-Z0-9_.]*$/;
if (!ev.values["Username"]?.match(usernameRegex)) {
resp.errors["Username"] = ["Username contains illegal characters or is empty."];
}
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!])[A-Za-z\d@#$%^&*!]{8,}$/;
if (!ev.values["Password"]?.match(regex)) {
resp.errors["Password"] = ["Password doesn't meet requirements."];
}
if (ev.values["RepeatPassword"] !== ev.values["Password"]) {
resp.errors["RepeatPassword"] = ["Passwords do not match."];
}
return resp;
};
</script>
<template>
<div class="min-h-screen bg-gray-100 flex items-center justify-center px-4">
<div class="bg-white shadow-lg rounded-2xl p-8 w-full max-w-md">
<h1 class="text-4xl font-bold text-center text-amber-500 mb-6">Create Account</h1>
<Form v-slot="$form" :resolver="formResolver" :validateOnValueUpdate="true" :validateOnBlur="true" @submit="onFormSubmit" class="space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<InputText v-model="username" name="Username" placeholder="Enter username" class="w-full" required />
<Message v-if="$form.Username?.invalid" severity="error" size="small" class="mt-1">{{ $form.Username.error }}</Message>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<Password v-model="password" name="Password" placeholder="Enter password" toggleMask class="w-full" required>
<template #header>
<h6 class="text-sm font-medium">Pick a secure password</h6>
</template>
<template #footer>
<Divider class="my-2" />
<p class="text-sm mb-1">Requirements:</p>
<ul class="text-sm text-gray-600 list-disc list-inside space-y-1">
<li>At least one lowercase</li>
<li>At least one uppercase</li>
<li>At least one numeric</li>
<li>At least one of @#$%^&*!</li>
<li>Minimum 8 characters</li>
</ul>
</template>
</Password>
<Message v-if="$form.Password?.invalid" severity="error" size="small" class="mt-1">{{ $form.Password.error }}</Message>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Repeat Password</label>
<Password v-model="repeatPassword" name="RepeatPassword" placeholder="Repeat password" toggleMask class="w-full" required />
<Message v-if="$form.RepeatPassword?.invalid" severity="error" size="small" class="mt-1">{{ $form.RepeatPassword.error }}</Message>
</div>
<Button type="submit" label="Register" class="w-full" severity="secondary" />
<div class="text-center text-sm text-gray-600">
Already have an account?
<a href="/login" class="text-amber-600 hover:underline">Log in here!</a>
</div>
</Form>
</div>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import Menu from "@/components/Menu.vue";
import Panel from "primevue/panel";
import Fieldset from 'primevue/fieldset';
import PasswordChanger from "@/components/PasswordChanger.vue";
</script>
<template>
<div class="p-5 space-y-5 h-full">
<Menu />
<Panel header="Settings" class="h-[85vh]">
<Fieldset legend="Password" class="w-max">
<PasswordChanger />
</Fieldset>
</Panel>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'
],
theme: {
extend: {},
},
plugins: [require('tailwindcss-primeui')],
}

View file

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View file

@ -0,0 +1,18 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

29
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'^/api': {
target: 'http://localhost:5118',
changeOrigin: true,
cookiePathRewrite: {
"*": "/",
}
}
}
}
})

26
index.js Normal file
View file

@ -0,0 +1,26 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const connectDB = require('./db/db');
const PORT = 3000;
const app = express();
connectDB();
app.use(express.json());
app.use(cors({
origin: "http://localhost:5173",
credentials: true,
allowedHeaders: ["Content-Type", "Authorization"]
}));
app.use('/api/user', require('./controllers/auth.controller'));
app.use('/api/file', require('./controllers/file.controller'));
app.use('/f', require('./controllers/serve.controller'));
// Start server
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});

28
middleware/auth.js Normal file
View file

@ -0,0 +1,28 @@
const jwt = require('jsonwebtoken');
const UserService = require('../services/userService');
const SECRET = process.env.JWT_SECRET;
function generateToken(user) {
return jwt.sign({ id: user._id, username: user.username }, SECRET, { expiresIn: '1d' });
}
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.sendStatus(401);
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
return res.sendStatus(403);
}
}
async function getActiveUser(req) {
if (!req.user?.username) return null;
return await UserService.getUserByUsername(req.user.username);
}
module.exports = { generateToken, authMiddleware, getActiveUser };

29
models/Upload.js Normal file
View file

@ -0,0 +1,29 @@
const mongoose = require('mongoose');
const uploadSchema = new mongoose.Schema({
filename: {
type: String,
required: true,
unique: true
},
originalFilename: {
type: String,
required: true
},
sizeInBytes: {
type: Number,
required: true
},
timeOfUpload: {
type: Date,
required: true,
default: Date.now
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
});
module.exports = mongoose.model('Upload', uploadSchema);

15
models/User.js Normal file
View file

@ -0,0 +1,15 @@
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true
},
passwordHash: {
type: String,
required: true
}
});
module.exports = mongoose.model('User', userSchema);

1652
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "mizuki-express",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"argon2": "^0.43.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.15.1",
"multer": "^2.0.0"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

28
services/driveService.js Normal file
View file

@ -0,0 +1,28 @@
const fs = require('fs');
const path = require('path');
const UPLOADS_FOLDER = './uploads';
if (!fs.existsSync(UPLOADS_FOLDER)) {
fs.mkdirSync(UPLOADS_FOLDER);
}
function saveFile(filename, stream) {
const targetPath = path.join(UPLOADS_FOLDER, path.basename(filename));
const writeStream = fs.createWriteStream(targetPath);
stream.pipe(writeStream);
}
function deleteFile(filename) {
const targetPath = path.join(UPLOADS_FOLDER, path.basename(filename));
if (fs.existsSync(targetPath)) {
fs.unlinkSync(targetPath);
}
}
function openFile(filename) {
const targetPath = path.join(UPLOADS_FOLDER, path.basename(filename));
return fs.existsSync(targetPath) ? fs.createReadStream(targetPath) : null;
}
module.exports = { saveFile, deleteFile, openFile };

54
services/uploadService.js Normal file
View file

@ -0,0 +1,54 @@
const Upload = require('../models/Upload');
const drive = require('./driveService');
const { randomUUID } = require('crypto');
const { Readable } = require('stream');
async function createUpload(user, file) {
const filename = randomUUID();
const upload = new Upload({
filename,
originalFilename: file.originalname,
sizeInBytes: file.size,
timeOfUpload: new Date(),
author: user._id
});
await upload.save();
const stream = Readable.from(file.buffer);
drive.saveFile(filename, stream);
return upload;
}
async function deleteUpload(user, filename) {
const upload = await Upload.findOne({ filename, author: user._id });
if (!upload) return;
await upload.deleteOne();
drive.deleteFile(filename);
}
async function getUserUploads(user) {
return await Upload.find({ author: user._id }).sort({ timeOfUpload: -1 });
}
async function getUploadByFilename(filename) {
return await Upload.findOne({ filename });
}
async function renameUpload(user, oldFilename, newFilename) {
const upload = await Upload.findOne({ filename: oldFilename, author: user._id });
if (!upload) return false;
upload.originalFilename = newFilename;
await upload.save();
return true;
}
module.exports = {
createUpload,
deleteUpload,
getUserUploads,
getUploadByFilename,
renameUpload
};

36
services/userService.js Normal file
View file

@ -0,0 +1,36 @@
const User = require('../models/User');
const argon2 = require('argon2');
async function usernameTaken(username) {
return await User.exists({ username });
}
async function getUserByUsername(username) {
return await User.findOne({ username });
}
async function checkPassword(username, password) {
const user = await User.findOne({ username });
if (!user) return false;
return await argon2.verify(user.passwordHash, password);
}
async function createUser(username, password) {
if (await usernameTaken(username)) return null;
const hash = await argon2.hash(password);
const newUser = new User({ username, passwordHash: hash });
return await newUser.save();
}
async function updatePassword(userId, newPassword) {
const hash = await argon2.hash(newPassword);
return await User.findByIdAndUpdate(userId, { passwordHash: hash });
}
module.exports = {
usernameTaken,
getUserByUsername,
checkPassword,
createUser,
updatePassword
};

View file

@ -0,0 +1,11 @@
const Joi = require('joi');
const loginSchema = Joi.object({
username: Joi.string().regex(/^[a-zA-Z0-9_.]*$/).required(),
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!]).+$/)
.required()
});
module.exports = { loginSchema };

View file

@ -0,0 +1,11 @@
const Joi = require('joi');
const passwordChangeSchema = Joi.object({
oldPassword: Joi.string().min(8).required(),
newPassword: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!]).+$/)
.required()
});
module.exports = { passwordChangeSchema };

View file

@ -0,0 +1,12 @@
function validateFile(file) {
const MAX_SIZE = 50 * 1024 * 1024; // 50MiB
const filenameRegex = /^(?!\s)(?!.*\s$)[^<>:"/\\|?*\x00-\x1F]+\.(?!\.)[^<>:"/\\|?*\x00-\x1F]{1,4}$|^(?!\s)(?!.*\s$)[^<>:"/\\|?*\x00-\x1F]+$/;
if (!file) return { valid: false, reason: 'No file provided' };
if (file.size > MAX_SIZE) return { valid: false, reason: 'The file is too big. (Max is 50MiB)' };
if (!filenameRegex.test(file.originalname)) return { valid: false, reason: 'Invalid filename.' };
return { valid: true };
}
module.exports = { validateFile };