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