From 6a3358e03ed2413e85f917e96352dfa10b04c682 Mon Sep 17 00:00:00 2001 From: sk1982 Date: Sun, 24 Mar 2024 21:47:52 -0400 Subject: [PATCH] add arcade and cab editing --- migrations/20240321005239-create-uuidv4.js | 53 +++ .../20240321005359-create-arcade-ext.js | 53 +++ migrations/20240321014407-create-user-ext.js | 53 +++ .../20240321022354-create-user-friends.js | 53 +++ migrations/20240321023511-create-teams.js | 53 +++ .../20240321023539-add-team-to-user-ext.js | 53 +++ .../20240322035901-create-arcade-join-keys.js | 53 +++ .../20240322035910-create-team-join-keys.js | 53 +++ .../20240321005239-create-uuidv4-down.sql | 1 + .../sqls/20240321005239-create-uuidv4-up.sql | 24 ++ .../20240321005359-create-arcade-ext-down.sql | 1 + .../20240321005359-create-arcade-ext-up.sql | 11 + .../20240321014407-create-user-ext-down.sql | 1 + .../20240321014407-create-user-ext-up.sql | 12 + ...0240321022354-create-user-friends-down.sql | 1 + .../20240321022354-create-user-friends-up.sql | 10 + .../sqls/20240321023511-create-teams-down.sql | 1 + .../sqls/20240321023511-create-teams-up.sql | 15 + ...240321023539-add-team-to-user-ext-down.sql | 3 + ...20240321023539-add-team-to-user-ext-up.sql | 5 + ...322035901-create-arcade-join-keys-down.sql | 1 + ...40322035901-create-arcade-join-keys-up.sql | 11 + ...40322035910-create-team-join-keys-down.sql | 1 + ...0240322035910-create-team-join-keys-up.sql | 11 + package-lock.json | 21 -- package.json | 1 - src/actions/arcade.ts | 211 +++++++++++ src/actions/auth.ts | 14 +- src/actions/machine.ts | 151 ++++++++ .../arcade/[arcadeId]/join/[join]/page.tsx | 58 +++ .../(with-header)/arcade/[arcadeId]/page.tsx | 24 ++ src/app/(with-header)/arcade/page.tsx | 72 ++++ .../(with-header)/chuni/dashboard/page.tsx | 1 - src/app/(with-header)/chuni/music/page.tsx | 2 - src/app/(with-header)/loading.tsx | 2 +- src/app/not-found.tsx | 3 + src/auth.ts | 30 +- src/components/arcade.tsx | 351 ++++++++++++++++++ src/components/cab.tsx | 221 +++++++++++ src/components/chuni/difficulty-container.tsx | 4 +- src/components/chuni/music-detail.tsx | 6 +- src/components/chuni/music-list.tsx | 5 +- src/components/chuni/music-playlog.tsx | 7 +- src/components/chuni/playlog-card.tsx | 2 +- src/components/chuni/playlog-list.tsx | 3 +- src/components/chuni/top-rating-sidebar.tsx | 2 +- src/components/chuni/top-rating.tsx | 2 +- src/components/chuni/userbox.tsx | 5 +- src/components/client-providers.tsx | 16 +- src/components/confirm-modal.tsx | 61 +++ src/components/create-arcade-button.tsx | 31 ++ src/components/error-modal.tsx | 9 +- src/components/filter-sorter.tsx | 12 +- src/components/header-sidebar.tsx | 2 +- src/components/invalid-link.tsx | 9 + src/components/join-links-modal.tsx | 102 +++++ src/components/join-success.tsx | 10 + src/components/login-card.tsx | 4 +- src/components/music-player.tsx | 2 +- src/components/permission-edit-modal.tsx | 73 ++++ src/components/private-visibility-error.tsx | 17 + src/components/prompt-modal.tsx | 71 ++++ src/components/providers.tsx | 1 - src/components/ticker.tsx | 2 +- src/data/arcade.ts | 146 ++++++++ src/data/user.ts | 71 ++++ src/helpers/chuni/filter.tsx | 1 - src/helpers/chuni/music.ts | 2 - src/helpers/keychip.ts | 17 + src/helpers/permissions.ts | 58 ++- src/helpers/random.ts | 19 + src/helpers/use-hash-navigation.ts | 37 ++ src/helpers/validators.ts | 3 + src/routes.ts | 3 + src/types/awaitable.ts | 1 + src/types/country.ts | 9 + src/types/db.d.ts | 78 +++- src/types/game-ids.ts | 31 ++ src/types/permissions.ts | 14 + src/types/privacy-visibility.ts | 14 + src/types/region.ts | 107 ++++++ src/types/user.ts | 12 +- src/types/validator-map.ts | 11 + 83 files changed, 2700 insertions(+), 87 deletions(-) create mode 100644 migrations/20240321005239-create-uuidv4.js create mode 100644 migrations/20240321005359-create-arcade-ext.js create mode 100644 migrations/20240321014407-create-user-ext.js create mode 100644 migrations/20240321022354-create-user-friends.js create mode 100644 migrations/20240321023511-create-teams.js create mode 100644 migrations/20240321023539-add-team-to-user-ext.js create mode 100644 migrations/20240322035901-create-arcade-join-keys.js create mode 100644 migrations/20240322035910-create-team-join-keys.js create mode 100644 migrations/sqls/20240321005239-create-uuidv4-down.sql create mode 100644 migrations/sqls/20240321005239-create-uuidv4-up.sql create mode 100644 migrations/sqls/20240321005359-create-arcade-ext-down.sql create mode 100644 migrations/sqls/20240321005359-create-arcade-ext-up.sql create mode 100644 migrations/sqls/20240321014407-create-user-ext-down.sql create mode 100644 migrations/sqls/20240321014407-create-user-ext-up.sql create mode 100644 migrations/sqls/20240321022354-create-user-friends-down.sql create mode 100644 migrations/sqls/20240321022354-create-user-friends-up.sql create mode 100644 migrations/sqls/20240321023511-create-teams-down.sql create mode 100644 migrations/sqls/20240321023511-create-teams-up.sql create mode 100644 migrations/sqls/20240321023539-add-team-to-user-ext-down.sql create mode 100644 migrations/sqls/20240321023539-add-team-to-user-ext-up.sql create mode 100644 migrations/sqls/20240322035901-create-arcade-join-keys-down.sql create mode 100644 migrations/sqls/20240322035901-create-arcade-join-keys-up.sql create mode 100644 migrations/sqls/20240322035910-create-team-join-keys-down.sql create mode 100644 migrations/sqls/20240322035910-create-team-join-keys-up.sql create mode 100644 src/actions/arcade.ts create mode 100644 src/actions/machine.ts create mode 100644 src/app/(with-header)/arcade/[arcadeId]/join/[join]/page.tsx create mode 100644 src/app/(with-header)/arcade/[arcadeId]/page.tsx create mode 100644 src/app/(with-header)/arcade/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/arcade.tsx create mode 100644 src/components/cab.tsx create mode 100644 src/components/confirm-modal.tsx create mode 100644 src/components/create-arcade-button.tsx create mode 100644 src/components/invalid-link.tsx create mode 100644 src/components/join-links-modal.tsx create mode 100644 src/components/join-success.tsx create mode 100644 src/components/permission-edit-modal.tsx create mode 100644 src/components/private-visibility-error.tsx create mode 100644 src/components/prompt-modal.tsx create mode 100644 src/data/arcade.ts create mode 100644 src/data/user.ts create mode 100644 src/helpers/keychip.ts create mode 100644 src/helpers/random.ts create mode 100644 src/helpers/use-hash-navigation.ts create mode 100644 src/helpers/validators.ts create mode 100644 src/types/awaitable.ts create mode 100644 src/types/country.ts create mode 100644 src/types/game-ids.ts create mode 100644 src/types/privacy-visibility.ts create mode 100644 src/types/region.ts create mode 100644 src/types/validator-map.ts diff --git a/migrations/20240321005239-create-uuidv4.js b/migrations/20240321005239-create-uuidv4.js new file mode 100644 index 0000000..e6fb191 --- /dev/null +++ b/migrations/20240321005239-create-uuidv4.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321005239-create-uuidv4-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321005239-create-uuidv4-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240321005359-create-arcade-ext.js b/migrations/20240321005359-create-arcade-ext.js new file mode 100644 index 0000000..e460ab1 --- /dev/null +++ b/migrations/20240321005359-create-arcade-ext.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321005359-create-arcade-ext-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321005359-create-arcade-ext-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240321014407-create-user-ext.js b/migrations/20240321014407-create-user-ext.js new file mode 100644 index 0000000..b31bd0a --- /dev/null +++ b/migrations/20240321014407-create-user-ext.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321014407-create-user-ext-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321014407-create-user-ext-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240321022354-create-user-friends.js b/migrations/20240321022354-create-user-friends.js new file mode 100644 index 0000000..e846795 --- /dev/null +++ b/migrations/20240321022354-create-user-friends.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321022354-create-user-friends-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321022354-create-user-friends-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240321023511-create-teams.js b/migrations/20240321023511-create-teams.js new file mode 100644 index 0000000..0cc3fe9 --- /dev/null +++ b/migrations/20240321023511-create-teams.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321023511-create-teams-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321023511-create-teams-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240321023539-add-team-to-user-ext.js b/migrations/20240321023539-add-team-to-user-ext.js new file mode 100644 index 0000000..6a2e9f6 --- /dev/null +++ b/migrations/20240321023539-add-team-to-user-ext.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321023539-add-team-to-user-ext-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240321023539-add-team-to-user-ext-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240322035901-create-arcade-join-keys.js b/migrations/20240322035901-create-arcade-join-keys.js new file mode 100644 index 0000000..0fb03b4 --- /dev/null +++ b/migrations/20240322035901-create-arcade-join-keys.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240322035901-create-arcade-join-keys-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240322035901-create-arcade-join-keys-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/20240322035910-create-team-join-keys.js b/migrations/20240322035910-create-team-join-keys.js new file mode 100644 index 0000000..9e883f5 --- /dev/null +++ b/migrations/20240322035910-create-team-join-keys.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240322035910-create-team-join-keys-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240322035910-create-team-join-keys-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/sqls/20240321005239-create-uuidv4-down.sql b/migrations/sqls/20240321005239-create-uuidv4-down.sql new file mode 100644 index 0000000..f9d9d89 --- /dev/null +++ b/migrations/sqls/20240321005239-create-uuidv4-down.sql @@ -0,0 +1 @@ +DROP FUNCTION uuid_v4; diff --git a/migrations/sqls/20240321005239-create-uuidv4-up.sql b/migrations/sqls/20240321005239-create-uuidv4-up.sql new file mode 100644 index 0000000..27b4c74 --- /dev/null +++ b/migrations/sqls/20240321005239-create-uuidv4-up.sql @@ -0,0 +1,24 @@ +-- https://stackoverflow.com/a/61062917 + +CREATE FUNCTION uuid_v4() + RETURNS CHAR(36) +BEGIN + -- 1th and 2nd block are made of 6 random bytes + SET @h1 = HEX(RANDOM_BYTES(4)); + SET @h2 = HEX(RANDOM_BYTES(2)); + + -- 3th block will start with a 4 indicating the version, remaining is random + SET @h3 = SUBSTR(HEX(RANDOM_BYTES(2)), 2, 3); + + -- 4th block first nibble can only be 8, 9 A or B, remaining is random + SET @h4 = CONCAT(HEX(FLOOR(ASCII(RANDOM_BYTES(1)) / 64)+8), + SUBSTR(HEX(RANDOM_BYTES(2)), 2, 3)); + + -- 5th block is made of 6 random bytes + SET @h5 = HEX(RANDOM_BYTES(6)); + + -- Build the complete UUID + RETURN LOWER(CONCAT( + @h1, '-', @h2, '-4', @h3, '-', @h4, '-', @h5 + )); +END diff --git a/migrations/sqls/20240321005359-create-arcade-ext-down.sql b/migrations/sqls/20240321005359-create-arcade-ext-down.sql new file mode 100644 index 0000000..dd9c692 --- /dev/null +++ b/migrations/sqls/20240321005359-create-arcade-ext-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_arcade_ext; diff --git a/migrations/sqls/20240321005359-create-arcade-ext-up.sql b/migrations/sqls/20240321005359-create-arcade-ext-up.sql new file mode 100644 index 0000000..d9f3faa --- /dev/null +++ b/migrations/sqls/20240321005359-create-arcade-ext-up.sql @@ -0,0 +1,11 @@ +CREATE TABLE actaeon_arcade_ext ( + arcadeId INT NOT NULL, + uuid CHAR(36) NOT NULL, + visibility INT NOT NULL, + joinPrivacy INT NOT NULL, + + PRIMARY KEY (arcadeId), + UNIQUE KEY (uuid), + FOREIGN KEY (arcadeId) REFERENCES arcade(id) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/migrations/sqls/20240321014407-create-user-ext-down.sql b/migrations/sqls/20240321014407-create-user-ext-down.sql new file mode 100644 index 0000000..fee1eea --- /dev/null +++ b/migrations/sqls/20240321014407-create-user-ext-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_user_ext; diff --git a/migrations/sqls/20240321014407-create-user-ext-up.sql b/migrations/sqls/20240321014407-create-user-ext-up.sql new file mode 100644 index 0000000..3fa9489 --- /dev/null +++ b/migrations/sqls/20240321014407-create-user-ext-up.sql @@ -0,0 +1,12 @@ +CREATE TABLE actaeon_user_ext ( + userId INT NOT NULL, + uuid CHAR(36) NOT NULL, + visibility INT NOT NULL, + + homepage VARCHAR(64), + + PRIMARY KEY (userId), + UNIQUE KEY (uuid), + FOREIGN KEY (userId) REFERENCES aime_user(id) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/migrations/sqls/20240321022354-create-user-friends-down.sql b/migrations/sqls/20240321022354-create-user-friends-down.sql new file mode 100644 index 0000000..6544a84 --- /dev/null +++ b/migrations/sqls/20240321022354-create-user-friends-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_user_friends; diff --git a/migrations/sqls/20240321022354-create-user-friends-up.sql b/migrations/sqls/20240321022354-create-user-friends-up.sql new file mode 100644 index 0000000..4bb7a2d --- /dev/null +++ b/migrations/sqls/20240321022354-create-user-friends-up.sql @@ -0,0 +1,10 @@ +CREATE TABLE actaeon_user_friends ( + user1 INT NOT NULL, + user2 INT NOT NULL, + + PRIMARY KEY (user1, user2), + FOREIGN KEY (user1) REFERENCES aime_user(id) + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (user2) REFERENCES aime_user(id) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/migrations/sqls/20240321023511-create-teams-down.sql b/migrations/sqls/20240321023511-create-teams-down.sql new file mode 100644 index 0000000..d8a6ddc --- /dev/null +++ b/migrations/sqls/20240321023511-create-teams-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_teams; diff --git a/migrations/sqls/20240321023511-create-teams-up.sql b/migrations/sqls/20240321023511-create-teams-up.sql new file mode 100644 index 0000000..0eff54b --- /dev/null +++ b/migrations/sqls/20240321023511-create-teams-up.sql @@ -0,0 +1,15 @@ +CREATE TABLE actaeon_teams ( + uuid CHAR(36), + visibility INT NOT NULL, + joinPrivacy INT NOT NULL, + name VARCHAR(255), + owner INT NOT NULL, + + chuniTeam INT NOT NULL, + + PRIMARY KEY (uuid), + FOREIGN KEY (chuniTeam) REFERENCES chuni_profile_team(id) + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (owner) REFERENCES aime_user(id) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/migrations/sqls/20240321023539-add-team-to-user-ext-down.sql b/migrations/sqls/20240321023539-add-team-to-user-ext-down.sql new file mode 100644 index 0000000..60bd596 --- /dev/null +++ b/migrations/sqls/20240321023539-add-team-to-user-ext-down.sql @@ -0,0 +1,3 @@ +ALTER TABLE actaeon_user_ext +DROP CONSTRAINT fk_team, +DROP COLUMN team; diff --git a/migrations/sqls/20240321023539-add-team-to-user-ext-up.sql b/migrations/sqls/20240321023539-add-team-to-user-ext-up.sql new file mode 100644 index 0000000..547b6b0 --- /dev/null +++ b/migrations/sqls/20240321023539-add-team-to-user-ext-up.sql @@ -0,0 +1,5 @@ +ALTER TABLE actaeon_user_ext +ADD COLUMN team CHAR(36), +ADD CONSTRAINT fk_team FOREIGN KEY(team) REFERENCES actaeon_teams(uuid) + ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/migrations/sqls/20240322035901-create-arcade-join-keys-down.sql b/migrations/sqls/20240322035901-create-arcade-join-keys-down.sql new file mode 100644 index 0000000..c88864a --- /dev/null +++ b/migrations/sqls/20240322035901-create-arcade-join-keys-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_arcade_join_keys; diff --git a/migrations/sqls/20240322035901-create-arcade-join-keys-up.sql b/migrations/sqls/20240322035901-create-arcade-join-keys-up.sql new file mode 100644 index 0000000..7445b82 --- /dev/null +++ b/migrations/sqls/20240322035901-create-arcade-join-keys-up.sql @@ -0,0 +1,11 @@ +CREATE TABLE actaeon_arcade_join_keys ( + id CHAR(10) NOT NULL, + arcadeId INT NOT NULL, + + remainingUses INT DEFAULT NULL, + totalUses INT NOT NULL DEFAULT 0, + + PRIMARY KEY (id), + FOREIGN KEY (arcadeId) REFERENCES arcade(id) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/migrations/sqls/20240322035910-create-team-join-keys-down.sql b/migrations/sqls/20240322035910-create-team-join-keys-down.sql new file mode 100644 index 0000000..7e363b8 --- /dev/null +++ b/migrations/sqls/20240322035910-create-team-join-keys-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_team_join_keys; diff --git a/migrations/sqls/20240322035910-create-team-join-keys-up.sql b/migrations/sqls/20240322035910-create-team-join-keys-up.sql new file mode 100644 index 0000000..9b4bdab --- /dev/null +++ b/migrations/sqls/20240322035910-create-team-join-keys-up.sql @@ -0,0 +1,11 @@ +CREATE TABLE actaeon_team_join_keys ( + id CHAR(10) NOT NULL, + teamId CHAR(36) NOT NULL, + + remainingUses INT DEFAULT NULL, + totalUses INT NOT NULL DEFAULT 0, + + PRIMARY KEY (id), + FOREIGN KEY (teamId) REFERENCES actaeon_teams(uuid) + ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/package-lock.json b/package-lock.json index 014a393..199efa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "mysql2": "^3.9.2", "next": "14.1.3", "next-auth": "^5.0.0-beta.15", - "next-client-cookies": "^1.1.0", "next-themes": "^0.2.1", "react": "^18", "react-day-picker": "^8.10.0", @@ -7753,14 +7752,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "engines": { - "node": ">=14" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8529,18 +8520,6 @@ } } }, - "node_modules/next-client-cookies": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-client-cookies/-/next-client-cookies-1.1.0.tgz", - "integrity": "sha512-e/rqQTXHSFuvUJELMeCDgM7dWW6IUNOGr7noWyRSgE/5l033UaqseDrjShfRZYG45JnrYKoX653OdXTJ8cn+NA==", - "dependencies": { - "js-cookie": "^3.0.5" - }, - "peerDependencies": { - "next": ">= 13.0.0", - "react": ">= 16.8.0" - } - }, "node_modules/next-themes": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz", diff --git a/package.json b/package.json index 2957d7c..847a87f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "mysql2": "^3.9.2", "next": "14.1.3", "next-auth": "^5.0.0-beta.15", - "next-client-cookies": "^1.1.0", "next-themes": "^0.2.1", "react": "^18", "react-day-picker": "^8.10.0", diff --git a/src/actions/arcade.ts b/src/actions/arcade.ts new file mode 100644 index 0000000..5f4d0f3 --- /dev/null +++ b/src/actions/arcade.ts @@ -0,0 +1,211 @@ +'use server'; + +import { ActaeonArcadeExt as DBArcadeExt, Arcade as DBArcade } from '@/types/db'; +import { Arcade, countryValidator, getArcadePermissions } from '@/data/arcade'; +import { db } from '@/db'; +import { requireUser } from '@/actions/auth'; +import { hasPermission, requireArcadePermission, requirePermission } from '@/helpers/permissions'; +import { ArcadePermissions, UserPermissions } from '@/types/permissions'; +import { ValidatorMap } from '@/types/validator-map'; +import { JoinPrivacy, PRIVACY_VALUES, Visibility, VISIBILITY_VALUES } from '@/types/privacy-visibility'; +import { IP_REGEX, TIMEZONE_REGEX } from '@/helpers/validators'; +import { notFound, redirect } from 'next/navigation'; +import { randomString } from '@/helpers/random'; +import crypto from 'crypto'; + +import type { Entries } from 'type-fest'; + +export type ArcadeUpdate = Partial>; + +const ARCADE_VALIDATORS: ValidatorMap = new Map(); + +ARCADE_VALIDATORS.set('visibility', val => { + if (!VISIBILITY_VALUES.has(val!)) + throw new Error('Invalid visibility value'); +}); +ARCADE_VALIDATORS.set('joinPrivacy', val => { + if (!PRIVACY_VALUES.has(val!)) + throw new Error('Invalid join privacy value'); +}); +(['name', 'nickname', 'state', 'city'] as const).forEach(v => ARCADE_VALIDATORS.set(v, () => {})); +(['country_id', 'region_id'] as const).forEach(v => ARCADE_VALIDATORS.set(v, val => { + const name = v.split('_')[0]; + if (typeof val !== 'number' || !Number.isInteger(val)) + throw new Error(`${name[0].toUpperCase()}${name.slice(1)} ID must be a number`); +})); +ARCADE_VALIDATORS.set('timezone', val => { + if (!TIMEZONE_REGEX.test(val ?? '')) + throw new Error('Timezone must be in the format ±##:## or ±####'); +}); +ARCADE_VALIDATORS.set('ip', val => { + if (!IP_REGEX.test(val ?? '')) + throw new Error('Invalid IP format'); +}); + +ARCADE_VALIDATORS.set('country', countryValidator); + + +export const updateArcade = async (id: number, update: ArcadeUpdate) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, id); + + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.EDITOR); + + const arcadeUpdate: Partial = {}; + const arcadeExtUpdate: Partial = {}; + + for (let [key, val] of (Object.entries(update) as Entries)) { + if (!ARCADE_VALIDATORS.has(key)) + return { error: true, message: `Unknown key ${key}` }; + + if (key === 'name' && !val?.toString()?.trim()) + return { error: true, message: `Name is required` }; + + try { + if (val === undefined) val = null; + if (val !== null) + val = (await ARCADE_VALIDATORS.get(key as keyof ArcadeUpdate)!(val)) ?? val; + } catch (e: any) { + return { error: true, message: e?.message ?? 'Unknown error' }; + } + + if (key === 'joinPrivacy' || key === 'visibility') + arcadeExtUpdate[key] = val as any; + else + arcadeUpdate[key] = val as any; + } + + await db.transaction().execute(async trx => { + if (Object.keys(arcadeUpdate).length) + await trx.updateTable('arcade') + .set(arcadeUpdate) + .where('id', '=', id) + .executeTakeFirst(); + + if (Object.keys(arcadeExtUpdate).length) + await trx.updateTable('actaeon_arcade_ext') + .set(arcadeExtUpdate) + .where('arcadeId', '=', id) + .executeTakeFirst(); + }); +}; + +export const joinPublicArcade = async (arcade: number) => { + const user = await requireUser(); + const arcadePrivacy = await db.selectFrom('arcade') + .innerJoin('actaeon_arcade_ext as ext', 'ext.arcadeId', 'arcade.id') + .where('arcade.id', '=', arcade) + .select('ext.joinPrivacy') + .executeTakeFirst(); + + if (!arcadePrivacy) + return notFound(); + + if (arcadePrivacy.joinPrivacy !== JoinPrivacy.PUBLIC) + requirePermission(user.permissions, UserPermissions.ACMOD); + + await db.insertInto('arcade_owner') + .values({ + arcade, + user: user.id, + permissions: 1 + }) + .executeTakeFirst(); +} + +export const removeUserFromArcade = async (arcade: number, userId?: number) => { + const user = await requireUser(); + userId ??= user.id; + if (user.id !== userId) { + const arcadePermissions = await getArcadePermissions(user, arcade); + if (!hasPermission(arcadePermissions, ArcadePermissions.OWNER)) + requirePermission(user.permissions, UserPermissions.USERMOD); + } + + await db.deleteFrom('arcade_owner') + .where('arcade', '=', arcade) + .where('user', '=', userId) + .executeTakeFirst(); +} + +export const createArcadeLink = async (arcade: number, remainingUses: number | null) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, arcade); + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.OWNER); + const id = randomString(10); + await db.insertInto('actaeon_arcade_join_keys') + .values({ + id, + arcadeId: arcade, + remainingUses, + totalUses: 0 + }) + .executeTakeFirst(); + return id; +}; + +export const deleteArcadeLink = async (arcade: number, link: string) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, arcade); + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.OWNER); + await db.deleteFrom('actaeon_arcade_join_keys') + .where('arcadeId', '=', arcade) + .where('id', '=', link) + .executeTakeFirst(); +}; + +export const createArcade = async (name: string) => { + const user = await requireUser({ permission: UserPermissions.ACMOD }); + const uuid = crypto.randomUUID(); + + await db.transaction().execute(async trx => { + const arcade = await trx.insertInto('arcade') + .values({ name }) + .executeTakeFirstOrThrow(); + + await trx.insertInto('actaeon_arcade_ext') + .values({ + arcadeId: Number(arcade.insertId!), + uuid, + visibility: Visibility.PRIVATE, + joinPrivacy: JoinPrivacy.INVITE_ONLY + }) + .executeTakeFirst(); + + await trx.insertInto('arcade_owner') + .values({ + user: user.id, + arcade: Number(arcade.insertId!), + permissions: (1 << ArcadePermissions.OWNER) | (1 << ArcadePermissions.VIEW) + }) + .executeTakeFirst(); + }); + + redirect('/arcade/' + uuid); +}; + +export const deleteArcade = async (id: number) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, id); + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.OWNER); + + await db.deleteFrom('arcade') + .where('id', '=', id) + .executeTakeFirst(); + + redirect('/arcade'); +} + +export const setUserArcadePermissions = async ({ arcadeUser, arcade, permissions }: { arcadeUser: number, arcade: number, permissions: number }) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, arcade); + if (!hasPermission(user.permissions, UserPermissions.USERMOD)) + requirePermission(arcadePermissions, ArcadePermissions.OWNER); + + await db.updateTable('arcade_owner') + .set({ permissions }) + .where('user', '=', arcadeUser) + .where('arcade', '=', arcade) + .executeTakeFirst(); +}; diff --git a/src/actions/auth.ts b/src/actions/auth.ts index 2eda26e..b9449aa 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -9,6 +9,8 @@ import { db } from '@/db'; import { UserPermissions } from '@/types/permissions'; import { requirePermission } from '@/helpers/permissions'; import { UserPayload } from '@/types/user'; +import { sql } from 'kysely'; +import { EMAIL_REGEX } from '@/helpers/validators'; export const getUser = async () => { const session = await auth(); @@ -63,8 +65,6 @@ export const logout = async (options: { redirectTo?: string, redirect?: boolean return signOut(options); }; -const emailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i; - export const register = async (formData: FormData) => { const username = formData.get('username')?.toString()?.trim(); const password = formData.get('password')?.toString()?.trim(); @@ -86,7 +86,7 @@ export const register = async (formData: FormData) => { return { error: true, message: 'Password must be at least 8 characters' }; if (!/^\d{20}$/.test(accessCode)) return { error: true, message: 'Invalid access code format' }; - if (!emailRegex.test(email)) + if (!EMAIL_REGEX.test(email)) return { error: true, message: 'Invalid email' }; const hashedPassword = await bcrypt.hash(password, process.env.BCRYPT_ROUNDS ? parseInt(process.env.BCRYPT_ROUNDS) : 12) @@ -130,5 +130,13 @@ export const register = async (formData: FormData) => { .set('email', email) .execute(); + await db.insertInto('actaeon_user_ext') + .values({ + userId: user.id, + uuid: sql`uuid_v4()`, + visibility: 0 + }) + .executeTakeFirst(); + return { error: false }; }; diff --git a/src/actions/machine.ts b/src/actions/machine.ts new file mode 100644 index 0000000..2f76dfd --- /dev/null +++ b/src/actions/machine.ts @@ -0,0 +1,151 @@ +'use server'; + +import { ValidatorMap } from '@/types/validator-map'; +import { db } from '@/db'; +import { ArcadeCab, countryValidator, getArcadeCabs, getArcadePermissions } from '@/data/arcade'; +import { requireUser } from '@/actions/auth'; +import { requireArcadePermission } from '@/helpers/permissions'; +import { ArcadePermissions } from '@/types/permissions'; +import type { Entries } from 'type-fest'; + +export type CabUpdate = Omit; + +const CAB_VALIDATORS: ValidatorMap = new Map(); +CAB_VALIDATORS.set('country', countryValidator); + + +const booleanValidator = (val: number | null | undefined) => { + val = +(val as any); + if (val !== 0 && val !== 1) + throw new Error('Invalid boolean value'); + return val; +} + +CAB_VALIDATORS.set('board', async (val, cab) => { + val = val!.toUpperCase().replace(/-/g, '').trim(); + + if (!/^[A-Z\d]{15}$/.test(val!)) + throw new Error('Invalid board number, must be 15 alphanumeric characters'); + + const existing = await db.selectFrom('machine') + .where(({ eb, and }) => and([ + eb('board', '=', val!), + ...(cab === null ? [] : [ + eb('id', '!=', cab) + ]) + ])) + .select('board') + .executeTakeFirst(); + + if (existing) + throw new Error('That board is already in use'); + + return val; +}); + +const getMachineBySerial = async (serial: string, excludeCab: number | null = null) => { + return db.selectFrom('machine') + .where(({ eb, and }) => and([ + eb('serial', 'like', `${serial.slice(0, 11)}%`), + ...(excludeCab === null ? [] : [ + eb('id', '!=', excludeCab) + ]) + ])) + .select('serial') + .executeTakeFirst(); +} + +CAB_VALIDATORS.set('serial', async (val, cab) => { + val = val!.toUpperCase().replace(/-/g, '').trim(); + + // https://gitea.tendokyu.moe/Hay1tsme/artemis/src/commit/87c7c91e3a7158aabfd2b8dbf69d6a5ca0249da1/core/adb_handlers/base.py#L123 + if (!/^A\d{2}[EX]\d{2}[A-HJ-NP-Z]\d{8}$/.test(val)) + throw new Error('Invalid keychip format'); + + if (await getMachineBySerial(val, cab)) + throw new Error('That serial is already in use'); + + return val; +}); + +CAB_VALIDATORS.set('is_cab', booleanValidator); +CAB_VALIDATORS.set('ota_enable', booleanValidator); +CAB_VALIDATORS.set('game', val => { + val = val!.trim().toUpperCase(); + // https://gitea.tendokyu.moe/Hay1tsme/artemis/src/commit/87c7c91e3a7158aabfd2b8dbf69d6a5ca0249da1/core/adb_handlers/base.py#L117 + if (!/^S[A-Z\d]{3}P?$/.test(val)) + throw new Error('Game must be in the format Sxxx or SxxxP'); +}); +CAB_VALIDATORS.set('timezone', () => {}); +CAB_VALIDATORS.set('memo', () => {}); + + + +export const deleteMachine = async (arcade: number, machine: number) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, arcade); + + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.REGISTRAR); + + await db.deleteFrom('machine') + .where('arcade', '=', arcade) + .where('id', '=', machine) + .executeTakeFirst(); +}; + +const validateUpdate = async (update: CabUpdate, cab: number | null) => { + if (!('serial' in update)) + return { error: true, message: 'Keychip is required' }; + + for (let [key, val] of (Object.entries(update) as Entries)) { + if (!CAB_VALIDATORS.has(key)) + return { error: true, message: `Unknown key ${key}` }; + if (key === 'serial' && !val) + return { error: true, message: 'Keychip is required' }; + + try { + if (val === undefined) val = null; + if (val !== null) + (update as any)[key] = ((await CAB_VALIDATORS.get(key)!(val, cab)) ?? val) as any; + else + (update as any)[key] = null; + } catch (e: any) { + return { error: true, message: e?.message ?? 'Unknown error' }; + } + } +} + +export const updateMachine = async ({ arcade, machine, update }: { arcade: number, machine: number, update: CabUpdate }) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, arcade); + + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.REGISTRAR); + + const res = await validateUpdate(update, machine); + if (res?.error) return res; + + await db.updateTable('machine') + .where('machine.id', '=', machine) + .where('machine.arcade', '=', arcade) + .set(update) + .executeTakeFirst(); +}; + +export const createMachine = async ({ arcade, update }: { arcade: number, update: CabUpdate }) => { + const user = await requireUser(); + const arcadePermissions = await getArcadePermissions(user, arcade); + + requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.REGISTRAR); + + const res = await validateUpdate(update, null); + if (res?.error) return { ...res, data: [] }; + + await db.insertInto('machine') + .values({ + ...update, + arcade + }) + .executeTakeFirst(); + + return { error: false, message: '', data: await getArcadeCabs({ arcade, user, permissions: arcadePermissions }) }; +} diff --git a/src/app/(with-header)/arcade/[arcadeId]/join/[join]/page.tsx b/src/app/(with-header)/arcade/[arcadeId]/join/[join]/page.tsx new file mode 100644 index 0000000..65d2cfb --- /dev/null +++ b/src/app/(with-header)/arcade/[arcadeId]/join/[join]/page.tsx @@ -0,0 +1,58 @@ +import { db } from '@/db'; +import { InvalidLink } from '@/components/invalid-link'; +import { requireUser } from '@/actions/auth'; +import { notFound, redirect } from 'next/navigation'; +import { JoinSuccess } from '@/components/join-success'; + +export default async function Join({ params }: { params: { arcadeId: string, join: string }}) { + const user = await requireUser(); + + if (!params.join) + return (); + + const joinLink = await db.selectFrom('actaeon_arcade_ext as ext') + .innerJoin('actaeon_arcade_join_keys as key', 'key.arcadeId', 'ext.arcadeId') + .where('ext.uuid', '=', params.arcadeId) + .where('key.id', '=', params.join) + .select(['key.arcadeId', 'key.remainingUses', 'key.totalUses', 'key.id']) + .executeTakeFirst(); + + if (!joinLink) + return (); + + const res = await db.selectFrom('arcade_owner') + .where('arcade', '=', joinLink.arcadeId) + .where('user', '=', user.id) + .select('user') + .executeTakeFirst(); + + if (res) + return redirect(`/arcade/${params.arcadeId}`); + + await db.transaction().execute(async trx => { + await trx.insertInto('arcade_owner') + .values({ + arcade: joinLink.arcadeId, + user: user.id, + permissions: 1 + }) + .executeTakeFirst(); + + if (joinLink.remainingUses !== null && joinLink.remainingUses <= 1) + await trx.deleteFrom('actaeon_arcade_join_keys') + .where('id', '=', joinLink.id) + .executeTakeFirst(); + else + await trx.updateTable('actaeon_arcade_join_keys') + .where('id', '=', joinLink.id) + .set({ + totalUses: joinLink.totalUses + 1, + ...(joinLink.remainingUses ? { + remainingUses: joinLink.remainingUses - 1 + } : {}) + }) + .executeTakeFirst(); + }); + + return (); +} diff --git a/src/app/(with-header)/arcade/[arcadeId]/page.tsx b/src/app/(with-header)/arcade/[arcadeId]/page.tsx new file mode 100644 index 0000000..b67c054 --- /dev/null +++ b/src/app/(with-header)/arcade/[arcadeId]/page.tsx @@ -0,0 +1,24 @@ +import { getUser } from '@/actions/auth'; +import { getArcadeCabs, getArcadeInviteLinks, getArcades, getArcadeUsers } from '@/data/arcade'; +import { notFound } from 'next/navigation'; +import { ArcadeDetail } from '@/components/arcade'; +import { PrivateVisibilityError } from '@/components/private-visibility-error'; + +export default async function ArcadeDetailPage({ params }: { params: { arcadeId: string }}) { + const user = await getUser(); + const arcade = (await getArcades({ user, uuids: [params.arcadeId], includeUnlisted: true }))[0]; + + if (!arcade) + return notFound(); + + if (!arcade.visible) + return ; + + const [users, cabs, links] = await Promise.all([ + getArcadeUsers({ arcade: arcade.id, permissions: arcade.permissions, user }), + getArcadeCabs({ arcade: arcade.id, permissions: arcade.permissions, user }), + getArcadeInviteLinks({ arcade: arcade.id, permissions: arcade.permissions, user }) + ]); + + return () +}; diff --git a/src/app/(with-header)/arcade/page.tsx b/src/app/(with-header)/arcade/page.tsx new file mode 100644 index 0000000..fe62297 --- /dev/null +++ b/src/app/(with-header)/arcade/page.tsx @@ -0,0 +1,72 @@ +import { Arcade, getArcades } from '@/data/arcade'; +import { getUser } from '@/actions/auth'; +import { Divider, Tooltip } from '@nextui-org/react'; +import { GlobeAltIcon, LinkIcon, LockClosedIcon, UserGroupIcon } from '@heroicons/react/24/outline'; +import { Visibility } from '@/types/privacy-visibility'; +import Link from 'next/link'; +import { CreateArcadeButton } from '@/components/create-arcade-button'; + +const getLocation = (arcade: Arcade) => { + let out = [arcade.city, arcade.state, arcade.country].filter(x => x).join(', '); + if (arcade.timezone) out += ` (${arcade.timezone})`; + + return out; +}; + +export default async function ArcadePage() { + const user = await getUser(); + const arcades = (await getArcades({ user })).filter(a => a.visible); + + return (
+
+ Arcades + +
+ +
+ {!arcades.length && No arcades found} + + {arcades.map(arcade =>
+ {arcade.visibility === Visibility.PUBLIC && + + } + {arcade.visibility === Visibility.UNLISTED && + + } + {arcade.visibility === Visibility.PRIVATE && + + } +
+ {arcade.uuid ? {arcade.name} : arcade.name} +
+ {getLocation(arcade) && + Location:  + {getLocation(arcade)} + } + {arcade.ip && + IP:  + {arcade.ip} + } +
+ + {!!arcade.machineCount && {arcade.machineCount!.toString()} Machine{Number(arcade.machineCount) > 1 ? 's' : ''}} + + +
+ + {arcade.userCount?.toString()} +
+
+ + Operated by {arcade.ownerUuid ? + + {arcade.ownerUsername} + : anonymous user} +
+
)} +
+ +
); +} diff --git a/src/app/(with-header)/chuni/dashboard/page.tsx b/src/app/(with-header)/chuni/dashboard/page.tsx index f82570c..7bfbffa 100644 --- a/src/app/(with-header)/chuni/dashboard/page.tsx +++ b/src/app/(with-header)/chuni/dashboard/page.tsx @@ -4,7 +4,6 @@ import { ChuniPlaylogCard } from '@/components/chuni/playlog-card'; import { getUserData, getUserRating } from '@/actions/chuni/profile'; import { requireUser } from '@/actions/auth'; import { notFound } from 'next/navigation'; -import { ChuniTopRating } from '@/components/chuni/top-rating'; import { ChuniTopRatingSidebar } from '@/components/chuni/top-rating-sidebar'; import { Button } from '@nextui-org/react'; import Link from 'next/link'; diff --git a/src/app/(with-header)/chuni/music/page.tsx b/src/app/(with-header)/chuni/music/page.tsx index a9c2847..1060538 100644 --- a/src/app/(with-header)/chuni/music/page.tsx +++ b/src/app/(with-header)/chuni/music/page.tsx @@ -1,6 +1,4 @@ import { getMusic } from '@/actions/chuni/music'; -import { SelectItem, Slider } from '@nextui-org/react'; -import { FilterSorter } from '@/components/filter-sorter'; import { ChuniMusicList } from '@/components/chuni/music-list'; diff --git a/src/app/(with-header)/loading.tsx b/src/app/(with-header)/loading.tsx index 931f3ec..66eb589 100644 --- a/src/app/(with-header)/loading.tsx +++ b/src/app/(with-header)/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton, Spinner } from '@nextui-org/react'; +import { Spinner } from '@nextui-org/react'; export default function Loading() { return (
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..03a661a --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return (
Not Found.
) +} diff --git a/src/auth.ts b/src/auth.ts index 19bf802..67c2332 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,9 +3,10 @@ import CredentialsProvider from 'next-auth/providers/credentials'; import { db, GeneratedDB } from '@/db'; import bcrypt from 'bcrypt'; import { DBUserPayload } from '@/types/user'; -import React from 'react'; -import { SelectQueryBuilder } from 'kysely'; +import { cache } from 'react'; +import { SelectQueryBuilder, sql } from 'kysely'; import { AimeUser } from '@/types/db'; +import crypto from 'crypto'; let basePath = process.env.BASE_PATH ?? ''; if (basePath.endsWith('/')) basePath = basePath.slice(0, -1); @@ -17,9 +18,14 @@ const selectUserProps = (builder: SelectQueryBuilder join.onRef('chuni.user', '=', 'u.id')) + .leftJoin('actaeon_user_ext as ext', 'ext.userId', 'u.id') .select(({ fn }) => [ 'u.username', 'u.password', 'u.id', 'u.email', 'u.permissions', 'u.created_date', 'u.last_login_date', 'u.suspend_expire_time', + 'ext.uuid', + 'ext.visibility', + 'ext.homepage', + 'ext.team', fn('not isnull', ['chuni.id']).as('chuni') ]) .executeTakeFirst(); @@ -49,6 +55,22 @@ const nextAuth = NextAuth({ session({ session, token, user }) { session.user = { ...session.user, ...(token.user as any) }; return session; + }, + async signIn({ user }) { + if ((user as any).visibility === null) { + const uuid = crypto.randomUUID(); + await db.insertInto('actaeon_user_ext') + .values({ + userId: (user as any).id, + uuid, + visibility: 0 + }) + .executeTakeFirst(); + (user as any).uuid = uuid; + (user as any).visibility = 0; + } + + return true; } }, providers: [CredentialsProvider({ @@ -68,14 +90,14 @@ const nextAuth = NextAuth({ if (!user?.password || !await bcrypt.compare(password.trim(), user.password)) return null; - const { password: _, ...payload } = user satisfies DBUserPayload; + const { password: _, ...payload } = user satisfies { [K in keyof DBUserPayload]: DBUserPayload[K] | null }; return payload as any; } })] }); -export const auth = React.cache(nextAuth.auth); +export const auth = cache(nextAuth.auth); export const { handlers: { GET, POST }, diff --git a/src/components/arcade.tsx b/src/components/arcade.tsx new file mode 100644 index 0000000..6b1eb1a --- /dev/null +++ b/src/components/arcade.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { Arcade, ArcadeCab, ArcadeLink, ArcadeUser } from '@/data/arcade'; +import { JoinPrivacy, Visibility } from '@/types/privacy-visibility'; +import { Autocomplete, AutocompleteItem, Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Select, SelectItem, Tooltip } from '@nextui-org/react'; +import { ChevronDownIcon, GlobeAltIcon, LinkIcon, LockClosedIcon, PencilIcon, PencilSquareIcon, PlusIcon, UserMinusIcon, UserPlusIcon } from '@heroicons/react/24/outline'; +import { useRef, useState } from 'react'; +import { useUser } from '@/helpers/use-user'; +import { hasArcadePermission, hasPermission } from '@/helpers/permissions'; +import { ARCADE_PERMISSION_NAMES, ArcadePermissions, UserPermissions } from '@/types/permissions'; +import { ALLNET_JAPAN_REGION, WACCA_REGION } from '@/types/region'; +import { COUNTRY_CODES } from '@/types/country'; +import { ArcadeUpdate, createArcadeLink, deleteArcade, deleteArcadeLink, joinPublicArcade, removeUserFromArcade, setUserArcadePermissions, updateArcade } from '@/actions/arcade'; +import { useErrorModal } from '@/components/error-modal'; +import { Entries } from 'type-fest'; +import { Cab } from '@/components/cab'; +import Link from 'next/link'; +import { useConfirmModal } from '@/components/confirm-modal'; +import { XMarkIcon } from '@heroicons/react/20/solid'; +import { JoinLinksModal } from '@/components/join-links-modal'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { PermissionEditModal, PermissionEditModalUser } from '@/components/permission-edit-modal'; + +export type ArcadeProps = { + arcade: Arcade, + users: ArcadeUser[], + cabs: ArcadeCab[], + links: ArcadeLink[] +}; + +const ARCADE_KEYS = ['nickname', 'city', 'state', 'country', 'country_id', 'timezone', 'region_id', 'ip'] as const; + +const ARCADE_UPDATE_KEYS = ['visibility', 'joinPrivacy', 'name', 'nickname', 'country', 'country_id', + 'state', 'city', 'region_id', 'timezone', 'ip']; + +const getArcadeLabel = (k: string) => { + if (k === 'ip') return 'IP'; + if (k === 'region_id') return 'Region'; + + return `${k[0].toUpperCase()}${k.slice(1).replace(/_id$/ig, ' ID')}`; +}; + +const getArcadeValue = (arcade: T, k: keyof T) => { + if (k === 'country') + return COUNTRY_CODES.get(arcade.country as any) ?? arcade.country; + if (k === 'region_id') + return ALLNET_JAPAN_REGION.get(+(arcade.region_id as any)) ?? arcade.region_id; + + return arcade[k]?.toString(); +} + +export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs: initialCabs, links }: ArcadeProps) => { + const searchParams = useSearchParams(); + const [arcade, setArcade] = useState({ + ...initialArcade, region_id: + initialArcade?.region_id?.toString(), + country_id: initialArcade?.country_id?.toString() + }); + const [editing, setEditing] = useState(!!searchParams.get('editing')); + const [regionValue, setRegionValue] = useState(''); + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState(initialUsers); + const arcadeRestore = useRef({ ...arcade }); + const user = useUser(); + const setError = useErrorModal(); + const [cabs, setCabs] = useState(initialCabs); + const [creatingNewCab, setCreatingNewCab] = useState(false); + const confirm = useConfirmModal(); + const [linksOpen, setLinksOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const router = useRouter(); + + const save = () => { + const arcadeUpdateVals = Object.fromEntries(Object.entries(arcade) + .filter(([k]) => ARCADE_UPDATE_KEYS.includes(k))); + + const update: Partial = { + ...arcadeUpdateVals, + country_id: (arcade.country_id === '' || arcade.country_id === undefined) ? null : +arcade.country_id, + region_id: null + }; + + const regionText = (arcade.region_id ?? regionValue ?? '') + .replace(/-wacca$/, '').trim(); + update.region_id = regionText === '' ? null : +regionText; + + (Object.entries(update) as Entries).forEach(([k, v]) => { + if (typeof v === 'string') + (update as any)[k] = v = v.trim(); + if (v === '') (update as any)[k] = null; + }); + + setLoading(true); + updateArcade(initialArcade.id, update) + .then(data => { + if (data?.error) + return setError(data?.message!); + setEditing(false); + }) + .finally(() => setLoading(false)); + }; + + const renderEdit = (k: keyof Arcade) => { + const label = getArcadeLabel(k); + if (k === 'country') + return (); + + if (k === 'region_id') + return ( setArcade(a => ({ ...a, region_id: s?.toString() }))}> + {[...[...ALLNET_JAPAN_REGION].map( + ([regionId, name]) => ( + {name} ({regionId}) + ))] + // looks like allnet -> wacca region id is handled internally by artemis? + // ...[...WACCA_REGION].map(([regionId, name]) => ( + // + // (WACCA) {name} ({regionId}) + // ))] + } + ); + + return ( setArcade(a => ({ ...a, [k]: ev.target.value }))} />); + }; + + const visibilityIcon = (<> + {arcade.visibility === Visibility.PUBLIC && + + } + {arcade.visibility === Visibility.UNLISTED && + + } + {arcade.visibility === Visibility.PRIVATE && + + }); + + return (
+ deleteArcadeLink(arcade.id, id)} + onCreate={uses => createArcadeLink(arcade.id, uses)} + open={linksOpen} onClose={() => setLinksOpen(false)} /> + + setEditingUser(null)} + permissions={ARCADE_PERMISSION_NAMES} + displayUpTo={hasPermission(user?.permissions, UserPermissions.ACMOD) ? + ArcadePermissions.OWNER : ArcadePermissions.REGISTRAR} + onEdit={(user, permissions) => { + setUsers(u => u.map(u => u.id === user ? { ...u, permissions } : u)); + setUserArcadePermissions({ arcadeUser: user, permissions, arcade: arcade.id }); + }} /> +
+ {editing ? + <> + + + + + typeof s !== 'string' && s.size && setArcade( + a => ({ ...a, visibility: +[...s][0] }))}> + }> + Private + + }> + Unlisted + + }> + Public + + + + setArcade(a => ({ ...a, name: ev.target.value }))} + classNames={{ + input: 'text-4xl leading-none', + inputWrapper: 'h-24' + }} /> + : + <>{visibilityIcon} {arcade.name}} +
+ {editing ?
+ {ARCADE_KEYS.map(renderEdit)} + + +
+ + +
+
: +
+ {ARCADE_KEYS.map(k => + arcade[k] && + {getArcadeLabel(k)}: {getArcadeValue(arcade, k)} + ) + } + Join Privacy: + {arcade.joinPrivacy === JoinPrivacy.INVITE_ONLY ? 'Invite only' : 'Public'} + + {hasArcadePermission(arcade.permissions, user?.permissions, ArcadePermissions.EDITOR) && + + + + } +
} + + + +
+
+ Machines + {!creatingNewCab && + } +
+
+ {creatingNewCab && setCreatingNewCab(false)} + onNewData={setCabs} />} + {(cabs.length || creatingNewCab) ? cabs.map( + cab => ( setCabs(c => c.map(c => c.id === cab.id ? newCab : c))} + onDelete={() => setCabs(c => c.filter(c => c.id !== cab.id))} />)) : + This arcade has no machines} +
+
+ + + +
+
+ Users + + {arcade.joinPrivacy === JoinPrivacy.PUBLIC && !arcade.permissions && + + } + + {(hasPermission(arcade.permissions, ArcadePermissions.OWNER) || + hasPermission(user?.permissions, UserPermissions.OWNER)) && + + } + + {!!arcade.permissions && !hasPermission(arcade.permissions, ArcadePermissions.OWNER) && + Leave this arcade}> + + } +
+ {!users.length && This arcade has no users} +
+ {users.map((arcadeUser, index) => (
+ {'username' in arcadeUser ? + {arcadeUser.username} : + Anonymous User} + + {(hasPermission(arcade.permissions, ArcadePermissions.OWNER) || + hasPermission(user?.permissions, UserPermissions.USERMOD)) && + + + + } + + {(hasPermission(arcade.permissions, ArcadePermissions.OWNER) || + hasPermission(user?.permissions, UserPermissions.USERMOD)) && + arcadeUser.id !== user?.id && + Kick user}> + + } +
))} +
+
+ + + + {hasArcadePermission(arcade.permissions, user?.permissions, ArcadePermissions.OWNER) &&
+
+ Management +
+ + +
} +
+); +}; diff --git a/src/components/cab.tsx b/src/components/cab.tsx new file mode 100644 index 0000000..373e2ba --- /dev/null +++ b/src/components/cab.tsx @@ -0,0 +1,221 @@ +import { ArcadeCab } from '@/data/arcade'; +import { GAME_IDS } from '@/types/game-ids'; +import { COUNTRY_CODES } from '@/types/country'; +import { useConfirmModal } from '@/components/confirm-modal'; +import { useUser } from '@/helpers/use-user'; +import { hasArcadePermission } from '@/helpers/permissions'; +import { ArcadePermissions } from '@/types/permissions'; +import { Autocomplete, AutocompleteItem, Button, Checkbox, Input, Select, SelectItem, Textarea, Tooltip } from '@nextui-org/react'; +import { ArrowPathIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { createMachine, deleteMachine, updateMachine } from '@/actions/machine'; +import { useRef, useState } from 'react'; +import { Entries } from 'type-fest'; +import { useErrorModal } from '@/components/error-modal'; +import { generateRandomKeychip } from '@/helpers/keychip'; + +type CabProps = { + cab?: ArcadeCab, + permissions: number | null, + onDelete: () => void, + onEdit?: (cab: ArcadeCab) => void, + onNewData?: (cab: ArcadeCab[]) => void, + creatingNew?: boolean +}; + +const formatSerial = (s: string) => { + s = s.replace(/[-\s]/g, ''); + if (s.length < 4) return s; + return `${s.slice(0, 4)}-${s.slice(4)}`; +}; + +export const Cab = ({ cab: initialCab, permissions, onEdit, onNewData, onDelete, creatingNew }: CabProps) => { + const confirm = useConfirmModal(); + const user = useUser(); + const [cab, setCab] = useState(initialCab ? { + ...initialCab, + serial: initialCab?.serial ? formatSerial(initialCab.serial) : initialCab.serial, + board: initialCab?.board ? formatSerial(initialCab.board) : initialCab.board, + } : ({} as ArcadeCab)); + const restoreCab = useRef({ ...cab }); + const [editing, setEditing] = useState(false); + const [gameInput, setGameInput] = useState(''); + const [loading, setLoading] = useState(false); + const setError = useErrorModal(); + + const save = () => { + setLoading(true); + const { id, arcade, ...update } = cab; + update.game ??= gameInput; + + (Object.entries(update) as Entries).forEach(([k, v]) => { + if (typeof v === 'string') + (update as any)[k] = v = v.trim(); + if (v === '') (update as any)[k] = null; + }); + + if (creatingNew) + return createMachine({ arcade, update }) + .then(res => { + if (res?.error) + return setError(res.message); + onNewData?.(res.data) + onDelete?.(); + }) + .finally(() => setLoading(false)) + + updateMachine({ arcade, machine: id, update }) + .then(res => { + if (res?.error) + return setError(res.message); + setEditing(false); + setCab(c => ({ ...cab, ...update })); + onEdit?.({ ...cab, ...update }); + }) + .finally(() => setLoading(false)) + }; + + const renderEdit = (k: keyof ArcadeCab) => { + if (k === 'game') + return ( setCab(c => ({ ...c, game: s?.toString() }))}> + {[...GAME_IDS].map(([id, name]) => ( + {id} ({name}) + ))} + ); + + if (k === 'country') + return () + + if (k === 'ota_enable' || k === 'is_cab') + return ( setCab(c => ({ ...c, [k]: +v }))}> + {k === 'ota_enable' ? 'OTA Enabled' : 'Is Cab'} + ) + + const setFormatSerial = () => setCab(c => c.serial ? + ({ ...c, serial: formatSerial(c.serial) }) : + c + ); + + if (k === 'serial') + return (
+ setCab(c => ({ + ...c, + serial: v.toUpperCase() + }))} + value={cab.serial ?? ''} /> + + + +
) + + return ( setCab(c => + ({ ...c, [k]: k === 'board' ? val.toUpperCase() : val }))} + label={`${k[0].toUpperCase()}${k.slice(1)}`} />); + }; + + if (creatingNew || editing) return (
+
+ {(['game', 'serial', 'board', 'country', 'timezone', 'ota_enable', 'is_cab'] as const).map(renderEdit)} + +
+ +