feat: init
This commit is contained in:
commit
a458a8a103
61 changed files with 7998 additions and 0 deletions
1
.env
Normal file
1
.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
JWT_SECRET=supersekretnyklucz123
|
||||
174
.gitignore
vendored
Normal file
174
.gitignore
vendored
Normal 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
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM node:20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
60
controllers/auth.controller.js
Normal file
60
controllers/auth.controller.js
Normal 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;
|
||||
67
controllers/file.controller.js
Normal file
67
controllers/file.controller.js
Normal 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;
|
||||
17
controllers/serve.controller.js
Normal file
17
controllers/serve.controller.js
Normal 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
14
db/db.js
Normal 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
17
docker-compose.yml
Normal 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
21
dtos/file.dto.js
Normal 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
8
dtos/login.dto.js
Normal 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
8
dtos/password.dto.js
Normal 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
8
dtos/user.dto.js
Normal 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
30
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
33
frontend/README.md
Normal 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
1
frontend/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
4381
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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
BIN
frontend/package.zip
Normal file
Binary file not shown.
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
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
15
frontend/src/App.vue
Normal 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>
|
||||
42
frontend/src/api/axiosClient.ts
Normal file
42
frontend/src/api/axiosClient.ts
Normal 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
16
frontend/src/api/user.ts
Normal 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;
|
||||
};
|
||||
14
frontend/src/assets/base.css
Normal file
14
frontend/src/assets/base.css
Normal 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;
|
||||
}
|
||||
1
frontend/src/assets/main.css
Normal file
1
frontend/src/assets/main.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import './base.css';
|
||||
63
frontend/src/components/FileList.vue
Normal file
63
frontend/src/components/FileList.vue
Normal 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>
|
||||
106
frontend/src/components/FileListItem.vue
Normal file
106
frontend/src/components/FileListItem.vue
Normal 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>
|
||||
91
frontend/src/components/Menu.vue
Normal file
91
frontend/src/components/Menu.vue
Normal 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>
|
||||
159
frontend/src/components/PasswordChanger.vue
Normal file
159
frontend/src/components/PasswordChanger.vue
Normal 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>
|
||||
201
frontend/src/components/Uploader.vue
Normal file
201
frontend/src/components/Uploader.vue
Normal 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>
|
||||
3
frontend/src/dto/file-creation-error-dto.ts
Normal file
3
frontend/src/dto/file-creation-error-dto.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface FileCreationErrorDto {
|
||||
reason: string
|
||||
}
|
||||
3
frontend/src/dto/file-creation-result-dto.ts
Normal file
3
frontend/src/dto/file-creation-result-dto.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface FileCreationResultDto {
|
||||
filename: string
|
||||
}
|
||||
5
frontend/src/dto/file-dto.ts
Normal file
5
frontend/src/dto/file-dto.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default interface FileDto {
|
||||
filename: string,
|
||||
originalFilename: string,
|
||||
sizeInBytes: number
|
||||
}
|
||||
4
frontend/src/dto/password-change-dto.ts
Normal file
4
frontend/src/dto/password-change-dto.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface PasswordChangeDto {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
4
frontend/src/dto/user-info-dto.ts
Normal file
4
frontend/src/dto/user-info-dto.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface UserInfoDto {
|
||||
username: string,
|
||||
avatar: string | null
|
||||
}
|
||||
25
frontend/src/main.ts
Normal file
25
frontend/src/main.ts
Normal 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')
|
||||
60
frontend/src/router/index.ts
Normal file
60
frontend/src/router/index.ts
Normal 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;
|
||||
30
frontend/src/views/HomeView.vue
Normal file
30
frontend/src/views/HomeView.vue
Normal 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>
|
||||
74
frontend/src/views/LoginView.vue
Normal file
74
frontend/src/views/LoginView.vue
Normal 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">
|
||||
Don’t have an account?
|
||||
<a href="/register" class="text-amber-600 hover:underline">Register here!</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
130
frontend/src/views/RegisterView.vue
Normal file
130
frontend/src/views/RegisterView.vue
Normal 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>
|
||||
21
frontend/src/views/SettingsView.vue
Normal file
21
frontend/src/views/SettingsView.vue
Normal 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>
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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')],
|
||||
}
|
||||
|
||||
12
frontend/tsconfig.app.json
Normal file
12
frontend/tsconfig.app.json
Normal 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
11
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
frontend/tsconfig.node.json
Normal file
18
frontend/tsconfig.node.json
Normal 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
29
frontend/vite.config.ts
Normal 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
26
index.js
Normal 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
28
middleware/auth.js
Normal 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
29
models/Upload.js
Normal 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
15
models/User.js
Normal 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
1652
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
package.json
Normal file
25
package.json
Normal 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
28
services/driveService.js
Normal 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
54
services/uploadService.js
Normal 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
36
services/userService.js
Normal 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
|
||||
};
|
||||
11
validators/login.validator.js
Normal file
11
validators/login.validator.js
Normal 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 };
|
||||
11
validators/password.validator.js
Normal file
11
validators/password.validator.js
Normal 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 };
|
||||
12
validators/upload.validator.js
Normal file
12
validators/upload.validator.js
Normal 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 };
|
||||
Loading…
Reference in a new issue