diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29aa36c3..e0059774 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Hello! We're glad to have you interested in contributing to Note Block World. This document will guide you through the process of setting up the project and submitting your contributions. -This page is a work-in-progress and will be updated as the project evolves. If you have any questions or need help, feel free to reach out to us on our [Discord server](https://discord.gg/open-note-block-studio-608692895179997252). +This page is a work-in-progress and will be updated as the project evolves. If you have any questions or need help, feel free to reach out to us on our [Discord server](https://discord.gg/note-block-world-608692895179997252). ## Stack diff --git a/server/src/auth/auth.service.spec.ts b/server/src/auth/auth.service.spec.ts index df510c6f..4fa1e388 100644 --- a/server/src/auth/auth.service.spec.ts +++ b/server/src/auth/auth.service.spec.ts @@ -51,7 +51,7 @@ describe('AuthService', () => { }, { provide: 'COOKIE_EXPIRES_IN', - useValue: '1d', + useValue: '3600', }, { provide: 'JWT_SECRET', @@ -373,7 +373,7 @@ describe('AuthService', () => { expect(res.cookie).toHaveBeenCalledWith('token', 'access-token', { domain: '.test.com', - maxAge: 1, + maxAge: 3600000, }); expect(res.cookie).toHaveBeenCalledWith( @@ -381,7 +381,7 @@ describe('AuthService', () => { 'refresh-token', { domain: '.test.com', - maxAge: 1, + maxAge: 3600000, }, ); diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index a57088eb..5d83b8d2 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -220,7 +220,7 @@ export class AuthService { const frontEndURL = this.FRONTEND_URL; const domain = this.APP_DOMAIN; - const maxAge = parseInt(this.COOKIE_EXPIRES_IN); + const maxAge = parseInt(this.COOKIE_EXPIRES_IN) * 1000; res.cookie('token', token.access_token, { domain: domain, diff --git a/server/src/song/song.controller.ts b/server/src/song/song.controller.ts index d310c2f9..55e05b76 100644 --- a/server/src/song/song.controller.ts +++ b/server/src/song/song.controller.ts @@ -122,6 +122,8 @@ export class SongController { @GetRequestToken() user: UserDocument | null, @Res() res: Response, ): Promise { + user = validateUser(user); + // TODO: no longer used res.set({ 'Content-Disposition': 'attachment; filename="song.nbs"', diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 2805ce42..6e13d102 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Inject, Query } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; @@ -7,6 +7,7 @@ import { GetRequestToken, validateUser } from '@server/GetRequestUser'; import { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; +import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; @Controller('user') export class UserController { @@ -37,4 +38,16 @@ export class UserController { user = validateUser(user); return await this.userService.getSelfUserData(user); } + + @Patch('username') + @ApiTags('user') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update the username' }) + async updateUsername( + @GetRequestToken() user: UserDocument | null, + @Body() body: UpdateUsernameDto, + ) { + user = validateUser(user); + return await this.userService.updateUsername(user, body); + } } diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index bf5df91c..36ac541d 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -287,4 +287,75 @@ describe('UserService', () => { expect(service.usernameExists).toHaveBeenCalledWith(baseUsername); }); }); + + describe('normalizeUsername', () => { + it('should normalize a username', () => { + const inputUsername = 'tést user'; + const normalizedUsername = 'test_user'; + + const result = (service as any).normalizeUsername(inputUsername); + + expect(result).toBe(normalizedUsername); + }); + + it('should remove special characters from a username', () => { + const inputUsername = '静_かな'; + const normalizedUsername = '_'; + + const result = (service as any).normalizeUsername(inputUsername); + + expect(result).toBe(normalizedUsername); + }); + + it('should replace spaces with underscores in a username', () => { + const inputUsername = 'Имя пользователя'; + const normalizedUsername = '_'; + + const result = (service as any).normalizeUsername(inputUsername); + + expect(result).toBe(normalizedUsername); + }); + + it('should replace spaces with underscores in a username', () => { + const inputUsername = 'Eglė Čepulytė'; + const normalizedUsername = 'Egle_Cepulyte'; + + const result = (service as any).normalizeUsername(inputUsername); + + expect(result).toBe(normalizedUsername); + }); + }); + + describe('updateUsername', () => { + it('should update a user username', async () => { + const user = { + username: 'testuser', + save: jest.fn().mockReturnThis(), + } as unknown as UserDocument; + const body = { username: 'newuser' }; + + jest.spyOn(service, 'usernameExists').mockResolvedValue(false); + + const result = await service.updateUsername(user, body); + + expect(result).toEqual(user); + expect(user.username).toBe(body.username); + expect(service.usernameExists).toHaveBeenCalledWith(body.username); + }); + + it('should throw an error if username already exists', async () => { + const user = { + username: 'testuser', + save: jest.fn().mockReturnThis(), + } as unknown as UserDocument; + + const body = { username: 'newuser' }; + + jest.spyOn(service, 'usernameExists').mockResolvedValue(true); + + await expect(service.updateUsername(user, body)).rejects.toThrow( + new HttpException('Username already exists', HttpStatus.BAD_REQUEST), + ); + }); + }); }); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 7b231c16..ec3a10f8 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -5,7 +5,7 @@ import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; - +import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; import { User, UserDocument } from './entity/user.entity'; @Injectable() @@ -106,14 +106,17 @@ export class UserService { return !!user; } - public async generateUsername(inputUsername: string) { - // Normalize username (remove accents, replace spaces with underscores) - const baseUsername = inputUsername + private normalizeUsername = (inputUsername: string) => + inputUsername .replace(' ', '_') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9_]/g, ''); + public async generateUsername(inputUsername: string) { + // Normalize username (remove accents, replace spaces with underscores) + const baseUsername = this.normalizeUsername(inputUsername); + let newUsername = baseUsername; let counter = 1; @@ -125,4 +128,24 @@ export class UserService { return newUsername; } + + public async updateUsername(user: UserDocument, body: UpdateUsernameDto) { + let { username } = body; + username = this.normalizeUsername(username); + + if (await this.usernameExists(username)) { + throw new HttpException( + 'Username already exists', + HttpStatus.BAD_REQUEST, + ); + } + + if (user.username === username) { + throw new HttpException('Username is the same', HttpStatus.BAD_REQUEST); + } + + user.username = username; + + return await user.save(); + } } diff --git a/shared/features/song/obfuscate.ts b/shared/features/song/obfuscate.ts index e0026791..4eeb74a4 100644 --- a/shared/features/song/obfuscate.ts +++ b/shared/features/song/obfuscate.ts @@ -119,8 +119,21 @@ export class SongObfuscator { const resolveKeyAndPitch = (note: Note) => { const factoredPitch = note.key * 100 + note.pitch; - const key = Math.floor((factoredPitch + 50) / 100); - const pitch = ((factoredPitch + 50) % 100) - 50; + + let key, pitch; + + if (factoredPitch < 0) { + // Below A0 + key = 0; + pitch = factoredPitch; + } else if (factoredPitch >= 87 * 100) { + // Above C8 + key = 87; + pitch = factoredPitch - 87 * 100; + } else { + key = Math.floor((factoredPitch + 50) / 100); + pitch = ((factoredPitch + 50) % 100) - 50; + } return { key, pitch }; }; diff --git a/shared/validation/song/constants.ts b/shared/validation/song/constants.ts index 154dc0ab..db09b939 100644 --- a/shared/validation/song/constants.ts +++ b/shared/validation/song/constants.ts @@ -128,7 +128,6 @@ export const MY_SONGS = deepFreeze({ }); export const BROWSER_SONGS = deepFreeze({ - max_recent_songs: 100, featuredPageSize: 10, paddedFeaturedPageSize: 5, }); diff --git a/shared/validation/user/dto/UpdateUsername.dto.ts b/shared/validation/user/dto/UpdateUsername.dto.ts new file mode 100644 index 00000000..c9afe018 --- /dev/null +++ b/shared/validation/user/dto/UpdateUsername.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator'; + +export class UpdateUsernameDto { + @IsString() + @MaxLength(64) + @MinLength(3) + @ApiProperty({ + description: 'Username of the user', + example: 'tomast1137', + }) + username: string; +} diff --git a/web/src/app/(content)/(info)/contact/contact.mdx b/web/src/app/(content)/(info)/contact/contact.mdx index a97a5a48..26c85682 100644 --- a/web/src/app/(content)/(info)/contact/contact.mdx +++ b/web/src/app/(content)/(info)/contact/contact.mdx @@ -2,6 +2,6 @@ Have you got any question, suggestion or feedback? We'd love to hear from you! To report an issue or suggest a new feature, please [open a new issue](https://github.com/issues/new/choose) in our GitHub repository. Make sure to search the existing issues to see if your suggestion has already been made. Also, check our [project roadmap](https://github.com/orgs/OpenNBS/projects/4) to see if the feature you want is already planned! -For general support, or if you'd just like to have a chat, the fastest way to get in touch with us is by joining our public community on [Discord](https://discord.gg/open-note-block-studio-608692895179997252). We're always happy to help! +For general support, or if you'd just like to have a chat, the fastest way to get in touch with us is by joining our public community on [Discord](https://discord.gg/note-block-world-608692895179997252). We're always happy to help! For business inquiries, or if you need to reach out to us privately, you can send us an email at [opennbs@gmail.com](opennbs@gmail.com). diff --git a/web/src/app/(content)/page.tsx b/web/src/app/(content)/page.tsx index 3050a6db..a4d1a03e 100644 --- a/web/src/app/(content)/page.tsx +++ b/web/src/app/(content)/page.tsx @@ -52,12 +52,12 @@ export const metadata: Metadata = { }; async function Home() { - const recentSongs = await fetchRecentSongs(); + //const recentSongs = await fetchRecentSongs(); const featuredSongs = await fetchFeaturedSongs(); return ( diff --git a/web/src/lib/axios/ClientAxios.ts b/web/src/lib/axios/ClientAxios.ts new file mode 100644 index 00000000..f27ba701 --- /dev/null +++ b/web/src/lib/axios/ClientAxios.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; +import { getTokenLocal } from './token.utils'; +export const baseApiURL = process.env.NEXT_PUBLIC_API_URL; + +const ClientAxios = axios.create({ + baseURL: baseApiURL, + withCredentials: true, +}); + +// Add a request interceptor to add the token to the request +ClientAxios.interceptors.request.use( + (config) => { + const token = getTokenLocal(); + + config.headers.authorization = `Bearer ${token}`; + + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +export default ClientAxios; diff --git a/web/src/modules/browse/WelcomeBanner.tsx b/web/src/modules/browse/WelcomeBanner.tsx index 447453ed..89c64d8a 100644 --- a/web/src/modules/browse/WelcomeBanner.tsx +++ b/web/src/modules/browse/WelcomeBanner.tsx @@ -40,7 +40,7 @@ export const WelcomeBanner = () => { {' • '} Discord Server diff --git a/web/src/modules/browse/components/client/context/RecentSongs.context.tsx b/web/src/modules/browse/components/client/context/RecentSongs.context.tsx index a174d402..f668d027 100644 --- a/web/src/modules/browse/components/client/context/RecentSongs.context.tsx +++ b/web/src/modules/browse/components/client/context/RecentSongs.context.tsx @@ -1,6 +1,5 @@ 'use client'; -import { BROWSER_SONGS } from '@shared/validation/song/constants'; import { SongPreviewDtoType } from '@shared/validation/song/dto/types'; import { createContext, @@ -39,7 +38,7 @@ export function RecentSongsProvider({ useState(initialRecentSongs); const [recentError, setRecentError] = useState(''); - const [page, setPage] = useState(3); + const [page, setPage] = useState(0); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const [categories, setCategories] = useState>({}); @@ -56,7 +55,7 @@ export function RecentSongsProvider({ { params: { page, - limit: 8, // TODO: fix constants + limit: 12, // TODO: fix constants order: false, }, }, @@ -67,7 +66,7 @@ export function RecentSongsProvider({ ...response.data, ]); - if (response.data.length < 8) { + if (response.data.length < 12) { setHasMore(false); } } catch (error) { @@ -114,18 +113,13 @@ export function RecentSongsProvider({ setEndpoint(newEndpoint); }, [selectedCategory]); - // Fetch recent songs when the page or endpoint changes useEffect(() => { + if (page === 0) return; fetchRecentSongs(); - }, [page, endpoint]); + }, [page, endpoint, fetchRecentSongs]); async function increasePageRecent() { - if ( - BROWSER_SONGS.max_recent_songs <= recentSongs.length || - loading || - recentError || - !hasMore - ) { + if (loading || recentError || !hasMore) { return; } diff --git a/web/src/modules/shared/components/client/GenericModal.tsx b/web/src/modules/shared/components/client/GenericModal.tsx index de053908..48572845 100644 --- a/web/src/modules/shared/components/client/GenericModal.tsx +++ b/web/src/modules/shared/components/client/GenericModal.tsx @@ -5,17 +5,19 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Dialog, Transition } from '@headlessui/react'; import { Fragment } from 'react'; -export default function GenericModal({ +interface GenericModalProps { + isOpen: boolean; + setIsOpen?: (isOpen: boolean) => void; + title: string; + children?: React.ReactNode | React.ReactNode[] | string; +} + +const GenericModal = ({ isOpen, setIsOpen, title, children, -}: { - isOpen: boolean; - setIsOpen?: (isOpen: boolean) => void; - title: string; - children: React.ReactNode; -}) { +}: GenericModalProps) => { return ( ); -} +}; + +export default GenericModal; diff --git a/web/src/modules/shared/components/client/ads/AdSlots.tsx b/web/src/modules/shared/components/client/ads/AdSlots.tsx index ea6db91d..b2927fe0 100644 --- a/web/src/modules/shared/components/client/ads/AdSlots.tsx +++ b/web/src/modules/shared/components/client/ads/AdSlots.tsx @@ -39,12 +39,14 @@ const AdTemplate = ({ adFormat = 'auto', fullWidthResponsive = 'true', hiddenClassName = 'hidden', + showCloseButton = true, }: { className: string; adSlot: string; adFormat: string; fullWidthResponsive: string; hiddenClassName?: string; + showCloseButton?: boolean; }) => { const pubId = useAdSenseClient(); @@ -81,7 +83,7 @@ const AdTemplate = ({ data-ad-format={adFormat} data-full-width-responsive={fullWidthResponsive} > - + {showCloseButton && } )} @@ -122,3 +124,18 @@ export const SideRailAdSlot = ({ className }: { className?: string }) => { /> ); }; + +export const DownloadPopupAdSlot = ({ className }: { className?: string }) => { + return ( + + ); +}; diff --git a/web/src/modules/shared/components/layout/Footer.tsx b/web/src/modules/shared/components/layout/Footer.tsx index 8d872b38..b7bb7eb7 100644 --- a/web/src/modules/shared/components/layout/Footer.tsx +++ b/web/src/modules/shared/components/layout/Footer.tsx @@ -29,7 +29,7 @@ export function Footer() { {/* */} {/* */} diff --git a/web/src/modules/shared/components/layout/RandomSongButton.tsx b/web/src/modules/shared/components/layout/RandomSongButton.tsx index ea3408bf..a98f63f2 100644 --- a/web/src/modules/shared/components/layout/RandomSongButton.tsx +++ b/web/src/modules/shared/components/layout/RandomSongButton.tsx @@ -37,7 +37,7 @@ export const RandomSongButton = () => { return ( + + ) : ( + <> +
+ + + +
+ + )} +

{userData.email}

@@ -57,4 +169,4 @@ export function UserMenu({ userData }: { userData: LoggedUserData }) { ); -} +}; diff --git a/web/src/modules/song-edit/components/client/context/EditSong.context.tsx b/web/src/modules/song-edit/components/client/context/EditSong.context.tsx index c9d212ff..c9d3c518 100644 --- a/web/src/modules/song-edit/components/client/context/EditSong.context.tsx +++ b/web/src/modules/song-edit/components/client/context/EditSong.context.tsx @@ -239,12 +239,15 @@ export const EditSongProvider = ({ formMethods.setValue('category', songData.category); // fetch song + const token = getTokenLocal(); + const songFile = ( await axiosInstance.get(`/song/${id}/download`, { params: { src: 'edit', }, responseType: 'arraybuffer', + headers: { authorization: `Bearer ${token}` }, }) ).data as ArrayBuffer; diff --git a/web/src/modules/song/components/SongDetails.tsx b/web/src/modules/song/components/SongDetails.tsx index 0fc0c8f9..7fdf96a8 100644 --- a/web/src/modules/song/components/SongDetails.tsx +++ b/web/src/modules/song/components/SongDetails.tsx @@ -22,7 +22,7 @@ const SongDetailsRow = ({ children }: { children: React.ReactNode }) => { const SongDetailsCell = ({ children }: { children: React.ReactNode }) => { return ( - + {children} ); diff --git a/web/src/modules/song/components/SongPage.tsx b/web/src/modules/song/components/SongPage.tsx index 075a7a95..5a3b6c45 100644 --- a/web/src/modules/song/components/SongPage.tsx +++ b/web/src/modules/song/components/SongPage.tsx @@ -66,7 +66,7 @@ export async function SongPage({ id }: { id: string }) { {/* */}
-
+
{/* */} diff --git a/web/src/modules/song/components/SongPageButtons.tsx b/web/src/modules/song/components/SongPageButtons.tsx index 7aab4cb1..aa920eac 100644 --- a/web/src/modules/song/components/SongPageButtons.tsx +++ b/web/src/modules/song/components/SongPageButtons.tsx @@ -16,7 +16,15 @@ import Link from 'next/link'; import { useState } from 'react'; import { toast } from 'react-hot-toast'; +import { getTokenLocal } from '@web/src/lib/axios/token.utils'; + +import DownloadSongModal from './client/DownloadSongModal'; import ShareModal from './client/ShareModal'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '../../shared/components/tooltip'; import { downloadSongFile, openSongInNBS } from '../util/downloadSong'; const UploaderBadge = ({ user }: { user: SongViewDtoType['uploader'] }) => { @@ -149,7 +157,7 @@ const OpenInNBSButton = ({ + + + + + {!isLoggedIn && ( + + {'You must sign in to download this song!'} + + )} +
); diff --git a/web/src/modules/song/components/client/DownloadSongModal.tsx b/web/src/modules/song/components/client/DownloadSongModal.tsx new file mode 100644 index 00000000..65b26d50 --- /dev/null +++ b/web/src/modules/song/components/client/DownloadSongModal.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { SongViewDtoType } from '@shared/validation/song/dto/types'; +import { useState } from 'react'; + +import { DownloadPopupAdSlot } from '@web/src/modules/shared/components/client/ads/AdSlots'; +import GenericModal from '@web/src/modules/shared/components/client/GenericModal'; + +export default function DownloadSongModal({ + isOpen, + setIsOpen, + song, +}: { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + song: SongViewDtoType; +}) { + const [isCopied, setIsCopied] = useState(false); + + let licenseInfo; + + if (song.license == 'cc_by_sa') { + licenseInfo = `"${song.title}" by ${song.uploader.username} is licensed under CC BY-SA 4.0\n(https://creativecommons.org/licenses/by-sa/4.0)\n${process.env.NEXT_PUBLIC_URL}/song/${song.publicId}`; + } else { + licenseInfo = `"${song.title}" by ${song.uploader.username}. All rights reserved.\n${process.env.NEXT_PUBLIC_URL}/song/${song.publicId}`; + } + + const handleCopy = () => () => { + navigator.clipboard.writeText(licenseInfo); + + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + + return ( + +

+ If you plan to use this song in your own creations, provide attribution + to the author by attaching the following text: +

+
+ {/* Attribution box */} +