Compare commits

..

2 Commits

Author SHA1 Message Date
naomi fbfc24ba0d release: v1.1.0
Node.js CI / CI (push) Failing after 1m25s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m35s
2026-02-20 20:35:15 -08:00
hikari 983b78b0e9 feat: base64 uploads, reusable forms, Discord roles, and UX improvements (#66)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m20s
Node.js CI / CI (push) Successful in 1m24s
## Summary

This PR includes multiple feature additions and fixes to improve the library application:

### 🎨 Base64 Image Upload Support
- Fixed Fastify body limit (1MB → 10MB) to accommodate base64-encoded images
- Corrected base64 size calculation and validation logic
- Improved error handling with proper 400 status codes and helpful messages
- Removed duplicate validation that was blocking uploads
- Users can now upload cover images up to 5MB (decoded size)

### 📝 Reusable Form Components
- Created 6 form components: `GameForm`, `BookForm`, `MusicForm`, `ShowForm`, `MangaForm`, `ArtForm`
- All forms support both 'add' and 'edit' modes with pre-population
- Integrated inline editing into all detail views (edit/delete buttons)
- Enhanced admin suggestions workflow with full forms instead of basic modals
- Added scroll-to-top when clicking edit in list views for better UX

### 🖼️ Default Cover Image
- Added beautiful library reading image as default cover for all media types
- Fixed static asset serving to use correct MIME types
- Updated all 12 components (6 list views + 6 detail views) to always show images

### 🔒 Tiered Rate Limiting
- Unauthenticated users: 100 requests/minute
- Authenticated users: 500 requests/minute (5x more lenient)
- Admin users: No rate limits (complete bypass via allowList)

### 🎮 Discord Integration
- Auto-assign library member role to users in NHCarrigan Discord server
- Checks server membership on every login
- Only assigns role if user is in server and doesn't have it yet
- Graceful error handling without blocking login
- Similar pattern to badge refresh flow

### 📚 Documentation
- Added comprehensive CLAUDE.md with:
  - Project structure and tech stack
  - Development workflow and commands
  - Database schema documentation
  - Authentication flow details
  - Security features
  - Code style conventions
  - Common gotchas and solutions

## Test Plan

- [x] Base64 image uploads work for cover images up to 5MB
- [x] Helpful error messages appear for validation failures
- [x] Edit/delete buttons appear on all detail views for admin users
- [x] Inline edit forms display and save correctly
- [x] Admin suggestions workflow uses full forms for all media types
- [x] Scroll-to-top works when editing from list views
- [x] Default cover image displays when no cover is provided
- [x] Static assets serve with correct MIME types
- [x] Rate limiting works correctly for different user types
- [x] Discord role assignment works on login
- [x] All builds pass without errors
- [x] No TypeScript errors

## Related Issues

Closes #65 - Base64 image upload issue

 This pull request was created with help from Hikari~ 🌸

Reviewed-on: #66
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-20 20:32:52 -08:00
17 changed files with 100 additions and 49 deletions
+47
View File
@@ -102,6 +102,53 @@ const authRoutes: FastifyPluginAsync = async (app) => {
request request
); );
// Assign library member role if user is in Discord server but doesn't have it
const libraryRoleId = process.env.LIBRARY_ROLE_ID;
if (inDiscord && guildId && libraryRoleId) {
try {
const memberResponse = await fetch(
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
{
headers: {
Authorization: `Bearer ${tokenResult.token.access_token}`,
},
}
);
if (memberResponse.ok) {
const memberData = await memberResponse.json() as { roles: string[] };
const hasLibraryRole = memberData.roles.includes(libraryRoleId);
if (!hasLibraryRole) {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken) {
const assignRoleResponse = await fetch(
`https://discord.com/api/v10/guilds/${guildId}/members/${userData.id}/roles/${libraryRoleId}`,
{
method: "PUT",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
}
);
if (assignRoleResponse.ok || assignRoleResponse.status === 204) {
app.log.info(`Assigned library role to user ${user.username} (${user.id})`);
} else {
app.log.error(
`Failed to assign library role to user ${user.username}: ${assignRoleResponse.status}`
);
}
}
}
}
} catch (error) {
// Don't fail the login if role assignment fails
app.log.error({ err: error }, "Error assigning library role");
}
}
// Set signed cookies and redirect to frontend // Set signed cookies and redirect to frontend
reply reply
.setCookie("auth-token", accessToken, { .setCookie("auth-token", accessToken, {
@@ -244,8 +244,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-game-form <app-game-form
mode="add" mode="add"
[initialData]="getGameInitialData(editingSuggestion()!)" [initialData]="getGameInitialData(editingSuggestion()!)"
(save)="saveGameFromSuggestion($event)" (formSubmit)="saveGameFromSuggestion($event)"
(cancel)="closeEditModal()" (formCancel)="closeEditModal()"
></app-game-form> ></app-game-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.book) { } @else if (editingSuggestion()!.entityType === SuggestionEntity.book) {
<h3>Review & Edit Book Before Accepting</h3> <h3>Review & Edit Book Before Accepting</h3>
@@ -253,8 +253,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-book-form <app-book-form
mode="add" mode="add"
[initialData]="getBookInitialData(editingSuggestion()!)" [initialData]="getBookInitialData(editingSuggestion()!)"
(save)="saveBookFromSuggestion($event)" (formSubmit)="saveBookFromSuggestion($event)"
(cancel)="closeEditModal()" (formCancel)="closeEditModal()"
></app-book-form> ></app-book-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.music) { } @else if (editingSuggestion()!.entityType === SuggestionEntity.music) {
<h3>Review & Edit Music Before Accepting</h3> <h3>Review & Edit Music Before Accepting</h3>
@@ -262,8 +262,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-music-form <app-music-form
mode="add" mode="add"
[initialData]="getMusicInitialData(editingSuggestion()!)" [initialData]="getMusicInitialData(editingSuggestion()!)"
(save)="saveMusicFromSuggestion($event)" (formSubmit)="saveMusicFromSuggestion($event)"
(cancel)="closeEditModal()" (formCancel)="closeEditModal()"
></app-music-form> ></app-music-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.show) { } @else if (editingSuggestion()!.entityType === SuggestionEntity.show) {
<h3>Review & Edit Show Before Accepting</h3> <h3>Review & Edit Show Before Accepting</h3>
@@ -271,8 +271,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-show-form <app-show-form
mode="add" mode="add"
[initialData]="getShowInitialData(editingSuggestion()!)" [initialData]="getShowInitialData(editingSuggestion()!)"
(save)="saveShowFromSuggestion($event)" (formSubmit)="saveShowFromSuggestion($event)"
(cancel)="closeEditModal()" (formCancel)="closeEditModal()"
></app-show-form> ></app-show-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) { } @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
<h3>Review & Edit Manga Before Accepting</h3> <h3>Review & Edit Manga Before Accepting</h3>
@@ -280,8 +280,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-manga-form <app-manga-form
mode="add" mode="add"
[initialData]="getMangaInitialData(editingSuggestion()!)" [initialData]="getMangaInitialData(editingSuggestion()!)"
(save)="saveMangaFromSuggestion($event)" (formSubmit)="saveMangaFromSuggestion($event)"
(cancel)="closeEditModal()" (formCancel)="closeEditModal()"
></app-manga-form> ></app-manga-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.art) { } @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
<h3>Review & Edit Art Before Accepting</h3> <h3>Review & Edit Art Before Accepting</h3>
@@ -289,8 +289,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-art-form <app-art-form
mode="add" mode="add"
[initialData]="getArtInitialData(editingSuggestion()!)" [initialData]="getArtInitialData(editingSuggestion()!)"
(save)="saveArtFromSuggestion($event)" (formSubmit)="saveArtFromSuggestion($event)"
(cancel)="closeEditModal()" (formCancel)="closeEditModal()"
></app-art-form> ></app-art-form>
} }
} }
@@ -31,8 +31,8 @@ import { Art, Comment, UpdateArtDto } from '@library/shared-types';
<app-art-form <app-art-form
mode="edit" mode="edit"
[art]="art()!" [art]="art()!"
(save)="saveEdit($event)" (formSubmit)="saveEdit($event)"
(cancel)="cancelEdit()" (formCancel)="cancelEdit()"
></app-art-form> ></app-art-form>
} }
@@ -31,8 +31,8 @@ import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types'
<app-book-form <app-book-form
mode="edit" mode="edit"
[book]="book()!" [book]="book()!"
(save)="saveEdit($event)" (formSubmit)="saveEdit($event)"
(cancel)="cancelEdit()" (formCancel)="cancelEdit()"
></app-book-form> ></app-book-form>
} }
@@ -60,8 +60,8 @@ import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types'
<app-game-form <app-game-form
mode="edit" mode="edit"
[game]="game()!" [game]="game()!"
(save)="saveEdit($event)" (formSubmit)="saveEdit($event)"
(cancel)="cancelEdit()" (formCancel)="cancelEdit()"
></app-game-form> ></app-game-form>
} }
@@ -31,8 +31,8 @@ import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-typ
<app-manga-form <app-manga-form
mode="edit" mode="edit"
[manga]="manga()!" [manga]="manga()!"
(save)="saveEdit($event)" (formSubmit)="saveEdit($event)"
(cancel)="cancelEdit()" (formCancel)="cancelEdit()"
></app-manga-form> ></app-manga-form>
} }
@@ -31,8 +31,8 @@ import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library
<app-music-form <app-music-form
mode="edit" mode="edit"
[music]="music()!" [music]="music()!"
(save)="saveEdit($event)" (formSubmit)="saveEdit($event)"
(cancel)="cancelEdit()" (formCancel)="cancelEdit()"
></app-music-form> ></app-music-form>
} }
@@ -290,8 +290,8 @@ export class ArtFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add'; @Input() mode: 'add' | 'edit' = 'add';
@Input() art?: Art; @Input() art?: Art;
@Input() initialData?: Partial<CreateArtDto>; @Input() initialData?: Partial<CreateArtDto>;
@Output() save = new EventEmitter<CreateArtDto | UpdateArtDto>(); @Output() formSubmit = new EventEmitter<CreateArtDto | UpdateArtDto>();
@Output() cancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
formData: Partial<CreateArtDto | UpdateArtDto> = { formData: Partial<CreateArtDto | UpdateArtDto> = {
tags: [], tags: [],
@@ -396,10 +396,10 @@ export class ArtFormComponent implements OnInit {
...this.formData as CreateArtDto | UpdateArtDto ...this.formData as CreateArtDto | UpdateArtDto
}; };
this.save.emit(data); this.formSubmit.emit(data);
} }
onCancel() { onCancel() {
this.cancel.emit(); this.formCancel.emit();
} }
} }
@@ -404,8 +404,8 @@ export class BookFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add'; @Input() mode: 'add' | 'edit' = 'add';
@Input() book?: Book; @Input() book?: Book;
@Input() initialData?: Partial<CreateBookDto>; @Input() initialData?: Partial<CreateBookDto>;
@Output() save = new EventEmitter<CreateBookDto | UpdateBookDto>(); @Output() formSubmit = new EventEmitter<CreateBookDto | UpdateBookDto>();
@Output() cancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
BookStatus = BookStatus; BookStatus = BookStatus;
@@ -534,10 +534,10 @@ export class BookFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
}; };
this.save.emit(data); this.formSubmit.emit(data);
} }
onCancel() { onCancel() {
this.cancel.emit(); this.formCancel.emit();
} }
} }
@@ -392,8 +392,8 @@ export class GameFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add'; @Input() mode: 'add' | 'edit' = 'add';
@Input() game?: Game; @Input() game?: Game;
@Input() initialData?: Partial<CreateGameDto>; @Input() initialData?: Partial<CreateGameDto>;
@Output() save = new EventEmitter<CreateGameDto | UpdateGameDto>(); @Output() formSubmit = new EventEmitter<CreateGameDto | UpdateGameDto>();
@Output() cancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
GameStatus = GameStatus; GameStatus = GameStatus;
@@ -522,10 +522,10 @@ export class GameFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
}; };
this.save.emit(data); this.formSubmit.emit(data);
} }
onCancel() { onCancel() {
this.cancel.emit(); this.formCancel.emit();
} }
} }
@@ -370,8 +370,8 @@ export class MangaFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add'; @Input() mode: 'add' | 'edit' = 'add';
@Input() manga?: Manga; @Input() manga?: Manga;
@Input() initialData?: Partial<CreateMangaDto>; @Input() initialData?: Partial<CreateMangaDto>;
@Output() save = new EventEmitter<CreateMangaDto | UpdateMangaDto>(); @Output() formSubmit = new EventEmitter<CreateMangaDto | UpdateMangaDto>();
@Output() cancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
MangaStatus = MangaStatus; MangaStatus = MangaStatus;
@@ -497,10 +497,10 @@ export class MangaFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
}; };
this.save.emit(data); this.formSubmit.emit(data);
} }
onCancel() { onCancel() {
this.cancel.emit(); this.formCancel.emit();
} }
} }
@@ -379,8 +379,8 @@ export class MusicFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add'; @Input() mode: 'add' | 'edit' = 'add';
@Input() music?: Music; @Input() music?: Music;
@Input() initialData?: Partial<CreateMusicDto>; @Input() initialData?: Partial<CreateMusicDto>;
@Output() save = new EventEmitter<CreateMusicDto | UpdateMusicDto>(); @Output() formSubmit = new EventEmitter<CreateMusicDto | UpdateMusicDto>();
@Output() cancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
MusicStatus = MusicStatus; MusicStatus = MusicStatus;
MusicType = MusicType; MusicType = MusicType;
@@ -509,10 +509,10 @@ export class MusicFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
}; };
this.save.emit(data); this.formSubmit.emit(data);
} }
onCancel() { onCancel() {
this.cancel.emit(); this.formCancel.emit();
} }
} }
@@ -368,8 +368,8 @@ export class ShowFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add'; @Input() mode: 'add' | 'edit' = 'add';
@Input() show?: Show; @Input() show?: Show;
@Input() initialData?: Partial<CreateShowDto>; @Input() initialData?: Partial<CreateShowDto>;
@Output() save = new EventEmitter<CreateShowDto | UpdateShowDto>(); @Output() formSubmit = new EventEmitter<CreateShowDto | UpdateShowDto>();
@Output() cancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
ShowStatus = ShowStatus; ShowStatus = ShowStatus;
ShowType = ShowType; ShowType = ShowType;
@@ -497,10 +497,10 @@ export class ShowFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
}; };
this.save.emit(data); this.formSubmit.emit(data);
} }
onCancel() { onCancel() {
this.cancel.emit(); this.formCancel.emit();
} }
} }
@@ -31,8 +31,8 @@ import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/sha
<app-show-form <app-show-form
mode="edit" mode="edit"
[show]="show()!" [show]="show()!"
(save)="saveEdit($event)" (formSubmit)="saveEdit($event)"
(cancel)="cancelEdit()" (formCancel)="cancelEdit()"
></app-show-form> ></app-show-form>
} }
+2
View File
@@ -16,6 +16,8 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id" SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id" MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id" STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
# Application URL # Application URL
BASE_URL="op://Environment Variables - Naomi/Library/localhost url" BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@library/source", "name": "@library/source",
"version": "1.0.0", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js", "dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
+2
View File
@@ -16,6 +16,8 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id" SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id" MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id" STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
# Application URL # Application URL
BASE_URL="op://Environment Variables - Naomi/Library/base url" BASE_URL="op://Environment Variables - Naomi/Library/base url"