├── .editorconfig ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── README.md ├── app.vue ├── assets └── styles │ ├── _base.scss │ ├── _global.scss │ ├── _reset.scss │ ├── _transitions.scss │ └── main.scss ├── components ├── AdminHeader.vue ├── AppLogo.vue ├── UserForm.vue └── ui │ ├── UiAlert.vue │ ├── UiBadge.vue │ ├── UiButton.vue │ ├── UiCard.vue │ ├── UiField.vue │ ├── UiFlexGrid.vue │ ├── UiGroup.vue │ ├── UiIcon.vue │ ├── UiInput.vue │ ├── UiOverlay.vue │ ├── UiPanel.vue │ ├── UiSelect.vue │ ├── UiSpinner.vue │ ├── UiToggle.vue │ └── UiTooltip.vue ├── composables ├── useApiFetch.ts └── useUser.ts ├── docker-compose.yml ├── layouts ├── admin.vue ├── default.vue └── panel.vue ├── middleware ├── auth.global.ts ├── requireGuest.ts └── requireLogin.ts ├── nuxt.config.ts ├── package.json ├── pages ├── admin │ ├── index.vue │ └── users │ │ ├── [id].vue │ │ ├── index.vue │ │ └── new.vue ├── index.vue ├── login.vue └── logout.vue ├── prisma ├── migrations │ ├── 20230406041648_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── favicon.ico ├── favicon.svg └── images │ ├── logo-full.png │ └── pattern.png ├── server ├── api │ ├── auth │ │ ├── login.post.ts │ │ └── me.ts │ ├── collections │ │ └── index.ts │ ├── platforms │ │ ├── cache.ts │ │ └── unimported.ts │ └── users │ │ ├── [id].delete.ts │ │ ├── [id].patch.ts │ │ ├── [id].ts │ │ ├── index.post.ts │ │ └── index.ts ├── middleware │ └── auth.ts └── utils │ ├── getPrismaClient.ts │ ├── requireAuth.ts │ ├── useIgdb.ts │ └── user │ ├── checkPassword.ts │ ├── createToken.ts │ ├── decodeToken.ts │ └── hashPassword.ts ├── tsconfig.json ├── types ├── auth.d.ts └── ui.d.ts ├── utils └── getAuthHeader.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = false 10 | 11 | [*.ya?ml] 12 | indent_style = space 13 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@nuxt/eslint-config', 'prettier'], 4 | rules: { 5 | indent: ['warn', 'tab'], 6 | 'vue/no-v-html': 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | *.log* 4 | .nuxt 5 | .nitro 6 | .cache 7 | .output 8 | .env 9 | dist 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "trailingComma": "none", 7 | "singleAttributePerLine": true 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Logo 3 |

4 | A self-hosted game browser. 5 |

6 |
7 | 8 |
9 | 🚧 Cartridge is currently in development and is not yet ready for use. Stay tuned! 🚧 10 |
11 | 12 | ## About 13 | 14 | ![Cartridge Screenshot](https://user-images.githubusercontent.com/1876231/169448529-54259dc2-0ad6-44eb-bc3e-df56220a6e64.png) 15 | Cartridge is a convenient browser for your game collection with easy file downloads and automatically imported metadata and images. This project is designed to be self-hosted on your local server. 16 | 17 | ## Installation 18 | 19 | _Coming soon..._ 20 | 21 | ## Development 22 | 23 | ### Requirements 24 | 25 | - [Node](https://nodejs.org/) 26 | - [Yarn](https://yarnpkg.com/) 27 | - [Docker](https://docs.docker.com/get-docker/) 28 | - [Docker Compose](https://docs.docker.com/compose/install/) 29 | - API key from [IGDB](https://api-docs.igdb.com/#about) 30 | 31 | ### Instructions 32 | 33 | _Coming soon..._ 34 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /assets/styles/_base.scss: -------------------------------------------------------------------------------- 1 | ::selection { 2 | background: $primary; 3 | color: $white; 4 | } 5 | 6 | html { 7 | height: 100%; 8 | background-color: #000; 9 | text-rendering: optimizelegibility; 10 | text-size-adjust: 100%; 11 | -moz-osx-font-smoothing: grayscale; 12 | -webkit-font-smoothing: antialiased; 13 | } 14 | 15 | body { 16 | min-height: 100%; 17 | color: $white-dark; 18 | font-family: $main-font; 19 | font-size: 16px; 20 | line-height: 1; 21 | } 22 | 23 | article, 24 | aside, 25 | figure, 26 | footer, 27 | header, 28 | hgroup, 29 | section { 30 | display: block; 31 | } 32 | 33 | button, 34 | input, 35 | optgroup, 36 | select, 37 | textarea { 38 | font-family: $main-font; 39 | font-size: 1rem; 40 | } 41 | 42 | code, 43 | pre { 44 | -moz-osx-font-smoothing: auto; 45 | -webkit-font-smoothing: auto; 46 | font-family: $monospace-font; 47 | } 48 | 49 | a { 50 | color: currentcolor; 51 | cursor: pointer; 52 | text-decoration: none; 53 | 54 | &:hover { 55 | text-decoration: none; 56 | } 57 | } 58 | 59 | hr { 60 | border: 0; 61 | background-color: $grey-dark; 62 | width: 100%; 63 | height: 1px; 64 | } 65 | 66 | img { 67 | height: auto; 68 | max-width: 100%; 69 | } 70 | 71 | input[type='checkbox'], 72 | input[type='radio'] { 73 | vertical-align: baseline; 74 | } 75 | 76 | small { 77 | font-size: 0.875em; 78 | } 79 | 80 | span { 81 | font-style: inherit; 82 | font-weight: inherit; 83 | } 84 | 85 | strong { 86 | font-weight: bold; 87 | color: $white-dark; 88 | } 89 | 90 | em { 91 | font-style: italic; 92 | } 93 | 94 | fieldset { 95 | border: none; 96 | } 97 | 98 | table { 99 | td, 100 | th { 101 | vertical-align: top; 102 | 103 | &:not([align]) { 104 | text-align: inherit; 105 | } 106 | } 107 | 108 | th { 109 | @extend strong; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /assets/styles/_global.scss: -------------------------------------------------------------------------------- 1 | $white: hsl(0deg 0% 99%); 2 | $white-dark: hsl(0deg 0% 98%); 3 | $white-darker: hsl(0deg 0% 96%); 4 | 5 | $black: hsl(0deg 0% 4%); 6 | $black-light: hsl(0deg 0% 7%); 7 | $black-lighter: hsl(0deg 0% 14%); 8 | 9 | $grey-darkest: hsl(0deg 0% 13%); 10 | $grey-darker: hsl(0deg 0% 21%); 11 | $grey-dark: hsl(0deg 0% 29%); 12 | $grey: hsl(0deg 0% 48%); 13 | $grey-light: hsl(0deg 0% 71%); 14 | $grey-lighter: hsl(0deg 0% 86%); 15 | $grey-lightest: hsl(0deg 0% 93%); 16 | 17 | $orange: hsl(14deg 100% 53%); 18 | $yellow: hsl(44deg 100% 77%); 19 | $green: hsl(153deg 53% 43%); 20 | $turquoise: hsl(171deg 100% 41%); 21 | $cyan: hsl(207deg 61% 53%); 22 | $blue: hsl(229deg 53% 53%); 23 | $purple: hsl(271deg 100% 71%); 24 | $red: hsl(348deg 86% 57%); 25 | 26 | $primary: hsl(246deg 97% 66%); 27 | $secondary: hsl(271deg 100% 71%); 28 | 29 | $danger: $red; 30 | $success: $green; 31 | $warning: $yellow; 32 | $info: $blue; 33 | $link-color: $blue; 34 | 35 | $fancy-gradient: linear-gradient( 36 | 25deg, 37 | rgb(102 86 252 / 100%) 0%, 38 | rgb(102 86 252 / 100%) 10%, 39 | rgb(147 86 252 / 100%) 50%, 40 | rgb(204 84 247 / 100%) 100% 41 | ); 42 | 43 | $main-font: 'Lato', 'Segoe UI', candara, 'Bitstream Vera Sans', 'DejaVu Sans', 'Bitstream Vera Sans', 44 | 'Trebuchet MS', verdana, 'Verdana Ref', sans-serif; 45 | $alt-font: 'Poppins', $main-font; 46 | 47 | $monospace-font: monospace; 48 | -------------------------------------------------------------------------------- /assets/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ 2 | html, 3 | body, 4 | p, 5 | ol, 6 | ul, 7 | li, 8 | dl, 9 | dt, 10 | dd, 11 | blockquote, 12 | figure, 13 | fieldset, 14 | legend, 15 | textarea, 16 | pre, 17 | iframe, 18 | hr, 19 | h1, 20 | h2, 21 | h3, 22 | h4, 23 | h5, 24 | h6 { 25 | padding: 0; 26 | margin: 0; 27 | } 28 | 29 | h1, 30 | h2, 31 | h3, 32 | h4, 33 | h5, 34 | h6 { 35 | font-size: 100%; 36 | font-weight: normal; 37 | } 38 | 39 | ul { 40 | list-style: none; 41 | } 42 | 43 | button, 44 | input, 45 | select { 46 | margin: 0; 47 | } 48 | 49 | button, 50 | input { 51 | outline: none; 52 | border: none; 53 | } 54 | 55 | html { 56 | box-sizing: border-box; 57 | } 58 | 59 | *, 60 | *::before, 61 | *::after { 62 | box-sizing: inherit; 63 | position: relative; 64 | } 65 | 66 | img, 67 | video { 68 | height: auto; 69 | max-width: 100%; 70 | } 71 | 72 | iframe { 73 | border: 0; 74 | } 75 | 76 | table { 77 | border-spacing: 0; 78 | border-collapse: collapse; 79 | } 80 | 81 | td, 82 | th { 83 | padding: 0; 84 | } 85 | -------------------------------------------------------------------------------- /assets/styles/_transitions.scss: -------------------------------------------------------------------------------- 1 | .v-enter-active, 2 | .v-leave-active { 3 | transition: opacity 0.2s ease-in-out; 4 | } 5 | 6 | .v-enter-from, 7 | .v-leave-to { 8 | opacity: 0; 9 | } 10 | 11 | .page-enter-active, 12 | .page-leave-active { 13 | transition: all 0.15s; 14 | } 15 | 16 | .page-enter-from, 17 | .page-leave-to { 18 | opacity: 0; 19 | filter: blur(1rem); 20 | } 21 | -------------------------------------------------------------------------------- /assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | @import 'base'; 3 | @import 'transitions'; 4 | 5 | #__nuxt { 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | width: 100%; 10 | height: 100%; 11 | overflow-x: scroll; 12 | } 13 | -------------------------------------------------------------------------------- /components/AdminHeader.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 35 | -------------------------------------------------------------------------------- /components/AppLogo.vue: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /components/UserForm.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 90 | -------------------------------------------------------------------------------- /components/ui/UiAlert.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 31 | 32 | 79 | -------------------------------------------------------------------------------- /components/ui/UiBadge.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /components/ui/UiButton.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 53 | 54 | 166 | -------------------------------------------------------------------------------- /components/ui/UiCard.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 60 | -------------------------------------------------------------------------------- /components/ui/UiField.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | 23 | 38 | -------------------------------------------------------------------------------- /components/ui/UiFlexGrid.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | 33 | -------------------------------------------------------------------------------- /components/ui/UiGroup.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | 21 | 42 | -------------------------------------------------------------------------------- /components/ui/UiIcon.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | 37 | 43 | -------------------------------------------------------------------------------- /components/ui/UiInput.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | 170 | 171 | 253 | -------------------------------------------------------------------------------- /components/ui/UiOverlay.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 30 | -------------------------------------------------------------------------------- /components/ui/UiPanel.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 36 | 37 | 82 | -------------------------------------------------------------------------------- /components/ui/UiSelect.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 79 | 80 | 124 | -------------------------------------------------------------------------------- /components/ui/UiSpinner.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | 29 | 43 | -------------------------------------------------------------------------------- /components/ui/UiToggle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 35 | 36 | 94 | -------------------------------------------------------------------------------- /components/ui/UiTooltip.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | 19 | 61 | -------------------------------------------------------------------------------- /composables/useApiFetch.ts: -------------------------------------------------------------------------------- 1 | export const useApiFetch = async (url: string, options: any = {}) => { 2 | const token = useCookie('token') 3 | 4 | return await useFetch(url, { 5 | headers: { 6 | authorization: `Bearer ${token.value}` 7 | }, 8 | ...options 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /composables/useUser.ts: -------------------------------------------------------------------------------- 1 | export const useUser = () => { 2 | return useState('user', () => null) 3 | } 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres 4 | restart: unless-stopped 5 | environment: 6 | POSTGRES_DB: ${DB_DATABASE} 7 | POSTGRES_USER: ${DB_USER} 8 | POSTGRES_PASSWORD: ${DB_PASSWORD} 9 | ports: 10 | - 5432:5432 11 | adminer: 12 | image: adminer 13 | restart: unless-stopped 14 | ports: 15 | - 8080:8080 16 | meilisearch: 17 | image: getmeili/meilisearch:v1.0 18 | restart: unless-stopped 19 | ports: 20 | - 7700:7700 21 | -------------------------------------------------------------------------------- /layouts/admin.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 85 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /layouts/panel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | -------------------------------------------------------------------------------- /middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@prisma/client' 2 | 3 | export default defineNuxtRouteMiddleware(async (to, from) => { 4 | const currentUser = useUser() 5 | 6 | // Fetch login information from token cookie if login state isn't found 7 | if (currentUser.value === null) { 8 | const token = useCookie('token') 9 | 10 | if (token.value !== null) { 11 | const { data: user } = await useApiFetch('/api/auth/me') 12 | 13 | if (user.value !== null) { 14 | currentUser.value = { 15 | id: user.value.id, 16 | username: user.value.username, 17 | isAdmin: user.value.isAdmin 18 | } 19 | } 20 | } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /middleware/requireGuest.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, from) => { 2 | const user = useUser() 3 | 4 | if (user.value !== null) { 5 | return navigateTo('/') 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /middleware/requireLogin.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, from) => { 2 | const user = useUser() 3 | 4 | if (user.value === null) { 5 | return navigateTo('/login') 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | runtimeConfig: { 3 | jwtSecret: process.env.JWT_SECRET, 4 | twitchClientId: process.env.TWITCH_CLIENT_ID, 5 | twitchAccessToken: process.env.TWITCH_APP_ACCESS_TOKEN, 6 | gamesDirectory: process.env.GAMES_DIRECTORY 7 | }, 8 | app: { 9 | pageTransition: { name: 'page', mode: 'out-in' } 10 | }, 11 | css: ['@/assets/styles/main.scss'], 12 | vite: { 13 | css: { 14 | preprocessorOptions: { 15 | scss: { 16 | additionalData: '@import "@/assets/styles/_global.scss";' 17 | } 18 | } 19 | } 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cartridge", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare", 10 | "lint": "eslint ." 11 | }, 12 | "prisma": { 13 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 14 | }, 15 | "devDependencies": { 16 | "@nuxt/eslint-config": "^0.1.1", 17 | "@types/feather-icons": "^4.29.1", 18 | "@types/node": "^18.15.11", 19 | "eslint": "^8.37.0", 20 | "eslint-config-prettier": "^8.8.0", 21 | "feather-icons": "^4.29.0", 22 | "nuxt": "^3.3.3", 23 | "prisma": "^4.12.0", 24 | "sass": "^1.61.0", 25 | "ts-node": "^10.9.1", 26 | "typescript": "^5.0.3" 27 | }, 28 | "dependencies": { 29 | "@prisma/client": "^4.12.0", 30 | "@types/bcrypt": "^5.0.0", 31 | "@types/jsonwebtoken": "^9.0.1", 32 | "axios": "^1.3.6", 33 | "bcrypt": "^5.1.0", 34 | "jsonwebtoken": "^9.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pages/admin/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /pages/admin/users/[id].vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 111 | -------------------------------------------------------------------------------- /pages/admin/users/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 66 | 67 | 90 | -------------------------------------------------------------------------------- /pages/admin/users/new.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 42 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 80 | 81 | 88 | -------------------------------------------------------------------------------- /pages/logout.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | -------------------------------------------------------------------------------- /prisma/migrations/20230406041648_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "users" ( 3 | "id" TEXT NOT NULL, 4 | "username" TEXT NOT NULL, 5 | "password" TEXT NOT NULL, 6 | "is_admin" BOOLEAN NOT NULL DEFAULT false, 7 | 8 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "sessions" ( 13 | "id" TEXT NOT NULL, 14 | "token" TEXT NOT NULL, 15 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "user_id" TEXT NOT NULL, 17 | 18 | CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") 19 | ); 20 | 21 | -- CreateIndex 22 | CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); 23 | 24 | -- CreateIndex 25 | CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token"); 26 | 27 | -- AddForeignKey 28 | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 29 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | 13 | username String @unique 14 | password String 15 | 16 | isAdmin Boolean @default(false) @map("is_admin") 17 | 18 | Session Session[] 19 | 20 | @@map("users") 21 | } 22 | 23 | model Session { 24 | id String @id @default(cuid()) 25 | 26 | token String @unique 27 | createdAt DateTime @default(now()) @map("created_at") 28 | 29 | userId String @map("user_id") 30 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 31 | 32 | @@map("sessions") 33 | } 34 | 35 | model Platform { 36 | id Int @id 37 | name String 38 | slug String @unique 39 | 40 | data Json 41 | 42 | collections Collection[] 43 | 44 | @@map("platforms") 45 | } 46 | 47 | model Collection { 48 | id String @id @default(cuid()) 49 | 50 | path String @unique 51 | 52 | platformId Int @map("platform_id") 53 | platform Platform @relation(fields: [platformId], references: [id]) 54 | 55 | @@map("collections") 56 | } 57 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import { hashPassword } from '../server/utils/user/hashPassword' 3 | 4 | const prisma = new PrismaClient() 5 | 6 | async function main() { 7 | const admin = await prisma.user.upsert({ 8 | where: { id: '1' }, 9 | update: {}, 10 | create: { 11 | id: '1', 12 | username: 'admin', 13 | password: hashPassword('hunter2'), 14 | isAdmin: true 15 | } 16 | }) 17 | 18 | console.log(admin) 19 | } 20 | 21 | main() 22 | .then(async () => { 23 | await prisma.$disconnect() 24 | }) 25 | .catch(async (e) => { 26 | console.error(e) 27 | await prisma.$disconnect() 28 | process.exit(1) 29 | }) 30 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamjnsn/cartridge/9704d5ccb1cd9c964892ed526feb396cde33e359/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | -------------------------------------------------------------------------------- /public/images/logo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamjnsn/cartridge/9704d5ccb1cd9c964892ed526feb396cde33e359/public/images/logo-full.png -------------------------------------------------------------------------------- /public/images/pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamjnsn/cartridge/9704d5ccb1cd9c964892ed526feb396cde33e359/public/images/pattern.png -------------------------------------------------------------------------------- /server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const { username, password } = await readBody(event) 3 | 4 | const prisma = getPrismaClient() 5 | const user = await prisma.user.findUnique({ 6 | where: { 7 | username 8 | } 9 | }) 10 | 11 | if (user === null) { 12 | return createError({ 13 | statusCode: 404, 14 | statusMessage: 'User not found' 15 | }) 16 | } 17 | 18 | if (!checkPassword(password, user.password)) { 19 | return createError({ 20 | statusCode: 400, 21 | statusMessage: 'Incorrect password' 22 | }) 23 | } 24 | 25 | const token = createToken(user) 26 | const session = await prisma.session.create({ 27 | data: { 28 | token, 29 | userId: user.id 30 | } 31 | }) 32 | 33 | return { 34 | session, 35 | user 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /server/api/auth/me.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | // Return authorized user determined by auth middleware 3 | return event.context.auth.user 4 | }) 5 | -------------------------------------------------------------------------------- /server/api/collections/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const prisma = getPrismaClient() 3 | const collections = await prisma.collection.findMany({ 4 | include: { 5 | platform: true 6 | } 7 | }) 8 | return collections 9 | }) 10 | -------------------------------------------------------------------------------- /server/api/platforms/cache.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const prisma = getPrismaClient() 3 | 4 | const platforms = await useIgdb({ 5 | endpoint: 'platforms', 6 | body: ['fields *', 'limit 200'] 7 | }) 8 | 9 | for (const platformData of platforms) { 10 | const data = { 11 | id: platformData.id, 12 | name: platformData.name, 13 | slug: platformData.slug, 14 | data: platformData 15 | } 16 | 17 | const platform = await prisma.platform.upsert({ 18 | where: { 19 | id: platformData.id 20 | }, 21 | update: data, 22 | create: data 23 | }) 24 | } 25 | 26 | return 'Done caching platforms.' 27 | }) 28 | -------------------------------------------------------------------------------- /server/api/platforms/unimported.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const config = useRuntimeConfig() 6 | const prisma = getPrismaClient() 7 | 8 | const unimportedDirectories: string[] = [] 9 | const files = fs.readdirSync(config.gamesDirectory) 10 | 11 | for (const file of files) { 12 | const filePath = path.join(config.gamesDirectory, file) 13 | const stats = fs.lstatSync(filePath) 14 | 15 | if (!stats.isDirectory()) continue 16 | 17 | const collection = await prisma.collection.findFirst({ 18 | where: { 19 | path: filePath 20 | } 21 | }) 22 | 23 | if (collection !== null) continue 24 | 25 | unimportedDirectories.push(filePath) 26 | } 27 | 28 | return unimportedDirectories 29 | }) 30 | -------------------------------------------------------------------------------- /server/api/users/[id].delete.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const params = event.context.params 3 | 4 | const userId = params?.id 5 | 6 | if (!userId) 7 | return createError({ 8 | statusCode: 400, 9 | statusMessage: 'No user ID provided' 10 | }) 11 | 12 | if (userId === '1') 13 | return createError({ 14 | statusCode: 400, 15 | statusMessage: 'Initial user cannot be deleted' 16 | }) 17 | 18 | const prisma = getPrismaClient() 19 | await prisma.user.delete({ 20 | where: { 21 | id: userId 22 | } 23 | }) 24 | 25 | return 'User deleted' 26 | }) 27 | -------------------------------------------------------------------------------- /server/api/users/[id].patch.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const params = event.context.params 3 | const { username, password, isAdmin } = await readBody(event) 4 | 5 | const userId = params?.id 6 | 7 | if (!userId) 8 | return createError({ 9 | statusCode: 400, 10 | statusMessage: 'No user ID provided' 11 | }) 12 | 13 | const prisma = getPrismaClient() 14 | 15 | const data: { 16 | username?: string 17 | password?: string 18 | isAdmin?: boolean 19 | } = { 20 | username, 21 | isAdmin 22 | } 23 | 24 | if (password !== '') { 25 | data.password = hashPassword(password) 26 | } 27 | 28 | const user = await prisma.user.update({ 29 | where: { 30 | id: userId 31 | }, 32 | data 33 | }) 34 | 35 | return user 36 | }) 37 | -------------------------------------------------------------------------------- /server/api/users/[id].ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const params = event.context.params 3 | 4 | if (!params?.id) 5 | return createError({ 6 | statusCode: 400, 7 | statusMessage: 'No user ID provided' 8 | }) 9 | 10 | const prisma = getPrismaClient() 11 | const user = await prisma.user.findUnique({ 12 | where: { 13 | id: params.id 14 | } 15 | }) 16 | 17 | return user 18 | }) 19 | -------------------------------------------------------------------------------- /server/api/users/index.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const { username, password, isAdmin } = await readBody(event) 3 | 4 | const prisma = getPrismaClient() 5 | const user = await prisma.user.create({ 6 | data: { 7 | username, 8 | password: hashPassword(password), 9 | isAdmin: isAdmin === 'true' 10 | } 11 | }) 12 | 13 | return user 14 | }) 15 | -------------------------------------------------------------------------------- /server/api/users/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | requireAuth(event) 3 | 4 | const prisma = getPrismaClient() 5 | const users = await prisma.user.findMany() 6 | return users 7 | }) 8 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | event.context.auth = { 3 | user: null 4 | } 5 | 6 | const { authorization } = event.node.req.headers 7 | 8 | if (authorization === undefined) return 9 | 10 | const match = authorization.match(/Bearer (.*)/) 11 | 12 | if (match === null) return 13 | 14 | const token = match[1] 15 | 16 | const prisma = getPrismaClient() 17 | const session = await prisma.session.findUnique({ 18 | where: { 19 | token 20 | }, 21 | include: { 22 | user: true 23 | } 24 | }) 25 | 26 | if (session === null) return 27 | 28 | event.context.auth = { 29 | user: session.user 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /server/utils/getPrismaClient.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | // ToDo: Find a way to not duplicate instances for a session 4 | export const getPrismaClient = () => { 5 | return new PrismaClient() 6 | } 7 | -------------------------------------------------------------------------------- /server/utils/requireAuth.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from 'h3' 2 | 3 | export const requireAuth = (event: H3Event) => { 4 | if (event.context.auth.user === null) { 5 | throw createError({ 6 | statusCode: 403, 7 | statusMessage: 'Invalid authorization' 8 | }) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/utils/useIgdb.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | declare type IgdbEndpoint = 'platforms' | 'games' 4 | 5 | const igdbUrl = 'https://api.igdb.com/v4' 6 | 7 | export const useIgdb = async ({ endpoint, body }: { endpoint: IgdbEndpoint; body?: string[] }) => { 8 | const config = useRuntimeConfig() 9 | 10 | const headers = { 11 | Authorization: `Bearer ${config.twitchAccessToken}`, 12 | 'Client-ID': config.twitchClientId 13 | } 14 | 15 | // Prepare raw body string 16 | const bodyString = body !== undefined && body.length > 0 ? body.join('; ') + ';' : '' 17 | 18 | const results = await axios 19 | .post(`${igdbUrl}/${endpoint}`, bodyString, { 20 | headers 21 | }) 22 | .then((res) => res.data) 23 | 24 | return results 25 | } 26 | -------------------------------------------------------------------------------- /server/utils/user/checkPassword.ts: -------------------------------------------------------------------------------- 1 | import { compareSync } from 'bcrypt' 2 | 3 | export const checkPassword = (password: string, hash: string) => { 4 | return compareSync(password, hash) 5 | } 6 | -------------------------------------------------------------------------------- /server/utils/user/createToken.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@prisma/client' 2 | import jwt from 'jsonwebtoken' 3 | 4 | export const createToken = (user: User) => { 5 | const { jwtSecret } = useRuntimeConfig() 6 | 7 | const payload: LoginUser = { 8 | id: user.id, 9 | username: user.username, 10 | isAdmin: user.isAdmin 11 | } 12 | 13 | const token = jwt.sign(payload, jwtSecret) 14 | 15 | return token 16 | } 17 | -------------------------------------------------------------------------------- /server/utils/user/decodeToken.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | export const decodeToken = (token: string) => { 4 | const { jwtSecret } = useRuntimeConfig() 5 | 6 | const payload = jwt.verify(token, jwtSecret) 7 | return payload as LoginUser 8 | } 9 | -------------------------------------------------------------------------------- /server/utils/user/hashPassword.ts: -------------------------------------------------------------------------------- 1 | import { hashSync, genSaltSync } from 'bcrypt' 2 | 3 | export const hashPassword = (password: string) => { 4 | const salt = genSaltSync() 5 | return hashSync(password, salt) 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@types/feather-icons"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /types/auth.d.ts: -------------------------------------------------------------------------------- 1 | declare type LoginUser = { 2 | id: string 3 | username: string 4 | isAdmin: boolean 5 | } 6 | -------------------------------------------------------------------------------- /types/ui.d.ts: -------------------------------------------------------------------------------- 1 | declare type UiSize = 'default' | 'small' | 'large' 2 | declare type UiColor = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' 3 | 4 | declare type UiIconName = import('@types/feather-icons').FeatherIconNames 5 | 6 | declare type UiSelectOptions = { 7 | name: string 8 | val: string 9 | } 10 | -------------------------------------------------------------------------------- /utils/getAuthHeader.ts: -------------------------------------------------------------------------------- 1 | export const getAuthHeader = () => { 2 | const token = useCookie('token') 3 | return `Bearer ${token.value}` 4 | } 5 | --------------------------------------------------------------------------------