feat: Implement session-based authorization for API requests and update upload folder path
All checks were successful
deploy / deploy (push) Successful in 3m55s

This commit is contained in:
Ari Yeger
2025-07-18 18:19:47 -04:00
parent 9c78322779
commit ebf42e073a
9 changed files with 85 additions and 68 deletions

View File

@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.0.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,12 +1,15 @@
import axios from 'axios'
import { type autocompleteRecord, type DBRecord} from '@/DBRecord.ts'
import { session } from '@/session.ts'
const API_URL = `${window.location.origin}/api/music-scans`
const API_URL = (import.meta.env.VITE_API_ROOT ?? window.location.origin) + "/api/music-scans"
export const addMusicScan = (musicData: FormData) => {
if (!session.token) throw new Error('Unauthorized: No session token found')
return axios.post(`${API_URL}/add`, musicData, {
headers: {
'Content-Type': 'multipart/form-data',
authorization: session.token
},
})
}
@ -18,7 +21,8 @@ export const searchMusicScans = (filters: any) => {
}
export const deleteMusicScan = (id: number) => {
return axios.delete(`${API_URL}/delete/${id}`)
if (!session.token) throw new Error('Unauthorized: No session token found')
return axios.delete(`${API_URL}/delete/${id}`, {headers: {authorization: session.token}})
}
export const getMusicScanById = (id: string) => {
@ -26,11 +30,14 @@ export const getMusicScanById = (id: string) => {
}
export const updateMusicScanById = (id: string, data: DBRecord) => {
return axios.patch(`${API_URL}/scan/${id}`, data)
if (!session.token) throw new Error('Unauthorized: No session token found')
return axios.patch(`${API_URL}/scan/${id}`, data, {headers: {authorization: session.token}})
}
export const downloadMusicScan = async (id: number) => {
return axios.get(`${API_URL}/download/${id}`, { responseType: 'blob' }).then((response) => response.data)
if (!session.token) throw new Error('Unauthorized: No session token found')
return axios.get(`${API_URL}/download/${id}`, { responseType: 'blob', headers: {authorization: session.token} })
.then((response) => response.data)
}
export const getAllAutocompleteOptions = () => {

View File

@ -5,6 +5,8 @@ import { nextTick, ref, watch } from 'vue'
import { deleteMusicScan, downloadMusicScan, getMusicScanById, searchMusicScans } from '@/api.ts'
import { saveAs } from 'file-saver'
import { filters, type ServerResponseRecord } from '@/DBRecord.ts'
import { toast } from '@/toast.ts'
import { session } from '@/session'
const totalPages = ref<number>(0)
const results = defineModel<ServerResponseRecord[]>('results')
@ -63,11 +65,20 @@ const confirmDelete = (id: number | undefined) => {
}
}
const downloadMusic = async (id: number | undefined) => {
if (session.user?.role !== 'admin') {
toast.error('You do not have permission to download this file.')
return
}
if (!id) {
toast.error('No ID provided for download.')
return
}
try {
const file = await downloadMusicScan(id)
if (!file) {
toast.error('An error occurred while downloading the file. Please contact the site administrator.')
return
}
const filename = await getMusicScanById(id.toString()).then((response) => response.data.name)
saveAs(file, filename)
} catch (error) {
@ -78,41 +89,43 @@ const downloadMusic = async (id: number | undefined) => {
</script>
<template>
<div class="container is-fluid" v-if="shownResults.length != 0">
<div class="container is-fluid" v-if="shownResults.length != 0">
<table class="table is-fullwidth is-striped is-bordered">
<thead>
<tr>
<th class="has-text-weight-bold has-text-centered">Name</th>
<th class="has-text-weight-bold has-text-centered">Composer</th>
<th class="has-text-weight-bold has-text-centered">Arranged By</th>
<th class="has-text-weight-bold has-text-centered">Words By</th>
<th class="has-text-weight-bold has-text-centered">Year</th>
<th class="has-text-weight-bold has-text-centered">Genre</th>
<th class="has-text-weight-bold has-text-centered">Language</th>
<th class="has-text-weight-bold has-text-centered">Scored For</th>
<th class="has-text-weight-bold has-text-centered">Controls</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Name</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Composer</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Arranged By</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Words By</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Year</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Genre</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Language</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Scored For</th>
<th class="has-text-weight-bold has-text-centered is-align-content-center">Controls</th>
</tr>
</thead>
<tbody>
<tr v-for="scan in shownResults" :key="scan!.id">
<td>{{ scan.name }}</td>
<td
:class="{ 'is-italic': scan.composer == 'unknown' }">
<td class="is-align-content-center">{{ scan.name }}</td>
<td class="is-align-content-center"
:class="{ 'is-italic': scan.composer === 'unknown' }">
{{ scan.composer }}
</td>
<td
:class="{ 'is-italic': scan.arranger == 'unknown' }">
<td class="is-align-content-center"
:class="{ 'is-italic': scan.arranger === 'unknown' }">
{{ scan.arranger }}
</td>
<td
:class="{ 'is-italic': scan.words == 'unknown' }">
<td class="is-align-content-center"
:class="{ 'is-italic': scan.words === 'unknown' }">
{{ scan.words }}
</td>
<td :class="{ 'is-italic': !scan.year }">{{ scan.year ? scan.year : 'null' }}</td>
<td>{{ scan.genre }}</td>
<td>{{ scan.language }}</td>
<td>{{ scan.instrument }}</td>
<td class="buttons has-addons" style="display: inline-flex; flex-wrap: nowrap;">
<td class="is-align-content-center"
:class="{ 'is-italic': !scan.year }">{{ scan.year ? scan.year : 'null' }}</td>
<td class="is-align-content-center">{{ scan.genre }}</td>
<td class="is-align-content-center">{{ scan.language }}</td>
<td class="is-align-content-center">{{ scan.instrument }}</td>
<td class="is-align-content-center">
<div class="buttons has-addons button-group">
<button class="button is-small is-info" @click="downloadMusic(scan.id)">
<font-awesome-icon :icon="faDownload" />
</button>
@ -122,6 +135,7 @@ const downloadMusic = async (id: number | undefined) => {
<button class="button is-small is-danger" @click="confirmDelete(scan.id)">
<font-awesome-icon :icon="faTrash" />
</button>
</div>
</td>
</tr>
</tbody>
@ -152,15 +166,13 @@ const downloadMusic = async (id: number | undefined) => {
</template>
<style scoped>
td.buttons {
display: flex; /* Make the cell flexible */
align-items: stretch; /* Ensure it stretches to match the row height */
td {
text-align: center;
}
td.buttons .button-group {
display: flex;
div.button-group {
display: inline-flex;
flex-wrap: nowrap;
gap: 0.25rem; /* Small space between buttons */
align-self: center; /* Center the buttons vertically */
justify-content: center;
align-items: center;
}
</style>

View File

@ -7,6 +7,16 @@ import router from './router'
import './assets/styles.scss'
import Oruga from '@oruga-ui/oruga-next'
import ToastPlugin from 'vue-toast-notification';
import { session } from './session';
if (localStorage.getItem('token') && localStorage.getItem('username') && localStorage.getItem('role')) {
session.user = {
username: localStorage.getItem('username') || '',
token: localStorage.getItem('token') || '',
role: localStorage.getItem('role') || 'user',
};
session.token = localStorage.getItem('token');
}
const app = createApp(App)

View File

@ -33,6 +33,7 @@ export function useLogin() {
session.redirectURL = null;
toast.success("Welcome " + session.user.username + "!\nYou are now logged in.");
localStorage.setItem("username", session.user.username);
localStorage.setItem("role", session.user.role ?? "user");
localStorage.setItem("token", session.token ?? "");
return session.user;
})
@ -42,6 +43,7 @@ export function useLogin() {
session.user = null;
session.token = null;
localStorage.removeItem("token");
localStorage.removeItem("role");
localStorage.removeItem("username");
for(let i = 0; i < session.messages.length; i++) {
console.debug("Messages: ");

View File

@ -29,6 +29,7 @@ const showPassword = ref(false)
<button type="button" class="password-toggle-btn svg"
style="cursor:pointer; border-left:0; z-index: 9999"
@click="showPassword = !showPassword">
<!--suppress HtmlDeprecatedAttribute -->
<svg v-if="showPassword" width="20px" height="20px" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27"> >
<path clip-rule="evenodd"
@ -37,6 +38,7 @@ const showPassword = ref(false)
<path
d="M14.6873 22.1761C19.1311 21.9148 23.4056 19.0687 26.8864 13.931C26.9593 13.8234 27 13.7121 27 13.5797C27 13.4535 26.965 13.3481 26.8956 13.2455C25.5579 11.2677 24.1025 9.62885 22.5652 8.34557L21.506 10.2052C22.3887 10.9653 23.2531 11.87 24.0894 12.9239C24.3904 13.3032 24.3904 13.8561 24.0894 14.2354C21.5676 17.4135 18.7903 19.2357 16.0254 19.827L14.6873 22.1761Z" />
</svg>
<!--suppress HtmlDeprecatedAttribute -->
<svg v-else width="20px" height="20px" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27">
<path clip-rule="evenodd"
@ -51,7 +53,7 @@ const showPassword = ref(false)
</div>
</div>
<div class="d-grid">
<button class="btn btn-submit mt-4" type="submit"
<button class="button is-primary is-fullwidth" type="submit"
@click.prevent="login(username, password)">Login
</button>
</div>

View File

@ -86,7 +86,7 @@ watch(filters.value, (value) => {
<radio-group v-model="filters.jewish" label="Jewish" :undefined-option="true"/>
<radio-group v-model="filters.choral" label="Choral" :undefined-option="true"/>
<radio-group v-model="filters.liturgical" label="Liturgical" :undefined-option="true"/>
<button class="button is-primary" type="submit">Search</button>
<button class="button is-primary is-fullwidth" type="submit">Search</button>
</form>
</div>
</div>
@ -94,20 +94,5 @@ watch(filters.value, (value) => {
</template>
<style scoped>
div.column.is-half {
padding-bottom: 0;
}
/*input {
overflow-y: visible !important;
}*/
/*.field {
position: relative !important;
overflow: visible !important;
}*/
/*form {
overflow: visible !important;
}*/
</style>

View File

@ -37,9 +37,8 @@ app.use("/edit/:id", express.static('../client/dist'));
app.use("/login", express.static('../client/dist'));
app.use("/register", express.static('../client/dist'));
app.use("/assets", express.static("../client/dist/assets"));
const UPLOAD_FOLDER = path.join(process.cwd(), 'Scans');
const __dirname = process.env.NODE_ENV === 'development' ? process.cwd() : "/app/storage";
const UPLOAD_FOLDER = path.join(__dirname, 'Scans');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_FOLDER);
@ -51,7 +50,7 @@ const storage = multer.diskStorage({
const upload = multer({ storage });
// Add a new music scan
app.post('/api/music-scans/add', upload.single('file'), async (req, res) => {
app.post('/api/music-scans/add', AuthHandler, upload.single('file'), async (req, res) => {
try {
const { name, composer, arranger, words, year, genre, jewish, choral, liturgical, language, instrument } = req.body;
const safeName = name.replace(/[^a-zA-Z0-9_\- ]/g, '').toLowerCase() + '.pdf';

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.1.0",
"version": "0.2.0",
"private": false,
"type": "module",
"scripts": {