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", "name": "client",
"version": "0.0.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,12 +1,15 @@
import axios from 'axios' import axios from 'axios'
import { type autocompleteRecord, type DBRecord} from '@/DBRecord.ts' 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) => { export const addMusicScan = (musicData: FormData) => {
if (!session.token) throw new Error('Unauthorized: No session token found')
return axios.post(`${API_URL}/add`, musicData, { return axios.post(`${API_URL}/add`, musicData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
authorization: session.token
}, },
}) })
} }
@ -18,7 +21,8 @@ export const searchMusicScans = (filters: any) => {
} }
export const deleteMusicScan = (id: number) => { 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) => { export const getMusicScanById = (id: string) => {
@ -26,11 +30,14 @@ export const getMusicScanById = (id: string) => {
} }
export const updateMusicScanById = (id: string, data: DBRecord) => { 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) => { 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 = () => { export const getAllAutocompleteOptions = () => {

View File

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

View File

@ -7,6 +7,16 @@ import router from './router'
import './assets/styles.scss' import './assets/styles.scss'
import Oruga from '@oruga-ui/oruga-next' import Oruga from '@oruga-ui/oruga-next'
import ToastPlugin from 'vue-toast-notification'; 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) const app = createApp(App)

View File

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

View File

@ -29,6 +29,7 @@ const showPassword = ref(false)
<button type="button" class="password-toggle-btn svg" <button type="button" class="password-toggle-btn svg"
style="cursor:pointer; border-left:0; z-index: 9999" style="cursor:pointer; border-left:0; z-index: 9999"
@click="showPassword = !showPassword"> @click="showPassword = !showPassword">
<!--suppress HtmlDeprecatedAttribute -->
<svg v-if="showPassword" width="20px" height="20px" fill="currentColor" <svg v-if="showPassword" width="20px" height="20px" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27"> > xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27"> >
<path clip-rule="evenodd" <path clip-rule="evenodd"
@ -37,6 +38,7 @@ const showPassword = ref(false)
<path <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" /> 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> </svg>
<!--suppress HtmlDeprecatedAttribute -->
<svg v-else width="20px" height="20px" fill="currentColor" <svg v-else width="20px" height="20px" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27"> xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27">
<path clip-rule="evenodd" <path clip-rule="evenodd"
@ -51,7 +53,7 @@ const showPassword = ref(false)
</div> </div>
</div> </div>
<div class="d-grid"> <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 @click.prevent="login(username, password)">Login
</button> </button>
</div> </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.jewish" label="Jewish" :undefined-option="true"/>
<radio-group v-model="filters.choral" label="Choral" :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"/> <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> </form>
</div> </div>
</div> </div>
@ -94,20 +94,5 @@ watch(filters.value, (value) => {
</template> </template>
<style scoped> <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> </style>

View File

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

View File

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