feat: Multiple Features, Accessibility, Security, and UX Improvements #59

Merged
naomi merged 27 commits from feat/polish into main 2026-02-20 01:51:25 -08:00
4 changed files with 254 additions and 0 deletions
Showing only changes of commit abb39c67f2 - Show all commits
+62
View File
@@ -6,12 +6,68 @@
import { Art, CreateArtDto, UpdateArtDto } from "@library/shared-types"; import { Art, CreateArtDto, UpdateArtDto } from "@library/shared-types";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import {
validateUrl,
validateStringLength,
MAX_LENGTHS,
} from "../utils/validation";
export class ArtService { export class ArtService {
private prisma = prisma; private prisma = prisma;
constructor() {} constructor() {}
/**
* Validate art data for security.
*/
private validateArtData(data: CreateArtDto | UpdateArtDto): void {
// Validate string lengths
if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(data.artist, MAX_LENGTHS.AUTHOR)) {
throw new Error(`Artist must be ${MAX_LENGTHS.AUTHOR} characters or less.`);
}
if (!validateStringLength(data.description, MAX_LENGTHS.DESCRIPTION)) {
throw new Error(`Description must be ${MAX_LENGTHS.DESCRIPTION} characters or less.`);
}
// Validate image URL (required)
if (!data.imageUrl) {
throw new Error("Image URL is required.");
}
if (!validateUrl(data.imageUrl)) {
throw new Error("Invalid image URL. Only http and https URLs are allowed.");
}
if (!validateStringLength(data.imageUrl, MAX_LENGTHS.URL)) {
throw new Error(`Image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate tags
if (data.tags) {
for (const tag of data.tags) {
if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) {
throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`);
}
}
}
// Validate link URLs
if (data.links) {
for (const link of data.links) {
if (!validateUrl(link.url)) {
throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`);
}
if (!validateStringLength(link.title, MAX_LENGTHS.AUTHOR)) {
throw new Error(`Link title must be ${MAX_LENGTHS.AUTHOR} characters or less.`);
}
if (!validateStringLength(link.url, MAX_LENGTHS.URL)) {
throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
}
}
}
/** /**
* Get all art pieces. * Get all art pieces.
*/ */
@@ -56,6 +112,9 @@ export class ArtService {
* Create new art piece. * Create new art piece.
*/ */
async createArt(data: CreateArtDto): Promise<Art> { async createArt(data: CreateArtDto): Promise<Art> {
// Validate input
this.validateArtData(data);
const art = await this.prisma.art.create({ const art = await this.prisma.art.create({
data, data,
}); });
@@ -75,6 +134,9 @@ export class ArtService {
* Update art by ID. * Update art by ID.
*/ */
async updateArt(id: string, data: UpdateArtDto): Promise<Art> { async updateArt(id: string, data: UpdateArtDto): Promise<Art> {
// Validate input
this.validateArtData(data);
const art = await this.prisma.art.update({ const art = await this.prisma.art.update({
where: { id }, where: { id },
data, data,
+65
View File
@@ -6,12 +6,71 @@
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto } from "@library/shared-types"; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto } from "@library/shared-types";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import {
validateUrl,
validateRating,
validateStringLength,
MAX_LENGTHS,
} from "../utils/validation";
export class MangaService { export class MangaService {
private prisma = prisma; private prisma = prisma;
constructor() {} constructor() {}
/**
* Validate manga data for security.
*/
private validateMangaData(data: CreateMangaDto | UpdateMangaDto): void {
// Validate string lengths
if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(data.author, MAX_LENGTHS.AUTHOR)) {
throw new Error(`Author must be ${MAX_LENGTHS.AUTHOR} characters or less.`);
}
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (!validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
}
// Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
// Validate tags
if (data.tags) {
for (const tag of data.tags) {
if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) {
throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`);
}
}
}
// Validate link URLs
if (data.links) {
for (const link of data.links) {
if (!validateUrl(link.url)) {
throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`);
}
if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(link.url, MAX_LENGTHS.URL)) {
throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
}
}
}
async getAllManga(): Promise<Manga[]> { async getAllManga(): Promise<Manga[]> {
const manga = await this.prisma.manga.findMany({ const manga = await this.prisma.manga.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
@@ -53,6 +112,9 @@ export class MangaService {
} }
async createManga(data: CreateMangaDto): Promise<Manga> { async createManga(data: CreateMangaDto): Promise<Manga> {
// Validate input
this.validateMangaData(data);
const manga = await this.prisma.manga.create({ const manga = await this.prisma.manga.create({
data: { data: {
...data, ...data,
@@ -75,6 +137,9 @@ export class MangaService {
} }
async updateManga(id: string, data: UpdateMangaDto): Promise<Manga> { async updateManga(id: string, data: UpdateMangaDto): Promise<Manga> {
// Validate input
this.validateMangaData(data);
const updateData = { ...data }; const updateData = { ...data };
if (updateData.status) { if (updateData.status) {
updateData.status = updateData.status.toUpperCase() as any; updateData.status = updateData.status.toUpperCase() as any;
+65
View File
@@ -6,12 +6,71 @@
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto } from "@library/shared-types"; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto } from "@library/shared-types";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import {
validateUrl,
validateRating,
validateStringLength,
MAX_LENGTHS,
} from "../utils/validation";
export class MusicService { export class MusicService {
private prisma = prisma; private prisma = prisma;
constructor() {} constructor() {}
/**
* Validate music data for security.
*/
private validateMusicData(data: CreateMusicDto | UpdateMusicDto): void {
// Validate string lengths
if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(data.artist, MAX_LENGTHS.AUTHOR)) {
throw new Error(`Artist must be ${MAX_LENGTHS.AUTHOR} characters or less.`);
}
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (data.rating !== undefined && !validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
}
// Validate cover art URL
if (data.coverArt && !validateUrl(data.coverArt)) {
throw new Error("Invalid cover art URL. Only http and https URLs are allowed.");
}
// Validate tags
if (data.tags) {
for (const tag of data.tags) {
if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) {
throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`);
}
}
}
// Validate link URLs
if (data.links) {
for (const link of data.links) {
if (!validateUrl(link.url)) {
throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`);
}
if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(link.url, MAX_LENGTHS.URL)) {
throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
}
}
}
/** /**
* Get all music. * Get all music.
*/ */
@@ -64,6 +123,9 @@ export class MusicService {
* Create new music. * Create new music.
*/ */
async createMusic(data: CreateMusicDto): Promise<Music> { async createMusic(data: CreateMusicDto): Promise<Music> {
// Validate input
this.validateMusicData(data);
const music = await this.prisma.music.create({ const music = await this.prisma.music.create({
data: { data: {
...data, ...data,
@@ -91,6 +153,9 @@ export class MusicService {
* Update music by ID. * Update music by ID.
*/ */
async updateMusic(id: string, data: UpdateMusicDto): Promise<Music> { async updateMusic(id: string, data: UpdateMusicDto): Promise<Music> {
// Validate input
this.validateMusicData(data);
const updateData = { ...data }; const updateData = { ...data };
if (updateData.type) { if (updateData.type) {
updateData.type = updateData.type.toUpperCase() as any; updateData.type = updateData.type.toUpperCase() as any;
+62
View File
@@ -6,12 +6,68 @@
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto } from "@library/shared-types"; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto } from "@library/shared-types";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import {
validateUrl,
validateRating,
validateStringLength,
MAX_LENGTHS,
} from "../utils/validation";
export class ShowService { export class ShowService {
private prisma = prisma; private prisma = prisma;
constructor() {} constructor() {}
/**
* Validate show data for security.
*/
private validateShowData(data: CreateShowDto | UpdateShowDto): void {
// Validate string lengths
if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (!validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
}
// Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
// Validate tags
if (data.tags) {
for (const tag of data.tags) {
if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) {
throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`);
}
}
}
// Validate link URLs
if (data.links) {
for (const link of data.links) {
if (!validateUrl(link.url)) {
throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`);
}
if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) {
throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`);
}
if (!validateStringLength(link.url, MAX_LENGTHS.URL)) {
throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
}
}
}
async getAllShows(): Promise<Show[]> { async getAllShows(): Promise<Show[]> {
const shows = await this.prisma.show.findMany({ const shows = await this.prisma.show.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
@@ -55,6 +111,9 @@ export class ShowService {
} }
async createShow(data: CreateShowDto): Promise<Show> { async createShow(data: CreateShowDto): Promise<Show> {
// Validate input
this.validateShowData(data);
const show = await this.prisma.show.create({ const show = await this.prisma.show.create({
data: { data: {
...data, ...data,
@@ -79,6 +138,9 @@ export class ShowService {
} }
async updateShow(id: string, data: UpdateShowDto): Promise<Show> { async updateShow(id: string, data: UpdateShowDto): Promise<Show> {
// Validate input
this.validateShowData(data);
const updateData = { ...data }; const updateData = { ...data };
if (updateData.type) { if (updateData.type) {
updateData.type = updateData.type.toUpperCase() as any; updateData.type = updateData.type.toUpperCase() as any;