├── .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 |

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 | 
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 |
9 |
10 |
11 |
12 |
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 |
2 |
9 |
10 |
11 |
35 |
--------------------------------------------------------------------------------
/components/AppLogo.vue:
--------------------------------------------------------------------------------
1 |
2 |
39 |
40 |
--------------------------------------------------------------------------------
/components/UserForm.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
89 |
90 |
--------------------------------------------------------------------------------
/components/ui/UiAlert.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
30 |
31 |
32 |
79 |
--------------------------------------------------------------------------------
/components/ui/UiBadge.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
15 |
--------------------------------------------------------------------------------
/components/ui/UiButton.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
52 |
53 |
54 |
166 |
--------------------------------------------------------------------------------
/components/ui/UiCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
60 |
--------------------------------------------------------------------------------
/components/ui/UiField.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
19 |
20 |
21 |
22 |
23 |
38 |
--------------------------------------------------------------------------------
/components/ui/UiFlexGrid.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
17 |
18 |
19 |
33 |
--------------------------------------------------------------------------------
/components/ui/UiGroup.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
42 |
--------------------------------------------------------------------------------
/components/ui/UiIcon.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
34 |
35 |
36 |
37 |
43 |
--------------------------------------------------------------------------------
/components/ui/UiInput.vue:
--------------------------------------------------------------------------------
1 |
109 |
110 |
111 |
112 |
116 |
117 |
118 |
119 |
132 |
133 |
134 |
139 |
140 |
141 |
142 |
143 |
144 |
149 |
150 |
151 |
152 |
153 |
154 |
167 |
168 |
169 |
170 |
171 |
253 |
--------------------------------------------------------------------------------
/components/ui/UiOverlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
30 |
--------------------------------------------------------------------------------
/components/ui/UiPanel.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
17 |
23 |
24 |
25 |
26 |
27 |
28 |
34 |
35 |
36 |
37 |
82 |
--------------------------------------------------------------------------------
/components/ui/UiSelect.vue:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
49 |
77 |
78 |
79 |
80 |
124 |
--------------------------------------------------------------------------------
/components/ui/UiSpinner.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
27 |
28 |
29 |
43 |
--------------------------------------------------------------------------------
/components/ui/UiToggle.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
34 |
35 |
36 |
94 |
--------------------------------------------------------------------------------
/components/ui/UiTooltip.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
17 |
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 |
2 |
3 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
85 |
--------------------------------------------------------------------------------
/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/layouts/panel.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
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 |
12 |
13 |
14 | Dashboard
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/pages/admin/users/[id].vue:
--------------------------------------------------------------------------------
1 |
50 |
51 |
52 |
53 |
54 | Editing {{ user.username }}
55 |
56 |
57 |
64 | Delete
65 |
66 |
67 |
68 |
69 |
74 | {{ formError }}
75 |
76 |
77 |
81 |
82 |
83 |
84 |
85 |
89 |
90 |
91 |
92 | Are you sure you want to delete {{ user.username }}?
93 |
94 |
95 |
100 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/pages/admin/users/index.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 | Users
20 |
21 |
22 |
27 | New
28 |
30 |
31 |
32 |
33 |
34 |
39 |
40 |
44 |
45 |
46 |
47 | {{ user.username }}
48 |
49 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
90 |
--------------------------------------------------------------------------------
/pages/admin/users/new.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 | New User
29 |
30 |
31 |
36 | {{ formError }}
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hover Me
5 |
6 |
7 |
8 |
9 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/pages/login.vue:
--------------------------------------------------------------------------------
1 |
44 |
45 |
46 |
50 | Login
51 |
52 |
78 |
79 |
80 |
81 |
88 |
--------------------------------------------------------------------------------
/pages/logout.vue:
--------------------------------------------------------------------------------
1 |
2 | Logging out...
3 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------