Compare commits
548
Commits
v1.0.2
...
93601e58e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93601e58e0 | ||
|
|
4863f78795 | ||
|
|
52516a368d | ||
|
|
d4fbbfa243 | ||
|
|
c023436413 | ||
|
|
d602ee59c7 | ||
|
|
1374bd332f | ||
|
|
1610aa1008 | ||
|
|
5b190cb929 | ||
|
|
5f7875ff18 | ||
|
|
9a0b6e091a | ||
|
|
e3a4726648 | ||
|
|
75b85dcdd9 | ||
|
|
a11575bae3 | ||
|
|
37ad27c5f3 | ||
|
|
384ede19c4 | ||
|
|
edfe878b2a | ||
|
|
9af1e62655 | ||
|
|
1dc8d1d46f | ||
|
|
2920a6521b | ||
|
|
7556b1f3fa | ||
|
|
f70d96b81c | ||
|
|
96c33efced | ||
|
|
a7ccc98f84 | ||
|
|
adf0658916 | ||
|
|
3efd6c90f0 | ||
|
|
4c8225720a | ||
|
|
a98b194386 | ||
|
|
b57697b2e0 | ||
|
|
d4677972cd | ||
|
|
c17ef0e7ab | ||
|
|
903d175f2d | ||
|
|
b1ddda66ff | ||
|
|
488b6e57c4 | ||
|
|
cdfae0b967 | ||
|
|
6ae0aef6c0 | ||
|
|
f249551a2f | ||
|
|
4d0c8c7e65 | ||
|
|
222535e116 | ||
|
|
2b8f73a91d | ||
|
|
c9e35cf602 | ||
|
|
65a62867db | ||
|
|
1ae59bcc1a | ||
|
|
b9c47aa89a | ||
|
|
cb77b2d3b9 | ||
|
|
b506b4499a | ||
|
|
8a31556d51 | ||
|
|
b235ce08ff | ||
|
|
a113e48880 | ||
|
|
24d6cf7047 | ||
|
|
d57424a9ee | ||
|
|
678d22ceab | ||
|
|
85ad0452d3 | ||
|
|
5574e601bb | ||
|
|
300ac0ef95 | ||
|
|
bcb0ca977f | ||
|
|
2e95a106e0 | ||
|
|
020c06f172 | ||
|
|
abc707cfb2 | ||
|
|
bfc5bd78a1 | ||
|
|
9c4b973faf | ||
|
|
1ec05cda39 | ||
|
|
b70bd401d1 | ||
|
|
08358b8aa4 | ||
|
|
d1160eb65a | ||
|
|
c524b305d7 | ||
|
|
d09eaa33d6 | ||
|
|
fd68819d9b | ||
|
|
bdab924bdf | ||
|
|
3fa1119f5a | ||
|
|
29fe22297b | ||
|
|
f2ac4745d4 | ||
|
|
3baac5e885 | ||
|
|
a6e69fe00e | ||
|
|
3d385d1869 | ||
|
|
b59d6c21af | ||
|
|
a6a288d9ff | ||
|
|
7f39ba4257 | ||
|
|
ca59a1065f | ||
|
|
7a41775c7e | ||
|
|
fcd1dfd96e | ||
|
|
91c9efcbb4 | ||
|
|
25b76437ff | ||
|
|
e1b31ff773 | ||
|
|
7920d4f46c | ||
|
|
d1382c839e | ||
|
|
e3f8b51d31 | ||
|
|
c325c53774 | ||
|
|
3788ecd9f1 | ||
|
|
0117b45d74 | ||
|
|
e5c230ba35 | ||
|
|
2970dff563 | ||
|
|
139e8cfc3a | ||
|
|
944e8ff1d8 | ||
|
|
01dceac073 | ||
|
|
7895188b56 | ||
|
|
00618fb2a3 | ||
|
|
796d009c07 | ||
|
|
6aed71a8b2 | ||
|
|
ca666d9f90 | ||
|
|
4c399873d1 | ||
|
|
60968409aa | ||
|
|
4e0bf598ed | ||
|
|
64bb230de5 | ||
|
|
750fcb2e5c | ||
|
|
164a4f4ef6 | ||
|
|
555c783d55 | ||
|
|
ed6eaf33a0 | ||
|
|
a3e3356ea7 | ||
|
|
b3340dbfdf | ||
|
|
087dd7d873 | ||
|
|
9f0ee74ce8 | ||
|
|
005af25773 | ||
|
|
89c1751e79 | ||
|
|
e3ed37d170 | ||
|
|
ac5cd6bc3f | ||
|
|
2eb0456999 | ||
|
|
86a2d9d069 | ||
|
|
b5957015e7 | ||
|
|
483d5b8c67 | ||
|
|
21cd88ccfd | ||
|
|
89dec9da05 | ||
|
|
7c93d3e766 | ||
|
|
bdb8f0b02b | ||
|
|
edc165ba8d | ||
|
|
cc6bb9a9a3 | ||
|
|
b7edbca043 | ||
|
|
67cb4b488d | ||
|
|
a9bdd6bd5d | ||
|
|
f72a2b6c74 | ||
|
|
74756dfed2 | ||
|
|
d42cd2af53 | ||
|
|
d5eadeb3a7 | ||
|
|
2a36ba1956 | ||
|
|
08f8d54888 | ||
|
|
ebc263da35 | ||
|
|
a158c78a7c | ||
|
|
9d0da5d65a | ||
|
|
e7f70bb429 | ||
|
|
f25a26a93e | ||
|
|
2d09a4a92c | ||
|
|
0201d21c38 | ||
|
|
3070d85423 | ||
|
|
f31530b3d7 | ||
|
|
3cc201a0ce | ||
|
|
94b0b65ca0 | ||
|
|
4eded4ec61 | ||
|
|
f63ca708fe | ||
|
|
e06596391e | ||
|
|
a5044c80d7 | ||
|
|
416c31643b | ||
|
|
a47b4eb195 | ||
|
|
b6bf5e69ca | ||
|
|
0b46a94d57 | ||
|
|
ff2b2af6d4 | ||
|
|
d19ba88005 | ||
|
|
cc241f0b7e | ||
|
|
8bf4663336 | ||
|
|
52ec48ffe4 | ||
|
|
addc9c7b1a | ||
|
|
cfeb0f19b8 | ||
|
|
14cfad4029 | ||
|
|
2d5f03048a | ||
|
|
7edca410fa | ||
|
|
c63c9830b2 | ||
|
|
7d62a04f0e | ||
|
|
3b3d7780c9 | ||
|
|
9b7d5de12f | ||
|
|
949e14c109 | ||
|
|
4d9845b52f | ||
|
|
3a03a3db15 | ||
|
|
09850c807c | ||
|
|
eb9f01d6fe | ||
|
|
a619f89106 | ||
|
|
dee4031421 | ||
|
|
3f10db41db | ||
|
|
601a3e746f | ||
|
|
3a76dbdb97 | ||
|
|
1a0d20fae5 | ||
|
|
6ae7c09682 | ||
|
|
6ed1b03e24 | ||
|
|
f94246e067 | ||
|
|
8d1b09f151 | ||
|
|
7116f49e2b | ||
|
|
5efd004533 | ||
|
|
4a95b53b02 | ||
|
|
3a292a44f1 | ||
|
|
6dced0fb54 | ||
|
|
d6af754211 | ||
|
|
02f8ffc0bc | ||
|
|
c7ba04bda1 | ||
|
|
c2e61dc324 | ||
|
|
c043af318b | ||
|
|
2b7b445b73 | ||
|
|
34475e9bc2 | ||
|
|
5a73ba292e | ||
|
|
cd418e0390 | ||
|
|
fd603129aa | ||
|
|
ca1756fbb0 | ||
|
|
536b61c7e7 | ||
|
|
02a3176dca | ||
|
|
5311ee1fb8 | ||
|
|
adb56eb80b | ||
|
|
f24426c1fa | ||
|
|
19450d56c5 | ||
|
|
e06e419707 | ||
|
|
e6dd22a4de | ||
|
|
85b5792d40 | ||
|
|
5668493485 | ||
|
|
1d8672ccc5 | ||
|
|
ac7c427c3d | ||
|
|
2996ae3ab0 | ||
|
|
53db4a858b | ||
|
|
6843af235e | ||
|
|
488f40919d | ||
|
|
eb885c044f | ||
|
|
b109ca6f32 | ||
|
|
bb7ec3db02 | ||
|
|
0b0cf4f37f | ||
|
|
86f3d82c5d | ||
|
|
b5c8a29956 | ||
|
|
8174ffa6af | ||
|
|
d156ad9905 | ||
|
|
3d832cceff | ||
|
|
f2a023efb3 | ||
|
|
e66b2c1a5a | ||
|
|
2339485e9a | ||
|
|
01a3ba6e39 | ||
|
|
8bb339cc49 | ||
|
|
984b5bf793 | ||
|
|
92b0ba3b18 | ||
|
|
4b7cb519df | ||
|
|
652d331402 | ||
|
|
7ad78db0ea | ||
|
|
01369d8943 | ||
|
|
56bfa164a1 | ||
|
|
b811086de6 | ||
|
|
3a63152ee3 | ||
|
|
60b6d0856c | ||
|
|
29958d0f8c | ||
|
|
6021a91ef7 | ||
|
|
2b9a015afb | ||
|
|
0b1d3b24c2 | ||
|
|
3fa192a108 | ||
|
|
121dda7249 | ||
|
|
7911a25ced | ||
|
|
dee843106f | ||
|
|
0b4fc4559e | ||
|
|
635abfbc9f | ||
|
|
b4968a9967 | ||
|
|
b4021f6b42 | ||
|
|
bcd24bfca4 | ||
|
|
99e77d9872 | ||
|
|
18825115a4 | ||
|
|
c95ad4a37f | ||
|
|
305a0c42ff | ||
|
|
7257a7b0f4 | ||
|
|
8a6c596727 | ||
|
|
f18735cf3e | ||
|
|
7c9602c5cd | ||
|
|
3b8bf74e35 | ||
|
|
f285a7cd98 | ||
|
|
54f2bc5e0c | ||
|
|
5b09179404 | ||
|
|
e31f6d985c | ||
|
|
7ef5038a9e | ||
|
|
f89b8a6adf | ||
|
|
3b6ebe7bdc | ||
|
|
a810fba0ae | ||
|
|
27a3652a79 | ||
|
|
3d89e108f3 | ||
|
|
0f35bc8529 | ||
|
|
1bca9657fa | ||
|
|
361ff56d65 | ||
|
|
e76c530e4f | ||
|
|
e5ec3c1f06 | ||
|
|
dc9e5a567c | ||
|
|
65e3915a5f | ||
|
|
9463c8d4da | ||
|
|
578a6e943a | ||
|
|
ade0f286d3 | ||
|
|
9e453666e5 | ||
|
|
abc06e438c | ||
|
|
0c39405dab | ||
|
|
c808c59fce | ||
|
|
e867b675fc | ||
|
|
0bd2ac6707 | ||
|
|
06cd0cfe42 | ||
|
|
adc201bfb6 | ||
|
|
bf24d9575a | ||
|
|
9702ab304c | ||
|
|
4a6fcd050e | ||
|
|
49185dd7a9 | ||
|
|
0aef27efc4 | ||
|
|
3213126a12 | ||
|
|
2f708bcf11 | ||
|
|
7a2c0fd4d4 | ||
|
|
fc7e6ca248 | ||
|
|
703c29bc83 | ||
|
|
b1ac2e1331 | ||
|
|
de28f26e0e | ||
|
|
ca49c6b5a8 | ||
|
|
6549ae1959 | ||
|
|
4993b75608 | ||
|
|
ae1393d3e3 | ||
|
|
76730b84cf | ||
|
|
5c035dc7f5 | ||
|
|
304cfb5be7 | ||
|
|
02abf8f88c | ||
|
|
ebd2ff131c | ||
|
|
6a67fb6e83 | ||
|
|
24bf8eabb9 | ||
|
|
510d070dc5 | ||
|
|
82e8448f26 | ||
|
|
11e8b06bf8 | ||
|
|
51ef7602ca | ||
|
|
b25e7b00dd | ||
|
|
94ccd098d5 | ||
|
|
f792c276b7 | ||
|
|
aee099339c | ||
|
|
a1775fbf1e | ||
|
|
4c93dc38c6 | ||
|
|
7149fb146b | ||
|
|
cfcb245c40 | ||
|
|
c2e48a8a0e | ||
|
|
1b817fe434 | ||
|
|
6f53ca514d | ||
|
|
8b7298657d | ||
|
|
366fb7e538 | ||
|
|
c505841990 | ||
|
|
af8aaaa512 | ||
|
|
00b5a6f5c3 | ||
|
|
02ad9c21f2 | ||
|
|
101c73df1b | ||
|
|
d1dafba2c9 | ||
|
|
936053cca6 | ||
|
|
79b82e0a31 | ||
|
|
4d4cbd2211 | ||
|
|
1851653afb | ||
|
|
172941c542 | ||
|
|
742536820a | ||
|
|
e8209df294 | ||
|
|
4816d3eebf | ||
|
|
bdcf1451f8 | ||
|
|
b7f14ff5e6 | ||
|
|
dd708a64fb | ||
|
|
694a24c791 | ||
|
|
dc8fb8a6ed | ||
|
|
ac9b3e3cb5 | ||
|
|
b892786ff8 | ||
|
|
83884ef2e3 | ||
|
|
b06f27ef89 | ||
|
|
a2b0752076 | ||
|
|
1fd6f7146b | ||
|
|
0abcead359 | ||
|
|
3edc987281 | ||
|
|
2e87e5bf09 | ||
|
|
b87c9a5b8d | ||
|
|
5c60ce364c | ||
|
|
cef068ff3c | ||
|
|
7137b046d7 | ||
|
|
c565351c2f | ||
|
|
847febbe64 | ||
|
|
0bf6133775 | ||
|
|
af8b59e024 | ||
|
|
a338f9cb72 | ||
|
|
624edf751a | ||
|
|
1258fd8e7b | ||
|
|
9fbfd2f51a | ||
|
|
fa7fc75c9a | ||
|
|
04b84e6055 | ||
|
|
2ee5ba5c4c | ||
|
|
d587749b50 | ||
|
|
b1a1e5125c | ||
|
|
750973de38 | ||
|
|
1167a48344 | ||
|
|
40da24bd8c | ||
|
|
e97213e5d1 | ||
|
|
86d59195ec | ||
|
|
1cbb33a4e2 | ||
|
|
40a21ba767 | ||
|
|
cf89681576 | ||
|
|
2fba923a3a | ||
|
|
080dc34e23 | ||
|
|
450e7e8d00 | ||
|
|
30ed2b8ce2 | ||
|
|
6fc14b9b84 | ||
|
|
bee55e1fd4 | ||
|
|
613b7502e1 | ||
|
|
44c34dbae6 | ||
|
|
5308ce6a12 | ||
|
|
0c8e62357f | ||
|
|
cde15ad022 | ||
|
|
1f9e8a0eec | ||
|
|
bac2f653af | ||
|
|
88ac5ac1ba | ||
|
|
cf563f3550 | ||
|
|
6b6fc5dc64 | ||
|
|
ab43f5d63c | ||
|
|
c78f8812ad | ||
|
|
aed70db81f | ||
|
|
1126a70d55 | ||
|
|
86cd43def9 | ||
|
|
01570874a8 | ||
|
|
a81af2d71a | ||
|
|
44b4b3b45c | ||
|
|
b3245a18ea | ||
|
|
a57c73055f | ||
|
|
bfc951cfd9 | ||
|
|
828fda63e7 | ||
|
|
1f4aadb3d7 | ||
|
|
c7fb37356e | ||
|
|
95b1955218 | ||
|
|
d173dd3194 | ||
|
|
0dc16e832d | ||
|
|
59f03827bc | ||
|
|
0d6032d444 | ||
|
|
ff1fa319a5 | ||
|
|
cc02851be3 | ||
|
|
7c1f8d709e | ||
|
|
2799b63943 | ||
|
|
fd8ab7db93 | ||
|
|
14a3de0f2a | ||
|
|
22d455da7a | ||
|
|
8275842e78 | ||
|
|
c601ffbb76 | ||
|
|
d39738eb0d | ||
|
|
d69e3f5da5 | ||
|
|
811cf5719b | ||
|
|
2194aa1239 | ||
|
|
16191cef14 | ||
|
|
9c4949186c | ||
|
|
db957d7fb1 | ||
|
|
725e1cb338 | ||
|
|
3bd712e744 | ||
|
|
ba82789166 | ||
|
|
d8199ea1e4 | ||
|
|
24d0b735c1 | ||
|
|
422567272c | ||
|
|
ca72966a37 | ||
|
|
30b54e99e7 | ||
|
|
9aa4f331bf | ||
|
|
580972c0a0 | ||
|
|
2cd0863cb8 | ||
|
|
7c39aef5d9 | ||
|
|
53b350057d | ||
|
|
65b2fbee6a | ||
|
|
22234fe102 | ||
|
|
096f55b394 | ||
|
|
ed9c23c702 | ||
|
|
9854719cfd | ||
|
|
67656a9aad | ||
|
|
20b85381fe | ||
|
|
e5ff7ec9e5 | ||
|
|
7f7971f578 | ||
|
|
d1807ca814 | ||
|
|
ef5e706ee1 | ||
|
|
c0bbcae5d6 | ||
|
|
2f030deb42 | ||
|
|
9a8905c726 | ||
|
|
beae86194d | ||
|
|
56061e41d4 | ||
|
|
9d1c71d926 | ||
|
|
744e224fd0 | ||
|
|
a2acac2c4c | ||
|
|
0224b34f15 | ||
|
|
1127807aaa | ||
|
|
3ac5d6f4f6 | ||
|
|
3e584aadaf | ||
|
|
bfbc02c75d | ||
|
|
98613af875 | ||
|
|
7138ae4445 | ||
|
|
3ad3f9512c | ||
|
|
fe46bd0544 | ||
|
|
822dfd6a1c | ||
|
|
525f58cd24 | ||
|
|
d805e54053 | ||
|
|
583d05eee9 | ||
|
|
58fae1f90f | ||
|
|
474de0717b | ||
|
|
ed5b9a6f7c | ||
|
|
6eab5e5343 | ||
|
|
686279eb25 | ||
|
|
c0a2c9db5e | ||
|
|
d8bb117114 | ||
|
|
63ad651b9b | ||
|
|
b03dbb295f | ||
|
|
4b2d893905 | ||
|
|
e07da3d052 | ||
|
|
10932a8799 | ||
|
|
4ec75c2c1d | ||
|
|
1e3b7bc31d | ||
|
|
ea7d861e69 | ||
|
|
c433e5a52f | ||
|
|
540bce911f | ||
|
|
749dc237f1 | ||
|
|
8c193936f6 | ||
|
|
d3068ce88b | ||
|
|
340ecb42c8 | ||
|
|
39b211084d | ||
|
|
22162fa8b9 | ||
|
|
ae2b9328b9 | ||
|
|
84f64b6615 | ||
|
|
91357aca3b | ||
|
|
cc95a0d690 | ||
|
|
da390e447f | ||
|
|
4bd717d627 | ||
|
|
1311d36933 | ||
|
|
ded8fa62af | ||
|
|
3bf0644a9a | ||
|
|
d87c4284fe | ||
|
|
9cdb0b85fb | ||
|
|
7cdb785218 | ||
|
|
7f58e428ed | ||
|
|
a8d86a10bc | ||
|
|
b926ffa93c | ||
|
|
65fae257ba | ||
|
|
22a9fc13f7 | ||
|
|
3b853efba0 | ||
|
|
72079050f4 | ||
|
|
e08e366266 | ||
|
|
1e661a2b41 | ||
|
|
a637d09ca5 | ||
|
|
15eeab8a59 | ||
|
|
54fa1aecdd | ||
|
|
5140784dc7 | ||
|
|
41e41f0d22 | ||
|
|
46db76219f | ||
|
|
5b9e2166df | ||
|
|
738d7b8223 | ||
|
|
ab63de000b | ||
|
|
3532972357 | ||
|
|
e644362d09 | ||
|
|
b6b8625246 | ||
|
|
01126207dc | ||
|
|
9177230dc2 | ||
|
|
db39fb335c | ||
|
|
9ea67ecf72 | ||
|
|
15a76ffe84 | ||
|
|
9c0b339736 | ||
|
|
7384041bb6 | ||
|
|
a72646c933 | ||
|
|
a513d9b7bd | ||
|
|
3827f3e686 | ||
|
|
e408fdd5e6 | ||
|
|
78fab3c9ac | ||
|
|
e69c2fbb04 | ||
|
|
2e2aba11af |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
node_modules
|
||||
coverage
|
||||
storage
|
||||
prod.log
|
||||
update.log
|
||||
.pm2
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
# ~3GB client assets; mounted as a volume at runtime (see docker-compose.yml)
|
||||
public/nitro-assets
|
||||
public/swf
|
||||
+53
-70
@@ -1,95 +1,78 @@
|
||||
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
||||
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
||||
# it does NOT own or migrate the emulator tables. Format:
|
||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||
# ==============================================================================
|
||||
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Optional pool tuning (defaults shown)
|
||||
DATABASE_POOL_SIZE=10
|
||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
REDIS_CACHE_TTL_DEFAULT=7200
|
||||
|
||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||
HOTEL_NAME=Atom
|
||||
APP_URL=http://localhost:3000
|
||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
AUTH_URL=http://localhost:3000
|
||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||
NODE_ENV=production
|
||||
PORT=3002
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
UV_THREADPOOL_SIZE=16
|
||||
# Production requires this kill switch plus housekeeping.preview.access.
|
||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
||||
|
||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
||||
AUTH_SECRET=
|
||||
APP_KEY=
|
||||
CONVERT_PASSWORDS=false
|
||||
# --- HOTEL & URLS ---
|
||||
HOTEL_NAME=EPIC WEB CONTROL
|
||||
APP_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||
AUTH_URL=http://localhost:3002
|
||||
|
||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
||||
# column). Existing accounts in either format still verify on login.
|
||||
PASSWORD_HASH=bcrypt
|
||||
# --- IMAGER ---
|
||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||
|
||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
||||
# Leave unset to disable badge uploads.
|
||||
BADGE_UPLOAD_DIR=
|
||||
# --- SECURITY & HASHING ---
|
||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
CONVERT_PASSWORDS=true
|
||||
# CONVERT_PASSWORDS: enables legacy md5/argon2id -> bcrypt upgrade on login.
|
||||
BCRYPT_COST=12
|
||||
|
||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
||||
EMULATOR_JAR_PATH=
|
||||
EMULATOR_BACKUP_DIR=
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||
EMULATOR_BACKUP_KEEP=7
|
||||
# Optional mysqldump (jobs-worker daily 03:30). Requires mysqldump on PATH.
|
||||
DB_BACKUP_DIR=
|
||||
DB_BACKUP_KEEP=7
|
||||
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||
|
||||
# RCON link to the Arcturus emulator
|
||||
# --- RCON (Low Latency Loop) ---
|
||||
RCON_HOST=127.0.0.1
|
||||
RCON_PORT=3001
|
||||
RCON_PORT=3003
|
||||
EMU_PORT=3004
|
||||
RCON_TIMEOUT_MS=2000
|
||||
|
||||
# Public imager URL — overrides the default /imaging relative path.
|
||||
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
||||
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
||||
|
||||
# Preferred email provider (HTTP API). Used before SMTP when set.
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Optional SMTP fallback (password reset / alert emails)
|
||||
# --- EMAIL & NOTIFICATIONS ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
SMTP_FROM=[email protected]
|
||||
|
||||
# Optional alerting (jobs worker / alert service)
|
||||
# --- ALERTING & MONITORING ---
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
|
||||
# Optional AI content moderation (user comments / guestbook).
|
||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
||||
# --- MODERATION & PAYMENTS ---
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional PayPal top-up (sandbox by default)
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
|
||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
||||
# in-process memory: limits reset on restart and do not work across instances.
|
||||
# Deploy logs a loud warning when this is unset in production.
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
# --- LOGGING ---
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
||||
# 'debug' in development. Production logs use structured JSON via pino.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional Sentry error monitoring (no-op when unset).
|
||||
# Server/edge use SENTRY_DSN; browser uses NEXT_PUBLIC_SENTRY_DSN.
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
# Optional source-map upload during CI builds (requires SENTRY_AUTH_TOKEN).
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Optional release tag shown in Sentry (e.g. git sha).
|
||||
# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha.
|
||||
APP_VERSION=
|
||||
NEXT_PUBLIC_APP_VERSION=
|
||||
# --- FLARESOLVERR (Cloudflare bypass for clone sources) ---
|
||||
FLARESOLVERR_URL=http://localhost:8191
|
||||
+509
-3
@@ -1,11 +1,35 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
runtime-diagnostics:
|
||||
if: gitea.event_name == 'workflow_dispatch'
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Read recent CMS runtime errors
|
||||
run: |
|
||||
set -e
|
||||
echo "--- Recent CMS runtime errors ---"
|
||||
pm2 logs next --lines 250 --nostream 2>&1 \
|
||||
| grep -Ei 'error|permissions|permission_ranks|permission_definitions|unknown column|doesn.t exist|digest' \
|
||||
| tail -120 \
|
||||
|| true
|
||||
echo "--- Permission page database probe ---"
|
||||
cd /var/www/atom-nexst
|
||||
pnpm diag:permissions
|
||||
|
||||
|
||||
check:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Typecheck, lint, and test
|
||||
@@ -29,13 +53,495 @@ jobs:
|
||||
git fetch --depth 50 origin "${REF}"
|
||||
git checkout -f "${REF}"
|
||||
|
||||
node scripts/check-node-toolchain.mjs
|
||||
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
export ARGON2_MEMORY_SIZE=1024
|
||||
export ARGON2_ITERATIONS=1
|
||||
export NODE_ENV=test
|
||||
export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4"
|
||||
export AUTH_SECRET="ci-test-secret-key-that-is-long-enough"
|
||||
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm prisma:generate
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm knip
|
||||
echo "--- CI checks passed ---"
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
if: gitea.event_name == 'push' && gitea.ref_name == 'main'
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
|
||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||
|
||||
echo "--- Deploying ---"
|
||||
|
||||
LIVE="/var/www/atom-nexst"
|
||||
STAGE=""
|
||||
CUTOVER_STARTED=0
|
||||
|
||||
error_handler() {
|
||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||
# Leave the healthy current process untouched when staging fails.
|
||||
# Restart only when cutover has already stopped or replaced it.
|
||||
if [ "${CUTOVER_STARTED}" = "1" ]; then
|
||||
if [ -d "${LIVE}/.next.prev" ]; then
|
||||
echo "Rolling back .next to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/.next" || true
|
||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
||||
fi
|
||||
if [ -d "${LIVE}/node_modules.prev" ]; then
|
||||
echo "Rolling back node_modules to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/node_modules" || true
|
||||
mv "${LIVE}/node_modules.prev" "${LIVE}/node_modules" || true
|
||||
fi
|
||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
trap 'error_handler $LINENO' ERR
|
||||
|
||||
docker image prune -f
|
||||
|
||||
DEPLOY_USER="$(id -un)"
|
||||
DEPLOY_GROUP="$(id -gn)"
|
||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||
git config --global --add safe.directory "${LIVE}"
|
||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||
|
||||
echo "Fetching origin/main..."
|
||||
git -C "${LIVE}" fetch origin --prune
|
||||
|
||||
echo "Clearing sticky git index bits (if any)..."
|
||||
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
||||
if [ -n "${STICKY_LIST}" ]; then
|
||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||
echo "APP_VERSION=${APP_VERSION}"
|
||||
|
||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
||||
|
||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||
|
||||
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||
fi
|
||||
|
||||
cd "${STAGE}"
|
||||
node scripts/check-node-toolchain.mjs
|
||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||
rm -rf .output dist .next .next/types .next/dev .next/cache
|
||||
|
||||
# No build-cache restore: every deploy is a fully clean, from-scratch
|
||||
# build so the shipped output is 100% up to date with origin/main.
|
||||
|
||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||
# tiny so install/test/build cannot exhaust max_connections.
|
||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||
pnpm build
|
||||
|
||||
if [ ! -d "${STAGE}/.next" ]; then
|
||||
echo "ERROR: stage build produced no .next/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Migrating staged release while the current app stays online..."
|
||||
cd "${STAGE}"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${LIVE}"
|
||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||
git reset --hard origin/main
|
||||
# Keep env, uploads, runtime-imported furni assets, and deps we are
|
||||
# about to replace from stage. The app can write furni files while it
|
||||
# remains online during staging, so cleaning those paths races with
|
||||
# active imports and can fail with "Directory not empty".
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache \
|
||||
-e public/swf/dcr/hof_furni \
|
||||
-e public/nitro-assets/bundled/furniture \
|
||||
-e node_modules -e node_modules.prev -e .next -e .next.prev
|
||||
|
||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||
git diff --stat HEAD -- src >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified live src/ matches HEAD"
|
||||
|
||||
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
|
||||
# Export PORT before start so the process matches health checks.
|
||||
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
|
||||
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
|
||||
export PORT="${DEPLOY_PORT}"
|
||||
echo "PM2/health PORT=${PORT}"
|
||||
|
||||
free_tcp_port() {
|
||||
local port="$1"
|
||||
[ -n "${port}" ] || return 0
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "${port}/tcp" 2>/dev/null || true
|
||||
elif command -v lsof >/dev/null 2>&1; then
|
||||
# Portable fallback when fuser is unavailable.
|
||||
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Cutover: atomically swap artifacts and restart on PORT=${PORT}..."
|
||||
CUTOVER_STARTED=1
|
||||
pm2 stop next --kill-timeout 10000 || true
|
||||
|
||||
cd "${LIVE}"
|
||||
|
||||
# Rename both current artifacts so cutover and rollback stay fast.
|
||||
if [ -d .next ]; then
|
||||
mv .next .next.prev
|
||||
fi
|
||||
mv "${STAGE}/.next" .next
|
||||
if [ -d node_modules ]; then
|
||||
mv node_modules node_modules.prev
|
||||
fi
|
||||
mv "${STAGE}/node_modules" node_modules
|
||||
|
||||
free_tcp_port "${PORT}"
|
||||
free_tcp_port 3000
|
||||
echo "Starting PM2 with a clean PORT=${PORT} listener..."
|
||||
pm2 delete next 2>/dev/null || true
|
||||
PORT="${PORT}" pm2 start pnpm --name next -- start
|
||||
pm2 save 2>/dev/null || true
|
||||
|
||||
sleep 3
|
||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||
echo "ERROR: PM2 next failed to start!" >&2
|
||||
pm2 logs next --lines 20 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check on port ${PORT}..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 20); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
echo "Listeners on PORT ${PORT}:" >&2
|
||||
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
|
||||
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning stage worktree and previous artifact backups..."
|
||||
rm -rf "${LIVE}/.next.prev" "${LIVE}/node_modules.prev"
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
STAGE=""
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
|
||||
release:
|
||||
if: startsWith(gitea.ref_name, 'v')
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Build and deploy
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
WORK="$(mktemp -d /var/tmp/epicnext-deploy.XXXXXX)"
|
||||
cleanup() { rm -rf "${WORK}"; }
|
||||
trap cleanup EXIT
|
||||
echo "=== Deploying ${VERSION} ==="
|
||||
git clone --depth 50 \
|
||||
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
|
||||
"${WORK}"
|
||||
cd "${WORK}"
|
||||
git checkout "${VERSION}"
|
||||
node scripts/check-node-toolchain.mjs
|
||||
export NODE_ENV=production
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
pnpm install --frozen-lockfile
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
# Tag releases must apply CMS SQL migrations against the live DB
|
||||
# (same path as push-to-main deploy), using the production .env.
|
||||
LIVE="/var/www/atom-nexst"
|
||||
ln -sfn "${LIVE}/.env" "${WORK}/.env"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
pnpm build
|
||||
pm2 restart next --update-env
|
||||
pm2 save
|
||||
echo "=== Deploy complete ==="
|
||||
- name: Create Release
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
GITEA_API: ${{ gitea.api_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
||||
echo "=== Creating release for ${VERSION} ==="
|
||||
|
||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
||||
|
||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
||||
else
|
||||
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
||||
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
||||
fi
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
||||
|
||||
# Pin the other components at their current commits so the release is reproducible.
|
||||
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
||||
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
{
|
||||
echo "# EpicNext-CMS ${VERSION}"
|
||||
echo ""
|
||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Drizzle ORM. Integrates with Polaris / Arcturus Morningstar databases."
|
||||
echo ""
|
||||
echo "## Menu"
|
||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||
echo "- [System Requirements](#system-requirements)"
|
||||
echo "- [Installation Wizard](#installation-wizard)"
|
||||
echo "- [How it is used](#how-it-is-used)"
|
||||
echo "- [Changes](#changes)"
|
||||
echo "- [Linked repositories](#linked-repositories)"
|
||||
echo ""
|
||||
echo '<a id="what-is-epicnext-cms"></a>'
|
||||
echo "## What is EpicNext-CMS?"
|
||||
echo ""
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo ""
|
||||
echo '<a id="system-requirements"></a>'
|
||||
echo "## System Requirements"
|
||||
echo ""
|
||||
echo "What you need to install before running the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Version | Notes |"
|
||||
echo "| --------- | ------- | ----- |"
|
||||
echo "| Node.js | 26.7.0 | Current release pinned in .nvmrc |"
|
||||
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
||||
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
||||
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
||||
echo "| Java | 17+ | Only if building the emulator |"
|
||||
echo "| Maven | 3.9+ | Only if building the emulator |"
|
||||
echo ""
|
||||
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
||||
echo ""
|
||||
echo '<a id="installation-wizard"></a>'
|
||||
echo "## Installation Wizard"
|
||||
echo ""
|
||||
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
||||
echo ""
|
||||
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
||||
echo ""
|
||||
echo "### 1. Clone & Install the CMS"
|
||||
echo '```bash'
|
||||
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
||||
echo "cd EpicNext-Cms"
|
||||
echo "pnpm install"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 2. Database Setup"
|
||||
echo ""
|
||||
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
||||
echo '```sql'
|
||||
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 3. Configure Environment"
|
||||
echo '```bash'
|
||||
echo "cp .env.example .env"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
||||
echo ""
|
||||
echo "### 4. Run CMS Migrations"
|
||||
echo '```bash'
|
||||
echo "pnpm db:migrate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status. Runtime types come from the committed Drizzle schema (src/db/schema.ts) via '@/lib/db'."
|
||||
echo ""
|
||||
echo "### 5. Polaris Emulator"
|
||||
echo ""
|
||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
||||
echo "cd /var/www/emulator/Emulator"
|
||||
echo "mvn clean package"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
||||
echo '```bash'
|
||||
echo "./update-Nitrov3.sh"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 6. Nitro V3 & Renderer"
|
||||
echo ""
|
||||
echo "Clone both Nitro repos and build the client:"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
||||
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
||||
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
||||
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
||||
echo ""
|
||||
echo "### 7. Catalogus (catalog & gamedata)"
|
||||
echo ""
|
||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||
echo '```bash'
|
||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 8. Build & Start the CMS"
|
||||
echo '```bash'
|
||||
echo "# Development (hot reload)"
|
||||
echo "pnpm dev"
|
||||
echo ""
|
||||
echo "# Production"
|
||||
echo "pnpm build && pnpm start"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Open http://localhost:3000 in your browser."
|
||||
echo ""
|
||||
echo "### 9. First Login"
|
||||
echo ""
|
||||
echo "1. Register at /register, or log in with an existing emulator account."
|
||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
||||
echo ""
|
||||
echo '<a id="how-it-is-used"></a>'
|
||||
echo "## How it is used"
|
||||
echo ""
|
||||
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
||||
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
||||
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
||||
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
||||
echo ""
|
||||
echo '<a id="changes"></a>'
|
||||
echo "## Changes"
|
||||
echo '```'
|
||||
echo "${CHANGELOG}"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo '<a id="linked-repositories"></a>'
|
||||
echo "## Linked repositories (exact commits)"
|
||||
echo ""
|
||||
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Repository | Commit |"
|
||||
echo "|-----------|------------|--------|"
|
||||
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
||||
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
||||
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
||||
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
||||
echo ""
|
||||
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Automated release from Gitea Actions*"
|
||||
} > /tmp/release-body.md
|
||||
|
||||
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
||||
|
||||
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
|
||||
if [ "${HTTP_CODE}" = "409" ]; then
|
||||
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}")"
|
||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
fi
|
||||
|
||||
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
||||
echo "SUCCESS: Release ${VERSION} created/updated"
|
||||
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
||||
else
|
||||
echo "FAILED HTTP ${HTTP_CODE}"
|
||||
cat /tmp/release-resp.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,417 +0,0 @@
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
jobs:
|
||||
release:
|
||||
if: startsWith(gitea.ref_name, 'v')
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Create Release
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
GITEA_API: ${{ gitea.api_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
||||
echo "=== Creating release for ${VERSION} ==="
|
||||
|
||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
||||
|
||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
||||
else
|
||||
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
||||
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
||||
fi
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
||||
|
||||
# Pin the other components at their current commits so the release is reproducible.
|
||||
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
||||
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
{
|
||||
echo "# EpicNext-CMS ${VERSION}"
|
||||
echo ""
|
||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Prisma 7. Integrates with Polaris / Arcturus Morningstar databases."
|
||||
echo ""
|
||||
echo "## Menu"
|
||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||
echo "- [System Requirements](#system-requirements)"
|
||||
echo "- [Installation Wizard](#installation-wizard)"
|
||||
echo "- [How it is used](#how-it-is-used)"
|
||||
echo "- [Changes](#changes)"
|
||||
echo "- [Linked repositories](#linked-repositories)"
|
||||
echo ""
|
||||
echo '<a id="what-is-epicnext-cms"></a>'
|
||||
echo "## What is EpicNext-CMS?"
|
||||
echo ""
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo ""
|
||||
echo '<a id="system-requirements"></a>'
|
||||
echo "## System Requirements"
|
||||
echo ""
|
||||
echo "What you need to install before running the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Version | Notes |"
|
||||
echo "| --------- | ------- | ----- |"
|
||||
echo "| Node.js | >= 22 | Required by Next.js 16 |"
|
||||
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
||||
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
||||
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
||||
echo "| Java | 17+ | Only if building the emulator |"
|
||||
echo "| Maven | 3.9+ | Only if building the emulator |"
|
||||
echo ""
|
||||
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
||||
echo ""
|
||||
echo '<a id="installation-wizard"></a>'
|
||||
echo "## Installation Wizard"
|
||||
echo ""
|
||||
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
||||
echo ""
|
||||
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
||||
echo ""
|
||||
echo "### 1. Clone & Install the CMS"
|
||||
echo '```bash'
|
||||
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
||||
echo "cd EpicNext-Cms"
|
||||
echo "pnpm install"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 2. Database Setup"
|
||||
echo ""
|
||||
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
||||
echo '```sql'
|
||||
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 3. Configure Environment"
|
||||
echo '```bash'
|
||||
echo "cp .env.example .env"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
||||
echo ""
|
||||
echo "### 4. Generate Prisma Client"
|
||||
echo '```bash'
|
||||
echo "pnpm prisma:generate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 5. Run CMS Migrations"
|
||||
echo '```bash'
|
||||
echo "pnpm db:migrate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
|
||||
echo ""
|
||||
echo "### 6. Polaris Emulator"
|
||||
echo ""
|
||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
||||
echo "cd /var/www/emulator/Emulator"
|
||||
echo "mvn clean package"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
||||
echo '```bash'
|
||||
echo "./update-Nitrov3.sh"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 7. Nitro V3 & Renderer"
|
||||
echo ""
|
||||
echo "Clone both Nitro repos and build the client:"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
||||
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
||||
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
||||
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
||||
echo ""
|
||||
echo "### 8. Catalogus (catalog & gamedata)"
|
||||
echo ""
|
||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||
echo '```bash'
|
||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 9. Build & Start the CMS"
|
||||
echo '```bash'
|
||||
echo "# Development (hot reload)"
|
||||
echo "pnpm dev"
|
||||
echo ""
|
||||
echo "# Production"
|
||||
echo "pnpm build && pnpm start"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Open http://localhost:3000 in your browser."
|
||||
echo ""
|
||||
echo "### 10. First Login"
|
||||
echo ""
|
||||
echo "1. Register at /register, or log in with an existing emulator account."
|
||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
||||
echo ""
|
||||
echo '<a id="how-it-is-used"></a>'
|
||||
echo "## How it is used"
|
||||
echo ""
|
||||
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
||||
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
||||
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
||||
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
||||
echo ""
|
||||
echo '<a id="changes"></a>'
|
||||
echo "## Changes"
|
||||
echo '```'
|
||||
echo "${CHANGELOG}"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo '<a id="linked-repositories"></a>'
|
||||
echo "## Linked repositories (exact commits)"
|
||||
echo ""
|
||||
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Repository | Commit |"
|
||||
echo "|-----------|------------|--------|"
|
||||
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
||||
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
||||
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
||||
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
||||
echo ""
|
||||
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Automated release from Gitea Actions*"
|
||||
} > /tmp/release-body.md
|
||||
|
||||
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
||||
|
||||
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
|
||||
if [ "${HTTP_CODE}" = "409" ]; then
|
||||
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}")"
|
||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
fi
|
||||
|
||||
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
||||
echo "SUCCESS: Release ${VERSION} created/updated"
|
||||
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
||||
else
|
||||
echo "FAILED HTTP ${HTTP_CODE}"
|
||||
cat /tmp/release-resp.json
|
||||
exit 1
|
||||
fi
|
||||
deploy:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
|
||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||
|
||||
echo "--- Deploying ---"
|
||||
|
||||
LIVE="/var/www/atom-nexst"
|
||||
STAGE=""
|
||||
CUTOVER_STARTED=0
|
||||
|
||||
error_handler() {
|
||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||
# Roll back the build artifact if cutover already moved .next into place.
|
||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
||||
echo "Rolling back .next to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/.next" || true
|
||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
||||
fi
|
||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
fi
|
||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
trap 'error_handler $LINENO' ERR
|
||||
|
||||
docker image prune -f
|
||||
|
||||
DEPLOY_USER="$(id -un)"
|
||||
DEPLOY_GROUP="$(id -gn)"
|
||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||
git config --global --add safe.directory "${LIVE}"
|
||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||
|
||||
echo "Fetching origin/main..."
|
||||
git -C "${LIVE}" fetch origin --prune
|
||||
|
||||
echo "Clearing sticky git index bits (if any)..."
|
||||
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
||||
if [ -n "${STICKY_LIST}" ]; then
|
||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||
echo "APP_VERSION=${APP_VERSION}"
|
||||
|
||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
||||
|
||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||
|
||||
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||
fi
|
||||
|
||||
cd "${STAGE}"
|
||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||
rm -rf .output dist .next .next/types .next/dev
|
||||
|
||||
# Restore build cache from last deploy so Turbopack can do
|
||||
# incremental compilation (much faster rebuilds).
|
||||
if [ -d "${LIVE}/.next/cache" ]; then
|
||||
mkdir -p .next/cache
|
||||
cp -r "${LIVE}/.next/cache/." .next/cache/
|
||||
fi
|
||||
|
||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||
# tiny so install/test/build cannot exhaust max_connections.
|
||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# prisma generate does not need a live DB connection.
|
||||
pnpm prisma:generate
|
||||
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||
pnpm build
|
||||
|
||||
if [ ! -d "${STAGE}/.next" ]; then
|
||||
echo "ERROR: stage build produced no .next/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
||||
CUTOVER_STARTED=1
|
||||
pm2 stop next --kill-timeout 10000 || true
|
||||
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
||||
sleep 10
|
||||
|
||||
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
||||
# the old process still holds DATABASE_POOL_SIZE connections.
|
||||
cd "${STAGE}"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${LIVE}"
|
||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||
git reset --hard origin/main
|
||||
# Keep env, uploads, and deps we are about to replace from stage.
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache -e node_modules -e .next -e .next.prev
|
||||
|
||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||
git diff --stat HEAD -- src >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified live src/ matches HEAD"
|
||||
|
||||
# Save current .next as backup before swapping (kept until health check passes).
|
||||
if [ -d .next ]; then
|
||||
mv .next .next.prev
|
||||
fi
|
||||
mv "${STAGE}/.next" .next
|
||||
|
||||
# Use the exact node_modules the stage build resolved against.
|
||||
rm -rf node_modules
|
||||
mv "${STAGE}/node_modules" node_modules
|
||||
|
||||
# Prisma client is gitignored — regenerate into live src/generated.
|
||||
pnpm prisma:generate
|
||||
|
||||
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
|
||||
|
||||
echo "Starting PM2 (zero-downtime reload)..."
|
||||
pm2 reload next --update-env || pm2 start next --update-env
|
||||
|
||||
sleep 3
|
||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||
echo "ERROR: PM2 next failed to start!" >&2
|
||||
pm2 logs next --lines 20 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 15); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/15 failed, retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning stage worktree and previous .next backup..."
|
||||
rm -rf "${LIVE}/.next.prev"
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
STAGE=""
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
@@ -11,9 +11,8 @@ jobs:
|
||||
- name: Self-hosted Renovate
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
||||
|
||||
# Ensure cache dir exists before Docker starts
|
||||
mkdir -p /var/tmp/renovate-cache
|
||||
|
||||
docker run --rm \
|
||||
@@ -22,7 +21,6 @@ jobs:
|
||||
-e RENOVATE_AUTODISCOVER=false \
|
||||
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
|
||||
-e RENOVATE_ONBOARDING=false \
|
||||
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||
-e LOG_LEVEL=info \
|
||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
+7
-1
@@ -1,11 +1,12 @@
|
||||
node_modules/
|
||||
node_modules.prev/
|
||||
.turbo/
|
||||
.next/
|
||||
.next.prev/
|
||||
.next-staging/
|
||||
next-env.d.ts
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
# Prisma client is generated by `prisma generate`
|
||||
src/generated/
|
||||
# Runtime avatar/badge imaging disk cache
|
||||
public/cache/
|
||||
@@ -25,5 +26,10 @@ gitea
|
||||
# Runtime uploaded media (persistent, outside public/)
|
||||
storage/
|
||||
|
||||
# Runtime downloaded furni assets (mirrored from gamedata / audit repair)
|
||||
public/nitro-assets/bundled/furniture/
|
||||
public/swf/dcr/
|
||||
public/tmp/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# ==============================================================================
|
||||
# EpicNext-CMS — Docker image (Node 26.8.1, pnpm 11.24.0, Next.js standalone)
|
||||
# ==============================================================================
|
||||
|
||||
# --- Builder stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS builder
|
||||
|
||||
# pnpm is required and pinned in package.json (packageManager: [email protected]).
|
||||
# Node 26 does not bundle corepack anymore, so install pnpm via npm.
|
||||
RUN npm install -g [email protected]
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# First copy only the manifests so dependency layers are cached.
|
||||
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Copy the rest of the source.
|
||||
COPY . .
|
||||
|
||||
# Build the production bundle.
|
||||
ENV NODE_ENV=production
|
||||
RUN pnpm build
|
||||
|
||||
# Copy runtime dependencies (node_modules) needed by standalone output.
|
||||
RUN pnpm prune --prod
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3002
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Occupied base port on the host; keep PM2-style default.
|
||||
EXPOSE 3002
|
||||
|
||||
# Non-root user for security.
|
||||
RUN groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Storage directory for runtime uploaded media (persistent volume).
|
||||
# nitro/swf client assets (~3GB) are mounted here as volumes at runtime.
|
||||
RUN mkdir -p /app/storage \
|
||||
/app/public/nitro-assets \
|
||||
/app/public/swf \
|
||||
&& chown -R nextjs:nodejs /app
|
||||
|
||||
# Copy standalone Next.js output (includes a minimal node_modules).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
# Copy static assets (public files served directly).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
# Copy the server-side static build output.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Client assets + runtime uploads live outside the image (mounted volumes).
|
||||
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
||||
|
||||
USER nextjs
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,8 +1,8 @@
|
||||
# EpicNext-CMS v1.0.1
|
||||
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,10 +10,10 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
|
||||
| Component | Version | Notes |
|
||||
| --------------- | -------------- | ---------------------------------------- |
|
||||
| Node.js | >= 22 | Required by Next.js 16 |
|
||||
| Node.js | 26.7.0 | Current release pinned in `.nvmrc` |
|
||||
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| Redis | 7.x+ | Optional — caching, rate limiting, SSE |
|
||||
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
|
||||
| Java | 17+ | Required only if building the emulator |
|
||||
| Maven | 3.9+ | Required only if building the emulator |
|
||||
|
||||
@@ -37,7 +37,9 @@ The CMS shares a database with the Polaris/Arcturus emulator. Use an existing da
|
||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them.
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
||||
|
||||
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/` (`pnpm db:migrate`).
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
@@ -54,15 +56,36 @@ HOTEL_NAME=YourHotel
|
||||
APP_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
See `.env.example` for all optional variables (RCON, email, Redis, OAuth, PayPal, etc.).
|
||||
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. Generate Prisma Client
|
||||
### 4. ORM Setup & Type Generation
|
||||
|
||||
```bash
|
||||
pnpm prisma:generate
|
||||
#### Drizzle ORM (Primary Data Layer)
|
||||
|
||||
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
|
||||
|
||||
```ts
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm/expressions";
|
||||
|
||||
const found = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "hello"));
|
||||
```
|
||||
|
||||
Generates TypeScript types in `src/generated/prisma/`.
|
||||
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
|
||||
|
||||
| Command | What it does |
|
||||
| ------- | ------------ |
|
||||
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
|
||||
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema names + live DB |
|
||||
|
||||
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
|
||||
|
||||
Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.ts` for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time.
|
||||
|
||||
### 5. Run CMS Migrations
|
||||
|
||||
@@ -70,7 +93,7 @@ Generates TypeScript types in `src/generated/prisma/`.
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `prisma/migrations/`. Emulator tables are never touched.
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
|
||||
|
||||
Check migration status:
|
||||
|
||||
@@ -98,6 +121,289 @@ Open `http://localhost:3000` in your browser.
|
||||
|
||||
---
|
||||
|
||||
## DragonflyDB (caching, rate limiting, SSE)
|
||||
|
||||
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it
|
||||
via `REDIS_URL` using the `ioredis` client, so no application code changes are needed —
|
||||
every Redis command (`PING`, `GET`, `SETEX`, `DEL`, `INCR`, `PEXPIRE`, `PTTL`) works
|
||||
unchanged. It is optional: without it the CMS falls back to in-process memory.
|
||||
|
||||
### Install (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
curl -fsSL -o /tmp/dragonfly_amd64.deb \
|
||||
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
|
||||
apt-get install -y /tmp/dragonfly_amd64.deb
|
||||
systemctl enable --now dragonfly
|
||||
```
|
||||
|
||||
This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`.
|
||||
|
||||
### Configure
|
||||
|
||||
```ini
|
||||
--bind=127.0.0.1
|
||||
--port=6379
|
||||
--maxmemory=2gb
|
||||
--version_check=false
|
||||
```
|
||||
|
||||
- `--bind=127.0.0.1` keeps it private on the machine (matches `REDIS_URL=redis://127.0.0.1:6379`).
|
||||
- `--port=6379` is the default Redis port, so `.env` stays unchanged.
|
||||
- `--maxmemory` must be at least `0.25GiB` per CPU thread (e.g. `2gb` on a 6-thread server).
|
||||
- `--version_check=false` disables the periodic outbound update check.
|
||||
- Snapshots are written to `--dir` (`/var/lib/dragonfly/dump-*.dfs`).
|
||||
|
||||
### Point the CMS at it
|
||||
|
||||
```dotenv
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
redis-cli PING # → PONG
|
||||
redis-cli FLUSHALL # clear the cache
|
||||
systemctl status dragonfly # service health
|
||||
```
|
||||
|
||||
Verify everything is wired up via the health endpoint:
|
||||
`/api/health` should report `"redis": true`. The ioredis client automatically reconnects
|
||||
after a DragonflyDB restart.
|
||||
|
||||
> **Note:** if an old Redis install still occupies port 6379, stop it first:
|
||||
> `systemctl disable --now redis-server`.
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt)
|
||||
- Next.js running on `127.0.0.1:3000` (default) or your configured port
|
||||
- Habbo imager (optional) running on `127.0.0.1:3030`
|
||||
|
||||
### Reference Configuration
|
||||
|
||||
Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`:
|
||||
|
||||
```nginx
|
||||
# ==========================================
|
||||
# GLOBAL SETTINGS
|
||||
# ==========================================
|
||||
server_tokens off;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied off;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types text/plain text/css text/javascript application/json
|
||||
application/javascript application/xml application/xml+rss
|
||||
image/svg+xml font/opentype font/ttf font/woff font/woff2;
|
||||
|
||||
# ==========================================
|
||||
# REDIRECT HTTP → HTTPS
|
||||
# ==========================================
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/epicnext/public;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# MAIN HTTPS SERVER
|
||||
# ==========================================
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
root /var/www/epicnext/public;
|
||||
index index.html;
|
||||
|
||||
# SSL Certificates
|
||||
ssl_certificate /etc/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/ssl/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 20m;
|
||||
client_body_timeout 30s;
|
||||
client_header_timeout 10s;
|
||||
keepalive_timeout 15s;
|
||||
send_timeout 10s;
|
||||
|
||||
# Shared Proxy Settings
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffers 16 16k;
|
||||
proxy_buffer_size 32k;
|
||||
|
||||
# ------------------------------------------
|
||||
# Static Files
|
||||
# ------------------------------------------
|
||||
location ^~ /nitro-client/ {
|
||||
alias /var/www/Nitro-V3/dist/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
||||
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
|
||||
|
||||
# ------------------------------------------
|
||||
# Next.js Assets (immutable, long cache)
|
||||
# ------------------------------------------
|
||||
location /_next/static/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location /_next/data/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "public, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# API Routes (never cached)
|
||||
# ------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Habbo Imager (optional)
|
||||
# ------------------------------------------
|
||||
# Proxies to a Docker container that renders Habbo avatars.
|
||||
# The imager caches renders to disk, so a long s-maxage is safe.
|
||||
location /imaging {
|
||||
proxy_pass http://127.0.0.1:3030;
|
||||
add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# WebSocket (Radio / SSE)
|
||||
# ------------------------------------------
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:3030;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Main Page Proxy (with HTML caching)
|
||||
# ------------------------------------------
|
||||
# The CMS middleware sets:
|
||||
# Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous)
|
||||
# Cache-Control: private, no-store (authenticated)
|
||||
#
|
||||
# nginx caches anonymous responses and serves them directly, bypassing
|
||||
# the Node.js process entirely. Authenticated responses are never cached.
|
||||
#
|
||||
# proxy_cache_valid: cache 200 responses for 60 seconds
|
||||
# proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
|
||||
# next-router-*, Accept-Encoding) that would fragment the cache key.
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering on;
|
||||
|
||||
proxy_cache html_cache;
|
||||
proxy_cache_valid 200 60s;
|
||||
proxy_cache_key "$host$request_uri";
|
||||
proxy_ignore_headers Vary;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_background_update on;
|
||||
proxy_cache_revalidate on;
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Health Check
|
||||
# ------------------------------------------
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Block hidden files
|
||||
location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Caching
|
||||
|
||||
The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means:
|
||||
|
||||
- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
|
||||
- **Authenticated visitors** always hit Node.js (personalized content).
|
||||
- The cache is **auto-invalidated** after 60 seconds and revalidates in the background.
|
||||
|
||||
The proxy cache zone is defined in the `http` block (above any `server` block):
|
||||
|
||||
```nginx
|
||||
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
|
||||
```
|
||||
|
||||
Verify caching works by checking the `X-Cache-Status` response header:
|
||||
|
||||
```bash
|
||||
# First request (MISS = fetched from Node.js, now cached)
|
||||
curl -sI https://yourdomain.com/ | grep X-Cache-Status
|
||||
# → X-Cache-Status: MISS
|
||||
|
||||
# Second request (HIT = served from nginx cache)
|
||||
curl -sI https://yourdomain.com/ | grep X-Cache-Status
|
||||
# → X-Cache-Status: HIT
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
| Setting | Value | Why |
|
||||
| ------- | ----- | --- |
|
||||
| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer |
|
||||
| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses |
|
||||
| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache |
|
||||
| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance |
|
||||
| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment (PM2)
|
||||
|
||||
```bash
|
||||
@@ -130,10 +436,16 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||
| `pnpm db:migrate:status` | Show migration status |
|
||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
||||
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
|
||||
| `pnpm db:studio` | Drizzle Studio (dev) |
|
||||
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
**Drizzle Kit notes:** `db:generate` / `db:introspect` write drafts only. Reviewed SQL must be copied into `drizzle/migrations/` as a new numbered file, then applied with `pnpm db:migrate`. Never run `drizzle-kit push` or `drizzle-kit migrate` against production.
|
||||
|
||||
---
|
||||
|
||||
## Performance Features
|
||||
@@ -144,7 +456,7 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| **View Transitions API** | Native browser transitions between page navigations |
|
||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
|
||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
||||
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
|
||||
| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB |
|
||||
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
||||
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||
@@ -160,23 +472,28 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
## Architecture
|
||||
|
||||
```
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # ~190 models (emulator + CMS)
|
||||
│ └── migrations/ # 16 SQL migrations for CMS tables
|
||||
├── drizzle/
|
||||
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
|
||||
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
|
||||
├── scripts/
|
||||
│ ├── apply-migrations.ts # Custom migration runner
|
||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||
│ ├── jobs-worker.ts # Background task scheduler
|
||||
│ └── sql-statements.ts # SQL parsing utilities
|
||||
│ ├── merge-config.cjs # Utility: merge split config files
|
||||
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
|
||||
├── src/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
|
||||
│ │ └── relations.ts # Drizzle relations
|
||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── components/ # UI components
|
||||
│ ├── lib/
|
||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||
│ │ ├── prisma.ts # Database connection singleton
|
||||
│ │ ├── redis.ts # Redis client (ioredis)
|
||||
│ │ ├── redis-cache.ts # Redis caching utility for API routes
|
||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||
│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
|
||||
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
||||
│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
|
||||
│ │ ├── cache.ts # In-memory cache fallback
|
||||
│ │ ├── motion.ts # Framer Motion animation variants
|
||||
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
||||
@@ -195,9 +512,17 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| Component | Type | Migrations |
|
||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `prisma/migrations/*.sql` (16 files) |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||
|
||||
### ORM Architecture
|
||||
|
||||
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. Schema lives in `src/db/schema.ts`.
|
||||
- **Schema regeneration**: `pnpm db:schema:generate` reuses field/table names from the previous `src/db/schema.ts` and refreshes column types from the live DB.
|
||||
- **Drizzle Kit**: studio / generate / introspect for local tooling; CMS apply path remains `pnpm db:migrate`.
|
||||
|
||||
Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Optional Integrations
|
||||
@@ -239,10 +564,83 @@ Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
|
||||
|
||||
12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`).
|
||||
|
||||
|
||||
## Furniture Import with Auto-Translation
|
||||
|
||||
The CMS now automatically translates furniture names and descriptions to **13 languages** on every import:
|
||||
|
||||
- **Native languages** (via official Habbo gamedata): Dutch (`nl`), English (`en`), German (`de`), French (`fr`), Spanish (`es`), Turkish (`tr`), Italian (`it`)
|
||||
- **Additional languages** (via LibreTranslate self-hosted Docker at `127.0.0.1:5000`): Portuguese (`pt`), Finnish (`fi`), Polish (`pl`), Russian (`ru`), Arabic (`ar`), Japanese (`ja`)
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Per-import translation** — Every import route (`/admin/import/furni`, batch, batch-regen, clone) triggers translation automatically after the furniture data is imported.
|
||||
|
||||
2. **Translation flow**:
|
||||
- The original (usually English) `classname` and `description` are sent to LibreTranslate
|
||||
- Official Habbo translations take priority for the 7 supported languages
|
||||
- Custom/new meubels fall back to LibreTranslate
|
||||
- Post-processing rules force Habbo‑style terminology (e.g. `bank` → `Bank`, `tafel` → `Tafel`, `stoel` → `Stoel`)
|
||||
|
||||
3. **No manual steps needed** — The translation happens as part of the import API calls. New custom furniture added via import immediately appears with translated names in the catalog for all 13 languages.
|
||||
|
||||
4. **CLI scripts** (optional):
|
||||
- `pnpm translate:full` — translate all furniture data fully (uses `--full` flag)
|
||||
- `pnpm translate:limited [--limit N] [--concurrency N]` — translate limited amount per language
|
||||
- `pnpm build:languages [--full] [--limit N] [--concurrency N]` — build the full localized furnidata JSON files
|
||||
|
||||
### Requirements
|
||||
|
||||
- LibreTranslate Docker container running at `http://127.0.0.1:5000` (or configure a different URL in the environment)
|
||||
- The `LIBRETRANSLATE_URL` environment variable can override the default if needed
|
||||
- For the 7 official languages, no external service is needed — they use the built‑in Habbo gamedata
|
||||
|
||||
### Environment variables
|
||||
|
||||
```dotenv
|
||||
# LibreTranslate endpoint (default: http://127.0.0.1:5000)
|
||||
LIBRETRANSLATE_URL=http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
### Example import flow
|
||||
|
||||
```bash
|
||||
# Single furniture import (triggers translation)
|
||||
POST /admin/import/furni
|
||||
|
||||
# Batch import (triggers translation)
|
||||
POST /admin/import/furni/batch
|
||||
|
||||
# Regenerate localized files
|
||||
POST /admin/import/furni/batch-regen
|
||||
|
||||
# Clone import (triggers translation)
|
||||
POST /admin/import/clone/batch
|
||||
```
|
||||
### AI Content Moderation
|
||||
|
||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||
|
||||
### FlareSolverr (Cloudflare Bypass)
|
||||
|
||||
Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically.
|
||||
|
||||
**Setup:**
|
||||
|
||||
```bash
|
||||
docker compose up -d flaresolverr
|
||||
```
|
||||
|
||||
Set the URL in `.env`:
|
||||
|
||||
```
|
||||
FLARESOLVERR_URL=http://localhost:8191
|
||||
```
|
||||
|
||||
When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning.
|
||||
|
||||
See `docker-compose.yml` for the FlareSolverr service definition.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
@@ -260,7 +658,9 @@ pnpm biome:check # Lint and format
|
||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
||||
4. SQL migrations in `prisma/migrations/` must be idempotent
|
||||
4. SQL migrations in `drizzle/migrations/` must be idempotent
|
||||
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
|
||||
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
cms:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: epicnext-cms
|
||||
ports:
|
||||
# Host port -> container port (container always listens on 3002)
|
||||
- "3002:3002"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# ~3GB client assets live on the host; mount them read-only into the container
|
||||
- ./public/nitro-assets:/app/public/nitro-assets:ro
|
||||
- ./public/swf:/app/public/swf:ro
|
||||
# Runtime uploaded media (persistent on the host)
|
||||
- ./storage:/app/storage
|
||||
@@ -0,0 +1,111 @@
|
||||
# Furni Import Hotel Source Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the furni import visibly and consistently use the CMS `habbo_gamedata_hotel` setting without Italy-specific names or messages.
|
||||
|
||||
**Architecture:** Keep locale selection centralized in `getHabboGamedataHotel()` and expose normalized display metadata to the server-rendered import page. Rename the furnidata cache contract to generic official-Habbo terminology, and keep asset downloads on the global `images.habbo.com` CDN.
|
||||
|
||||
**Tech Stack:** TypeScript 7, React 19, Next.js 16, Vitest 4.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `habbo_gamedata_hotel` remains the single source of truth.
|
||||
- Furnidata and external texts use `www.habbo.<hotel>`.
|
||||
- SWF and icon downloads remain on `images.habbo.com`.
|
||||
- Cache entries must never leak across two configured hotels.
|
||||
- Existing import behavior and permissions remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generic official-Habbo furnidata contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
- Modify: `src/types/furni.ts`
|
||||
- Modify: `src/lib/services/habbo-furnidata-cache.ts`
|
||||
- Modify: `src/lib/services/habbofurni.ts`
|
||||
- Modify: `src/lib/services/furni-data.ts`
|
||||
- Modify: `src/lib/services/furni-import.ts`
|
||||
- Modify: `src/actions/admin-settings.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/route.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/resync/route.ts`
|
||||
- Modify: `src/app/api/admin/import/furni/batch-regen/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `OfficialHabboFurniEntry`.
|
||||
- Produces: `getOfficialHabboFurnidata()`, `lookupOfficialHabboFurni()`, and `clearOfficialHabboFurnidataCache()`.
|
||||
- Consumes: `getHabboGamedataHotel()` and `habboFurnidataUrl(hotel)`.
|
||||
|
||||
- [ ] **Step 1: Write a failing cache-isolation test**
|
||||
|
||||
Mock the selected hotel as `it` for the first request and `nl` for the second. Return distinct fixtures from `fetch` and assert that the second call returns the Dutch fixture and requests `https://www.habbo.nl/gamedata/furnidata_json/1`.
|
||||
|
||||
- [ ] **Step 2: Run the cache test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
|
||||
Expected: FAIL because the generic official-Habbo API is not exported yet.
|
||||
|
||||
- [ ] **Step 3: Rename the cache contract and consumers**
|
||||
|
||||
Rename the Italy-specific type and functions throughout the import pipeline. Keep the existing hotel-keyed cache behavior and update comments to say “configured official Habbo hotel”.
|
||||
|
||||
- [ ] **Step 4: Run the cache test and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||
|
||||
Expected: PASS with separate `it` and `nl` fetches.
|
||||
|
||||
### Task 2: Display and report the configured source
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/admin/import/furni/import-source.test.ts`
|
||||
- Create: `src/app/admin/import/furni/import-source.ts`
|
||||
- Modify: `src/app/admin/import/furni/page.tsx`
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Modify: `src/lib/services/furni-import.ts`
|
||||
- Modify: `src/lib/services/furni-import.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `FurniImportSource { hotel, label, host, furnidataUrl }`.
|
||||
- Produces: `getFurniImportSource()` for the server page.
|
||||
- Produces: `formatOfficialHabboEnrichmentWarning(hotel, name)`.
|
||||
|
||||
- [ ] **Step 1: Write failing source and message tests**
|
||||
|
||||
Assert that an `nl` setting becomes `{ hotel: "nl", label: "Netherlands (habbo.nl)", host: "habbo.nl", furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1" }` and that enrichment reports `Enriched from habbo.nl`.
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||
|
||||
Expected: FAIL because source metadata and the dynamic warning formatter do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement source metadata and UI**
|
||||
|
||||
Read the normalized hotel on the server page, pass source metadata into `ImportFurniClient`, and render a compact source badge/link above the furni controls. Replace literal `habbo.it` enrichment wording with the resolved host.
|
||||
|
||||
- [ ] **Step 4: Run focused verification**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run repository verification**
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all commands exit with status 0 using the same non-secret production validation environment as CI where required.
|
||||
|
||||
- [ ] **Step 6: Commit the implementation**
|
||||
|
||||
```text
|
||||
fix: use configured Habbo hotel in furni import
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Manual Recent Furni Resync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a guarded Tools command to Admin Import Furni that resyncs furniture imported during the last seven days and displays the operation result.
|
||||
|
||||
**Architecture:** Keep the existing permission-protected `/api/admin/import/furni/resync` route unchanged. Add a client-safe request/normalization helper with an injected fetcher so its exact URL, HTTP method, success payload, partial failures, and transport failures are testable without rendering the large Import Furni client. The existing client component owns the confirmation, loading, and result UI state.
|
||||
|
||||
**Tech Stack:** TypeScript, React 19, Next.js 16 App Router, Vitest, shadcn/Radix UI, Sonner, Biome.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The operation targets only `furni_import` audit entries from the last seven days.
|
||||
- Use the existing `ASSETS_IMPORT`-protected endpoint and `adminFetch` CSRF flow.
|
||||
- Do not expose `all=1`, delete database rows, or regenerate `.nitro`, SWF, or icon files.
|
||||
- Display examined, resynced, failed, RCON status, and returned per-item errors.
|
||||
- Prevent duplicate submissions while a request is active.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Tested recent-resync client contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/admin/recent-furni-resync.ts`
|
||||
- Test: `src/lib/admin/recent-furni-resync.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a fetcher matching `(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>`.
|
||||
- Produces: `RECENT_FURNI_RESYNC_URL`, `RecentFurniResyncError`, `RecentFurniResyncResult`, and `requestRecentFurniResync(fetcher): Promise<RecentFurniResyncResult>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing request-contract tests**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RECENT_FURNI_RESYNC_URL,
|
||||
requestRecentFurniResync,
|
||||
} from "./recent-furni-resync";
|
||||
|
||||
describe("requestRecentFurniResync", () => {
|
||||
it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
mode: "days",
|
||||
days: 7,
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
});
|
||||
expect(RECENT_FURNI_RESYNC_URL).toBe(
|
||||
"/api/admin/import/furni/resync?days=7",
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the API error when the request fails", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ error: "Forbidden" }, { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `recent-furni-resync.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal typed request helper**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.ts`:
|
||||
|
||||
```ts
|
||||
export const RECENT_FURNI_RESYNC_URL =
|
||||
"/api/admin/import/furni/resync?days=7";
|
||||
|
||||
export interface RecentFurniResyncError {
|
||||
classname: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecentFurniResyncResult {
|
||||
examined: number;
|
||||
resynced: number;
|
||||
failed: number;
|
||||
rconOk: boolean;
|
||||
errors: RecentFurniResyncError[];
|
||||
}
|
||||
|
||||
type AdminFetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export async function requestRecentFurniResync(
|
||||
fetcher: AdminFetcher,
|
||||
): Promise<RecentFurniResyncResult> {
|
||||
const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" });
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof payload.error === "string" ? payload.error : "Furni resync failed",
|
||||
);
|
||||
}
|
||||
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors.filter(
|
||||
(value): value is RecentFurniResyncError =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as RecentFurniResyncError).classname === "string" &&
|
||||
typeof (value as RecentFurniResyncError).message === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
examined: Number(payload.examined) || 0,
|
||||
resynced: Number(payload.resynced) || 0,
|
||||
failed: Number(payload.failed) || 0,
|
||||
rconOk: payload.rconOk === true,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec biome check --write src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: 2 tests pass and Biome reports no remaining errors.
|
||||
|
||||
- [ ] **Step 5: Commit the tested contract**
|
||||
|
||||
```powershell
|
||||
git add -- src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
git commit -m "feat: add recent furni resync client contract"
|
||||
```
|
||||
|
||||
### Task 2: Import Furni Tools action and result UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Consume: `src/lib/admin/recent-furni-resync.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `requestRecentFurniResync(adminFetch): Promise<RecentFurniResyncResult>`.
|
||||
- Produces: a `Tools → Update imported furni` action, confirmation dialog, loading state, and dismissible result panel.
|
||||
|
||||
- [ ] **Step 1: Add state and the guarded request handler**
|
||||
|
||||
Import `DatabaseZap`, `RecentFurniResyncResult`, and
|
||||
`requestRecentFurniResync`. Add state alongside the existing reorganization
|
||||
state:
|
||||
|
||||
```ts
|
||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||
const [recentResyncResult, setRecentResyncResult] =
|
||||
useState<RecentFurniResyncResult | null>(null);
|
||||
```
|
||||
|
||||
Add the handler near `startReorganize`:
|
||||
|
||||
```ts
|
||||
async function resyncRecentImports() {
|
||||
setConfirmRecentResync(false);
|
||||
setRecentResyncing(true);
|
||||
setRecentResyncResult(null);
|
||||
try {
|
||||
const result = await requestRecentFurniResync(adminFetch);
|
||||
setRecentResyncResult(result);
|
||||
if (result.examined === 0) {
|
||||
toast.info("No imported furni found in the last 7 days");
|
||||
} else if (result.failed > 0 || !result.rconOk) {
|
||||
toast.warning("Furni resync completed with warnings");
|
||||
} else {
|
||||
toast.success(`Updated ${result.resynced} imported furni`);
|
||||
}
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Furni resync failed",
|
||||
);
|
||||
} finally {
|
||||
setRecentResyncing(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Tools entry and duplicate-submit guard**
|
||||
|
||||
Insert before the Tools separator:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmRecentResync(true)}
|
||||
disabled={recentResyncing}
|
||||
>
|
||||
{recentResyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DatabaseZap className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{recentResyncing ? "Updating imported furni..." : "Update imported furni"}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the confirmation dialog**
|
||||
|
||||
Add a controlled dialog beside the existing batch confirmation dialogs:
|
||||
|
||||
```tsx
|
||||
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update imported furni?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This updates FurnitureData for furni imported during the last 7 days,
|
||||
then refreshes the emulator catalog and item caches. No database rows or
|
||||
asset files are deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRecentResync(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
|
||||
Update last 7 days
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render a dismissible result panel**
|
||||
|
||||
Place the panel after the existing error banner and before batch summaries:
|
||||
|
||||
```tsx
|
||||
{recentResyncResult && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecentResyncResult(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>{recentResyncResult.examined} examined</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{recentResyncResult.resynced} updated
|
||||
</span>
|
||||
<span className={recentResyncResult.failed ? "text-destructive" : ""}>
|
||||
{recentResyncResult.failed} failed
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
recentResyncResult.rconOk
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
|
||||
</span>
|
||||
</div>
|
||||
{recentResyncResult.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
View errors
|
||||
</summary>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{recentResyncResult.errors.map((error) => (
|
||||
<p key={`${error.classname}:${error.message}`}>
|
||||
<span className="font-mono">{error.classname}</span>: {error.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused and static verification**
|
||||
|
||||
```powershell
|
||||
pnpm exec biome check --write src/app/admin/import/furni/import-furni-client.tsx src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts src/lib/services/furni-data-paths.test.ts
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
Expected: Biome clean, focused tests pass, and TypeScript exits 0.
|
||||
|
||||
- [ ] **Step 6: Run complete regression verification**
|
||||
|
||||
```powershell
|
||||
pnpm test
|
||||
$env:NODE_ENV='production'
|
||||
$env:DATABASE_URL='mysql://test:test@localhost:3306/test?charset=utf8mb4'
|
||||
$env:AUTH_SECRET='ci-test-secret-key-that-is-long-enough'
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all tests pass and the production build exits 0. Redis/database
|
||||
availability warnings during static generation are acceptable in the local test
|
||||
environment; compilation or route-generation errors are not.
|
||||
|
||||
- [ ] **Step 7: Commit the UI integration**
|
||||
|
||||
```powershell
|
||||
git add -- src/app/admin/import/furni/import-furni-client.tsx
|
||||
git commit -m "feat: add manual recent furni resync action"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify the final repository state**
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git log --oneline origin/main..HEAD
|
||||
```
|
||||
|
||||
Expected: clean worktree and the design, plan, tested helper, and UI commits are
|
||||
ahead of `origin/main`, ready for an explicit push request.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Production Furni Assets Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make every furni import write the icon, Nitro bundle, and FurnitureData entry into the directories served by the production client under `/var/www/Gamedata`.
|
||||
|
||||
**Architecture:** Extend the central furni asset resolver with a separate Gamedata layout while preserving CMS-local and Nitro-Files targets. Both import paths consume the same resolved targets; FurnitureData resolves the same Gamedata root independently so metadata and binary assets stay aligned.
|
||||
|
||||
**Tech Stack:** TypeScript 7, Node.js filesystem APIs, Vitest 4, Next.js 16.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all existing `nitro_files_root`, `furni_*_dir`, and `furni_data_mirror_path` behavior.
|
||||
- Auto-detect `/var/www/Gamedata` only when the directory exists; allow `gamedata_root` to override it.
|
||||
- Do not copy source SWFs into the Gamedata tree.
|
||||
- Report live mirror failures in the import warnings.
|
||||
- Use test-first development and run the production build before completion.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Resolve the production Gamedata layout
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/furni-asset-dirs.test.ts`
|
||||
- Modify: `src/lib/services/furni-asset-dirs.ts`
|
||||
- Modify: `src/lib/services/furni-data.ts`
|
||||
- Modify: `src/app/admin/settings/cms-settings-config.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `getGamedataRoot(): Promise<string>`.
|
||||
- Produces: Gamedata mirror entries from `getFurniAssetWriteTargets()` with `<root>/icons` and `<root>/bundled/furniture`.
|
||||
- Consumes: `siteSettings.get("gamedata_root", "")` and `existsSync(DEFAULT_GAMEDATA_ROOT)`.
|
||||
|
||||
- [ ] **Step 1: Write failing resolver tests**
|
||||
|
||||
Mock `siteSettings.get` and `existsSync`, then assert that a configured Gamedata root produces:
|
||||
|
||||
```ts
|
||||
expect(targets.mirrorDirs).toContainEqual({
|
||||
swfDir: targets.swfDir,
|
||||
iconDir: path.join(gamedataRoot, "icons"),
|
||||
nitroDir: path.join(gamedataRoot, "bundled/furniture"),
|
||||
});
|
||||
```
|
||||
|
||||
Also assert that an absent unconfigured root adds no Gamedata mirror and that a duplicate primary destination is de-duplicated.
|
||||
|
||||
- [ ] **Step 2: Run the resolver test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
|
||||
|
||||
Expected: FAIL because `gamedata_root` is not read and the Gamedata mirror is absent.
|
||||
|
||||
- [ ] **Step 3: Implement the Gamedata resolver**
|
||||
|
||||
Add `DEFAULT_GAMEDATA_ROOT`, `getGamedataRoot()`, and a pure Gamedata mapping that uses the primary `swfDir` while mapping icons and Nitro bundles to the live locations. Merge this candidate with the existing Nitro-Files candidate and remove identical directory triples.
|
||||
|
||||
- [ ] **Step 4: Extend FurnitureData and the settings form**
|
||||
|
||||
Make `getFurnitureDataWritePaths()` include `<gamedata_root>/config/FurnitureData.json`, after any explicit `furni_data_mirror_path`. Add a documented `gamedata_root` text setting with `/var/www/Gamedata` as the placeholder.
|
||||
|
||||
- [ ] **Step 5: Run resolver tests and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 1**
|
||||
|
||||
```text
|
||||
fix: map furni imports to production gamedata
|
||||
```
|
||||
|
||||
### Task 2: Mirror manual Nitro uploads
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/services/upload-import.test.ts`
|
||||
- Modify: `src/lib/services/upload-import.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets>`.
|
||||
- Produces: manual upload copies at every unique `mirrorDirs[].iconDir` and `mirrorDirs[].nitroDir`.
|
||||
|
||||
- [ ] **Step 1: Write a failing manual-upload test**
|
||||
|
||||
Mock the database and metadata dependencies, provide temporary primary and Gamedata directories, call `uploadSingleFurni`, and assert:
|
||||
|
||||
```ts
|
||||
await expect(fs.readFile(path.join(liveNitroDir, "chair.nitro"))).resolves.toEqual(nitroBuffer);
|
||||
await expect(fs.readFile(path.join(liveIconDir, "chair_icon.png"))).resolves.toEqual(iconBuffer);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the upload test and verify RED**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
|
||||
|
||||
Expected: FAIL because manual uploads currently write only to the primary CMS directories.
|
||||
|
||||
- [ ] **Step 3: Implement manual mirroring**
|
||||
|
||||
Resolve all write targets, create their directories through the existing `ensureDirectories()`, and copy the successfully written primary Nitro and optional icon files to each unique mirror. Catch each copy error separately and append a warning containing the destination path.
|
||||
|
||||
- [ ] **Step 4: Run the upload test and verify GREEN**
|
||||
|
||||
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run focused and full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: every command exits with status 0.
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
```text
|
||||
fix: mirror manual furni uploads to live assets
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
# Furni import hotel source
|
||||
|
||||
## Problem
|
||||
|
||||
The furni import already fetches furnidata through the CMS setting
|
||||
`habbo_gamedata_hotel`, but its public and internal language still refers to
|
||||
Habbo Italy. This makes the active source unclear and gives the impression
|
||||
that the importer is hardcoded to `habbo.it`.
|
||||
|
||||
## Design
|
||||
|
||||
The import page will read `habbo_gamedata_hotel` on the server and pass the
|
||||
normalized hotel value and label to the client. The page will display the
|
||||
active official furnidata source near the import controls, including the
|
||||
resolved `habbo.<hotel>` host.
|
||||
|
||||
Import enrichment warnings will use the resolved hotel label rather than the
|
||||
literal `habbo.it`. Internal furnidata cache APIs and types will be renamed
|
||||
from Italy-specific names to generic official-Habbo names while retaining
|
||||
temporary aliases only where needed to avoid an unsafe all-at-once migration.
|
||||
|
||||
Furniture SWF and icon downloads remain on `images.habbo.com`. That host is
|
||||
Habbo's global asset CDN and must not be derived from the gamedata locale.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Admin settings stores `habbo_gamedata_hotel`.
|
||||
2. Server-side import code normalizes the value through
|
||||
`getHabboGamedataHotel()`.
|
||||
3. Furnidata and external texts use `www.habbo.<hotel>`.
|
||||
4. The import UI and enrichment messages display the same resolved hotel.
|
||||
5. The in-memory cache remains keyed by hotel, so changing the setting loads
|
||||
the selected locale rather than reusing another locale's data.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests will cover normalized locale URL generation, cache separation after a
|
||||
hotel change, dynamic enrichment text, and the source information passed to
|
||||
the import UI. Existing furni import tests, typecheck, full tests, and the
|
||||
production build must pass.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Manual Recent Furni Resync Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a safe manual command to the existing Admin Import Furni screen for
|
||||
refreshing furniture imported during the last seven days. The command must
|
||||
update the live FurnitureData metadata and emulator caches without deleting
|
||||
database rows or regenerating existing asset bundles.
|
||||
|
||||
## User interface
|
||||
|
||||
Add `Update imported furni` to the existing `Tools` dropdown on
|
||||
`/admin/import/furni`.
|
||||
|
||||
Selecting it opens a confirmation dialog that states the fixed scope: furniture
|
||||
recorded by the admin audit log as imported during the last seven days. While
|
||||
the operation is running, the action and confirmation button are disabled and a
|
||||
loading state is shown to prevent duplicate requests.
|
||||
|
||||
After completion, show a result panel containing:
|
||||
|
||||
- examined item count;
|
||||
- successfully resynced item count;
|
||||
- failed item count;
|
||||
- emulator RCON refresh status;
|
||||
- returned per-item errors, when present.
|
||||
|
||||
## Data flow
|
||||
|
||||
The client sends an authenticated, CSRF-protected `POST` request through
|
||||
`adminFetch` to the existing endpoint:
|
||||
|
||||
`/api/admin/import/furni/resync?days=7`
|
||||
|
||||
The endpoint remains authoritative for selecting the targets. It reads only
|
||||
`ItemsBase` IDs referenced by `admin_audit_log` entries whose action is
|
||||
`furni_import`, target is `ItemsBase`, and timestamp falls within the last seven
|
||||
days.
|
||||
|
||||
For each target, the existing resync logic rebuilds its FurnitureData entry,
|
||||
enriches it from the configured `habbo_gamedata_hotel`, and upserts it into the
|
||||
live `FurnitureData.json`. It then requests `updateCatalog` and `updateItems`
|
||||
through RCON.
|
||||
|
||||
## Safety and permissions
|
||||
|
||||
- Reuse the endpoint's `ASSETS_IMPORT` permission check and admin CSRF guard.
|
||||
- Do not expose a UI option for `all=1`.
|
||||
- Do not delete or recreate `items_base` or catalog rows.
|
||||
- Do not regenerate `.nitro`, SWF, or icon files.
|
||||
- Keep returned errors visible without treating an RCON failure as a successful
|
||||
refresh.
|
||||
|
||||
## Error handling
|
||||
|
||||
Network or non-JSON failures produce an error toast and leave the command
|
||||
available for retry. A successful API response is rendered even when individual
|
||||
items failed, so the administrator can distinguish partial completion from a
|
||||
request failure.
|
||||
|
||||
The result panel is dismissible. Running the command again replaces the prior
|
||||
result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit-test the result normalization used by the UI, including partial failures
|
||||
and RCON status.
|
||||
- Verify that the client calls exactly `resync?days=7` through `adminFetch`.
|
||||
- Run focused tests, Biome, TypeScript, the full test suite, and a production
|
||||
build.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Production furni asset destinations
|
||||
|
||||
## Problem
|
||||
|
||||
The production Nitro client loads furniture icons from `/gamedata/icons`,
|
||||
furniture bundles from `/gamedata/bundled/furniture`, and furniture metadata
|
||||
from `/gamedata/config/FurnitureData.json`. The importer currently derives its
|
||||
mirror paths using the development `Nitro-Files` layout. On the production
|
||||
server this creates paths such as `swf/dcr/hof_furni/icons` and
|
||||
`nitro-assets/bundled/furniture` below the configured root, which are not the
|
||||
directories served by the live client. Manual `.nitro` uploads do not mirror
|
||||
assets at all.
|
||||
|
||||
Files written only below the CMS `public` directory are also not durable: the
|
||||
deployment workflow cleans untracked files from the CMS checkout.
|
||||
|
||||
## Design
|
||||
|
||||
Keep the existing CMS-local and `Nitro-Files` destinations for compatibility,
|
||||
and add the deployed Gamedata tree as a distinct asset layout. In production,
|
||||
`/var/www/Gamedata` is detected automatically when it exists. A configurable
|
||||
`gamedata_root` setting can override that location for other installations.
|
||||
|
||||
The Gamedata layout maps assets as follows:
|
||||
|
||||
- icons: `<gamedata_root>/icons`
|
||||
- Nitro furniture: `<gamedata_root>/bundled/furniture`
|
||||
- FurnitureData: `<gamedata_root>/config/FurnitureData.json`
|
||||
- source SWFs: not copied into Gamedata because the Nitro client does not load
|
||||
them; existing CMS-local and `Nitro-Files` SWF handling remains unchanged
|
||||
|
||||
Both the normal Habbo importer and manual `.nitro` uploader use the same write
|
||||
target resolver. Duplicate destinations are removed before writing.
|
||||
|
||||
## Error handling
|
||||
|
||||
The CMS-local write remains the primary operation. Every configured or
|
||||
auto-detected live destination is treated as an expected mirror. Directory or
|
||||
copy failures are returned as import warnings containing the failed target,
|
||||
instead of being silently ignored. A successful import therefore cannot hide
|
||||
that the live client asset copy failed.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing `nitro_files_root`, `furni_swf_dir`, `furni_icon_dir`,
|
||||
`furni_nitro_dir`, and `furni_data_mirror_path` behavior is preserved. The new
|
||||
Gamedata destination is additive, so Windows development using the
|
||||
`Nitro-Files` layout continues to work.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests will cover Gamedata path derivation, automatic production-root
|
||||
detection through an injected root, destination de-duplication, and manual
|
||||
upload mirroring. Existing importer tests, type checks, and the production
|
||||
build must remain green.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Public Avatar Thumbnail and Currency Icon Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make avatar and currency presentation consistent across the public site:
|
||||
|
||||
- user thumbnails in lists and compact cards show only the avatar head at a fixed 40 x 40 pixel size;
|
||||
- full-body avatars remain available on profile pages and deliberately large previews;
|
||||
- currency amounts use the existing graphical currency icons instead of placeholder letters such as `c`, `cr`, `du`, or `di`.
|
||||
|
||||
## Scope
|
||||
|
||||
The change covers public-facing pages and shared public components. It includes rankings, leaderboards, shop and badge-purchase currency rows, plus every other compact user list or card that currently renders an avatar directly.
|
||||
|
||||
Admin-only screens are outside this visual cleanup unless they reuse a shared public component changed by this work. Profile hero avatars, the main current-user avatar, registration/login previews, and other intentionally large previews retain their full-avatar presentation.
|
||||
|
||||
## Avatar Design
|
||||
|
||||
Compact user representations will use one shared semantic thumbnail path rather than choosing imager options independently in each page.
|
||||
|
||||
The thumbnail contract is:
|
||||
|
||||
- request `headOnly: true` from the avatar imager;
|
||||
- render at 40 x 40 CSS and image dimensions;
|
||||
- preserve pixel-art rendering and contain the image without stretching;
|
||||
- prevent the thumbnail container from shrinking into adjacent text;
|
||||
- use the user's actual figure and the existing avatar URL fallback behavior;
|
||||
- keep useful alternative text based on the displayed username where that context is available.
|
||||
|
||||
The existing shared avatar component will be extended with an explicit compact/head-thumbnail variant, or a narrowly focused wrapper will be added if that keeps call sites clearer. Public list and compact-card call sites will migrate to this shared contract. Large/profile call sites will remain explicit so they cannot be accidentally cropped by a global CSS rule.
|
||||
|
||||
## Currency Design
|
||||
|
||||
All public currency amount rows will use the existing `CurrencyIcon` component and the existing assets under `public/assets/images/icons/currency`.
|
||||
|
||||
The mapping is:
|
||||
|
||||
- credits: `credits.png`;
|
||||
- duckets: `duckets.png`;
|
||||
- diamonds/crystals: `diamonds.png`.
|
||||
|
||||
Icons will be decorative when the surrounding UI already names the currency, using an empty alternative text to avoid repeated screen-reader announcements. The numeric amount and existing pill/layout styling remain unchanged. Icon size will be fixed consistently for compact amount rows, with no textual placeholder left visible.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Add the shared compact avatar contract and focused tests.
|
||||
2. Inventory public avatar call sites and classify each as compact thumbnail or large/profile preview.
|
||||
3. Migrate every compact call site to the shared head-only 40 x 40 rendering.
|
||||
4. Replace textual and empty CSS currency markers in public amount rows with `CurrencyIcon` and the correct currency kind.
|
||||
5. Remove CSS rules that exist only to draw obsolete letter-based or background-only markers, while retaining layout classes still used by the amount pills.
|
||||
|
||||
This semantic migration is preferred over a global CSS crop because it sends the correct head-only request to the imager and does not risk changing profile avatars.
|
||||
|
||||
## Verification
|
||||
|
||||
Verification will include:
|
||||
|
||||
- automated tests for the compact avatar contract (`headOnly`, fixed dimensions, actual figure propagation);
|
||||
- source/component checks ensuring public currency rows use `CurrencyIcon` with the correct mapping and no placeholder letters remain;
|
||||
- the existing test, type-check, and build commands relevant to the changed files;
|
||||
- visual checks at desktop and narrow widths for rankings, leaderboard, shop, badge purchase, and representative user lists/cards;
|
||||
- explicit checks that profile pages and large avatar previews still render full avatars.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- changing balances or currency business logic;
|
||||
- changing the avatar imager service itself;
|
||||
- redesigning profile hero sections;
|
||||
- modifying admin-only layouts that do not share the affected public components.
|
||||
@@ -0,0 +1,438 @@
|
||||
# Housekeeping modernization design
|
||||
|
||||
Date: 2026-08-24
|
||||
Status: approved in design review; awaiting review of this written specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Replace the current administration experience with one coherent, role-adaptive Housekeeping (HK) at `/admin`.
|
||||
|
||||
The new HK is a modular part of the existing Next.js application. It is built in parallel, validated against the current system, and exposed with one atomic cutover. It unifies the current `/admin` and `/mod` surfaces, removes duplicated workflows, and preserves reliable domain services without automatically preserving their current pages.
|
||||
|
||||
This document is the master architecture for the program. It is deliberately not one giant implementation plan. Delivery is split into independently specified and verified subprojects, beginning with **Inventory & Foundation**.
|
||||
|
||||
## Current-state findings
|
||||
|
||||
- The repository currently contains 124 `page.tsx` files below `src/app/admin` and 13 below `src/app/mod`: 137 administration pages in total.
|
||||
- `src/lib/admin-nav.ts` currently exposes nine navigation groups and seven hub definitions.
|
||||
- `/admin` and `/mod` provide overlapping moderation, ticket, ban, team, and user workflows with separate shells.
|
||||
- `/admin/housekeeping` is a legacy permission archive/comparison/export surface, while `/admin/permissions` is the live permission-management surface.
|
||||
- The current dashboard reports useful counts but is not an operational work queue.
|
||||
- Page composition, localization, ACL checks, filtering, error handling, and action feedback are not yet uniform across the administration surface.
|
||||
|
||||
The migration must therefore classify every current page. A visual refresh without workflow and boundary changes is insufficient.
|
||||
|
||||
## Approved decisions
|
||||
|
||||
| Area | Decision |
|
||||
| --- | --- |
|
||||
| Audience | One role-adaptive HK. Effective capabilities, not rank names alone, determine what an operator sees and can do. |
|
||||
| Entry point | `/admin` is the only administration entry point after cutover. `/mod` is removed. |
|
||||
| Layout | Command Deck: compact domain rail, contextual navigation, global command palette, operational workspace. |
|
||||
| Personalization | Hybrid: the system supplies mandatory capability-derived content; the operator may pin and reorder allowed shortcuts and optional widgets. |
|
||||
| Compatibility | Clean break. Old subroute compatibility and legacy UX are not preserved through redirects. |
|
||||
| Build strategy | Build the new HK in parallel, keep it unavailable to normal production operators, then switch atomically. |
|
||||
| Work queue | “Da fare ora” is derived from existing sources. It is not a second task database and never owns workflow state. |
|
||||
| Command palette | It navigates, searches entities, and executes only safe commands. Sensitive actions open a dedicated contextual flow. |
|
||||
| Architecture | Modular hybrid replacement inside the current application: reuse sound services, rebuild weak UI/workflows, merge duplicates, and remove obsolete surfaces. |
|
||||
|
||||
## Goals
|
||||
|
||||
1. Give each operator one clear, capability-appropriate place to work.
|
||||
2. Replace feature sprawl with six stable domains and consistent page contracts.
|
||||
3. Make urgent work visible without copying or diverging from source workflow state.
|
||||
4. Enforce authorization, validation, transaction boundaries, error semantics, and audit behavior server-side.
|
||||
5. Remove `/mod`, the legacy HK archive page, duplicate hubs, and manual navigation concepts that the new foundation owns.
|
||||
6. Reach explicit functional, authorization, audit, localization, accessibility, and data-parity gates before cutover.
|
||||
7. Keep rollback practical without exposing a mixed legacy/new experience.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Creating a separate HK application, microservice, or deployment.
|
||||
- Creating a new assignment/task system for the operational inbox.
|
||||
- Preserving every current page, route, component, or interaction.
|
||||
- Adding backward-compatible redirects for removed administration subroutes.
|
||||
- Providing full sensitive-workflow parity on phones. The target is desktop-first with usable tablet layouts.
|
||||
- Redesigning public CMS or game-client experiences as part of this program.
|
||||
- Replacing sound domain logic solely for architectural uniformity.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Modular monolith
|
||||
|
||||
The HK remains inside EpicNext CMS and uses the application's existing authentication, database, service, localization, and deployment infrastructure.
|
||||
|
||||
The target source organization separates composition from behavior:
|
||||
|
||||
```text
|
||||
src/app/admin/ route composition only
|
||||
src/features/housekeeping/
|
||||
foundation/ shell, registry, ACL context, preferences
|
||||
domains/
|
||||
operations/ derived inbox, global search, recent work
|
||||
people/
|
||||
content/
|
||||
economy/
|
||||
hotel/
|
||||
system/
|
||||
src/lib/services/ existing and extracted domain services
|
||||
```
|
||||
|
||||
The exact filenames are an implementation-plan concern, but the boundaries are mandatory:
|
||||
|
||||
- App Router files compose pages and bind route parameters; they do not own business rules.
|
||||
- The foundation owns cross-cutting HK behavior and does not mutate domain data.
|
||||
- Each domain owns its queries, commands, search providers, inbox providers, widgets, and page composition.
|
||||
- Domains do not import another domain's UI internals. Cross-domain interaction uses registered contracts or links to the owning route.
|
||||
- Existing reliable services are adapted behind domain contracts rather than copied into the new UI.
|
||||
|
||||
### Module manifest and registry
|
||||
|
||||
Every domain exports a manifest with stable identifiers for:
|
||||
|
||||
- domain metadata and localized labels;
|
||||
- routes and contextual navigation;
|
||||
- required capabilities;
|
||||
- command-palette entries;
|
||||
- entity-search providers;
|
||||
- derived-inbox sources;
|
||||
- mandatory and optional dashboard widgets.
|
||||
|
||||
The foundation composes these manifests into the rail, contextual navigation, palette, dashboard, and route metadata. Contract tests reject duplicate IDs, duplicate routes, missing localization keys, unknown capability slugs, and commands without an owner.
|
||||
|
||||
The manifest registry replaces hand-maintained duplication between the sidebar, hubs, search, and dashboards. It is code-owned and reviewable. Operator preferences can alter presentation only within what the registry and capability context permit.
|
||||
|
||||
### Capability context
|
||||
|
||||
The server creates one request-scoped capability context from the authenticated operator and the existing ACL source.
|
||||
|
||||
- Capability checks are based on effective permission slugs.
|
||||
- Super-administrator behavior remains explicit and testable.
|
||||
- Rank may help choose default presentation, but never grants access by itself.
|
||||
- Navigation filtering is a usability feature, not an authorization boundary.
|
||||
- Every query and command rechecks its capability on the server and defaults to deny.
|
||||
|
||||
## Functional domains
|
||||
|
||||
| Domain | Owns | Representative current areas |
|
||||
| --- | --- | --- |
|
||||
| Da fare & operations | Derived inbox, global search, recent work, favorites, operational summaries | Dashboard, selected alerts and cross-domain counts; projections only |
|
||||
| People & community | Users, online state, accounts, guilds, applications, staff directory, moderation, support | Users, multi-accounts, guilds, applications, CFH, moderation actions, bans, IP/VPN, word filter, tickets, help tickets, `/mod/*` |
|
||||
| Content & engagement | Public/editorial content and engagement workflows | Articles, photos, media, banners, ads, events, polls, help content, tags, prefixes, writable boxes, email content, branding/localization surfaces |
|
||||
| Economy & catalog | Products, value, commercial assets, and economic history | Catalog, items, import/maintenance, shop, marketplace, transactions, vouchers, subscriptions, rare values, badges, achievements, sounds |
|
||||
| Hotel & world | Live hotel surfaces and world-management tools | Rooms, navigator, radio, studio/runtime asset tools, contextual hotel actions |
|
||||
| System, access & observability | Configuration, authorization, diagnostics, and privileged operations | Permissions, access audit, settings, maintenance, emulator, command center, logs, analytics, alerts, DevOps |
|
||||
|
||||
Where an existing feature spans two domains, responsibility follows the action rather than the old route. For example, the staff directory belongs to People, while the policy granting staff capabilities belongs to System and Access.
|
||||
|
||||
Domain landing pages summarize their own workflows. They do not recreate the global dashboard or become a second source of state.
|
||||
|
||||
## Operator experience
|
||||
|
||||
### Command Deck shell
|
||||
|
||||
The shared shell contains:
|
||||
|
||||
1. A compact rail for the six domains.
|
||||
2. Contextual navigation generated from the active domain manifest.
|
||||
3. A global command/search field available by keyboard.
|
||||
4. A main workspace using consistent title, context, primary action, filters, content, and feedback regions.
|
||||
5. Operator identity, effective-capability context, notifications, and session controls.
|
||||
|
||||
The shell is desktop-first, fully keyboard operable, and responsive for tablets. Phone layouts may support inspection and low-risk triage, but sensitive multi-step operations are not optimized for phone use.
|
||||
|
||||
### Adaptive dashboard
|
||||
|
||||
ACL and capability data determine:
|
||||
|
||||
- visible domains and routes;
|
||||
- mandatory queues and warnings;
|
||||
- permitted metrics and widgets;
|
||||
- available commands and search providers.
|
||||
|
||||
The operator may:
|
||||
|
||||
- pin allowed routes and safe commands;
|
||||
- reorder shortcuts and optional widgets;
|
||||
- add or remove optional allowed widgets;
|
||||
- persist preferred filters and presentation density where supported.
|
||||
|
||||
The operator may not hide mandatory warnings, reveal unauthorized data, or preserve a shortcut after its required capability is lost.
|
||||
|
||||
Preferences are server-persisted, user-scoped, schema-versioned, and non-authoritative. If no suitable existing preference store exists, the foundation adds one additive `housekeeping_user_preferences` store containing presentation state only. It never stores task status or authorization decisions. Every preference is reconciled with the current manifest and capability context when read.
|
||||
|
||||
### Standard page contract
|
||||
|
||||
Every target page follows the same structural contract:
|
||||
|
||||
- localized title, description, breadcrumb/context, and one clear primary action;
|
||||
- capability-derived actions with server authorization;
|
||||
- shared filtering, pagination, empty, loading, partial, and error states;
|
||||
- explicit unsaved-change behavior for editable forms;
|
||||
- consistent confirmation and outcome feedback;
|
||||
- stable deep links to owned entities and workflows;
|
||||
- responsive table-to-detail behavior without hiding critical fields;
|
||||
- audit context for mutations.
|
||||
|
||||
## Operational inbox
|
||||
|
||||
The inbox is a read model over domain-owned sources such as tickets, CFH reports, alerts, emulator errors, and detected anomalies.
|
||||
|
||||
Each source emits normalized work items containing at least:
|
||||
|
||||
- stable source and item IDs;
|
||||
- domain and required capability;
|
||||
- severity and source timestamp;
|
||||
- localized summary and optional context;
|
||||
- stable destination route and entity target;
|
||||
- deduplication key;
|
||||
- freshness/availability metadata.
|
||||
|
||||
The aggregator:
|
||||
|
||||
1. Requests sources independently with bounded timeouts.
|
||||
2. Filters every result against the operator's capability context.
|
||||
3. Deduplicates by stable source identity.
|
||||
4. Orders by severity, age, and domain policy.
|
||||
5. Returns both items and per-source availability.
|
||||
|
||||
The aggregator never creates, assigns, dismisses, or completes work. Selecting an item opens the owning workflow. If that workflow supports assignment or resolution, those state changes occur there.
|
||||
|
||||
A failed or timed-out source does not erase successful sources. The UI labels the missing source and the freshness of remaining data instead of presenting the whole system as healthy.
|
||||
|
||||
## Global search and command palette
|
||||
|
||||
The palette has three provider types:
|
||||
|
||||
1. **Navigation providers** for permitted routes and favorites.
|
||||
2. **Entity providers** for capability-filtered entities such as users, rooms, tickets, articles, or catalog entries.
|
||||
3. **Safe command providers** for narrowly scoped, validated, idempotent or reversible actions.
|
||||
|
||||
A mutation may run directly from the palette only when it is single-target, low impact, reviewable in the palette, protected by a specific capability, and safe against duplicate submission. It still uses the normal server command and audit path.
|
||||
|
||||
Destructive, economic, moderation, permission, bulk, or otherwise sensitive actions return a navigation intent. The target page receives validated context and shows impact, current state, required reason, confirmation, and final outcome.
|
||||
|
||||
## Data and command flow
|
||||
|
||||
### Queries
|
||||
|
||||
```text
|
||||
page or shell
|
||||
-> request-scoped capability context
|
||||
-> typed domain query
|
||||
-> existing API/repository through an adapter
|
||||
-> sanitized response
|
||||
```
|
||||
|
||||
The UI does not query arbitrary tables or reproduce sensitive filter rules. Authorization-sensitive results are filtered at the query boundary. Short-lived caching may be used for operational counts, but authorization is applied after cache lookup and sensitive per-user results are not shared across capability contexts.
|
||||
|
||||
### Commands
|
||||
|
||||
```text
|
||||
intent
|
||||
-> server capability check
|
||||
-> schema validation
|
||||
-> current-state/concurrency check
|
||||
-> domain transaction or controlled external call
|
||||
-> audit outcome
|
||||
-> typed result and cache invalidation
|
||||
```
|
||||
|
||||
Every command receives a server-issued action ID used as an idempotency key. Duplicate submissions return the original known outcome rather than repeating the mutation.
|
||||
|
||||
For records with a revision or update timestamp, edits use optimistic concurrency. A stale edit returns a conflict result and current-state reference; it is not silently overwritten. Where a source cannot expose a revision, the command performs the strongest available transactional re-read before mutation.
|
||||
|
||||
## Security and audit
|
||||
|
||||
- Default-deny server checks protect every query and command.
|
||||
- Sensitive actions require a dedicated flow, an explicit target, an impact summary, confirmation, and a non-empty operator reason.
|
||||
- Domain validation occurs after authorization and before mutation.
|
||||
- Audit is append-only from the HK application: no HK route can edit or delete audit events.
|
||||
- Audit records include actor, target, command, reason, sanitized before/after details where appropriate, outcome, timestamp, action ID, and correlation ID.
|
||||
- Secrets, credentials, tokens, and unnecessary personal data are excluded from audit payloads.
|
||||
- When data and audit share a transactional store, a privileged mutation and its audit record commit together.
|
||||
- For external operations, an intent/pending audit record is written before dispatch and completed with success or failure afterward.
|
||||
- A privileged mutation fails closed if its required audit trail cannot be established.
|
||||
|
||||
## Error model
|
||||
|
||||
Domain boundaries return typed outcomes rather than leaking raw infrastructure errors:
|
||||
|
||||
- validation failure;
|
||||
- authentication required;
|
||||
- capability denied;
|
||||
- not found;
|
||||
- stale/conflicting state;
|
||||
- dependency unavailable;
|
||||
- partial aggregate result;
|
||||
- unexpected internal failure.
|
||||
|
||||
Expected outcomes have localized, actionable messages. Unexpected failures expose a correlation ID to the operator and retain technical detail only in server logs. Forms preserve safe input after recoverable failures. Lists and the operational dashboard distinguish empty results from unavailable data.
|
||||
|
||||
## Migration inventory
|
||||
|
||||
The first subproject creates a committed migration matrix covering all 137 current pages. Each row contains:
|
||||
|
||||
- legacy path and source surface (`admin` or `mod`);
|
||||
- target domain and owning workflow;
|
||||
- target path;
|
||||
- decision: `REHOST`, `REBUILD`, `MERGE`, or `REMOVE`;
|
||||
- required read and mutation capabilities;
|
||||
- source queries and mutations;
|
||||
- audit requirement;
|
||||
- localization and accessibility status;
|
||||
- required unit, integration, and E2E coverage;
|
||||
- parity evidence and migration status.
|
||||
|
||||
Decision meanings:
|
||||
|
||||
- **REHOST**: the current UI and service are sound enough to enter the new shell after contract and ACL adaptation.
|
||||
- **REBUILD**: preserve the workflow and sound service logic, but reconstruct its interaction and page composition.
|
||||
- **MERGE**: combine duplicated routes or variants into one owning workflow with contextual views.
|
||||
- **REMOVE**: eliminate obsolete or foundation-owned behavior at cutover.
|
||||
|
||||
Mandatory consolidations:
|
||||
|
||||
- All 13 `/mod` pages merge into People and Community workflows. `/mod` does not redirect after cutover.
|
||||
- `/admin/housekeeping` ceases to exist as a named feature. Useful comparison/export history moves into System, Access, and Audit.
|
||||
- `/admin/permissions` remains the live policy editor under System and Access.
|
||||
- Legacy dashboard, hub, and manual HK-navigation concepts are removed when their responsibilities are supplied by the registry and Command Deck.
|
||||
|
||||
No page is considered migrated merely because it renders in the new shell. Its matrix row closes only after data, actions, capability behavior, audit, localization, accessibility, and required tests pass.
|
||||
|
||||
## Delivery decomposition
|
||||
|
||||
This master design controls the program. For delivery purposes it is also the approved design specification for subproject 01. Subprojects 02 through 06 require their own scoped design specifications before their implementation plans. Subprojects are delivered in this order:
|
||||
|
||||
### 01. Inventory & Foundation
|
||||
|
||||
This is the first and only scope of the initial implementation plan.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- the complete 137-page migration matrix;
|
||||
- HK manifest contracts and registry validation;
|
||||
- request-scoped capability context and server guard interfaces;
|
||||
- domain query, command, search, inbox, and widget contracts;
|
||||
- the Command Deck shell primitives and standard page-state contract;
|
||||
- six domain manifests with no migrated business workflow yet;
|
||||
- a non-production/test-only entry mechanism that cannot expose a mixed HK to normal production operators;
|
||||
- contract, capability, localization-key, accessibility-smoke, and shell tests.
|
||||
|
||||
Explicit exclusions:
|
||||
|
||||
- no current `/admin` or `/mod` route changes;
|
||||
- no production operator exposure;
|
||||
- no operational inbox aggregation;
|
||||
- no entity search implementation;
|
||||
- no domain mutation migration;
|
||||
- no legacy deletion.
|
||||
|
||||
### 02. Access, audit & system core
|
||||
|
||||
Implement the capability enforcement adapters, audit command path, error taxonomy, correlation IDs, and core observability used by every later vertical.
|
||||
|
||||
### 03. People, moderation & support
|
||||
|
||||
Deliver the first complete vertical and unify user, ticket, CFH, moderation-action, and ban workflows. This vertical proves the future removal of `/mod` without exposing a partial cutover.
|
||||
|
||||
### 04. Command Deck operations
|
||||
|
||||
Implement global search, safe commands, favorites, preferences, and the derived inbox against the sources available from completed verticals.
|
||||
|
||||
### 05. Remaining domain verticals
|
||||
|
||||
Deliver separate scoped specifications and plans for:
|
||||
|
||||
1. Content and Engagement;
|
||||
2. Hotel and World;
|
||||
3. Economy and Catalog;
|
||||
4. remaining System, Access, and Observability pages.
|
||||
|
||||
Economy and permission-affecting mutations receive the strictest confirmation, concurrency, and audit coverage.
|
||||
|
||||
### 06. Parity, cutover & cleanup
|
||||
|
||||
Close the migration matrix, run cross-role journeys and data comparisons, switch `/admin`, make `/mod` unreachable, observe the release, then delete unreachable legacy code and later remove obsolete schema safely.
|
||||
|
||||
Subproject 01 uses this specification; every later subproject has its own spec, implementation plan, tests, review, and completion gate. A later subproject may not silently expand an earlier approved scope.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
Every subproject runs proportionate checks from these layers:
|
||||
|
||||
1. **Unit tests** for manifest parsing, normalizers, policy functions, reducers, and domain services.
|
||||
2. **Contract tests** for unique IDs/routes, capability declarations, localization keys, command ownership, and provider behavior.
|
||||
3. **Integration tests** against representative repository/API implementations, including transactions, external failures, idempotency, and conflicts.
|
||||
4. **ACL matrix tests** covering permitted, denied, capability-revoked, and super-administrator cases at both render and server boundaries.
|
||||
5. **E2E journeys** for moderation, support, editorial, economy, hotel operations, and administration roles defined by capabilities rather than rank labels.
|
||||
6. **Audit assertions** after every tested mutation.
|
||||
7. **Accessibility checks** for keyboard use, focus order, names, contrast, live feedback, dialogs, and table/detail transitions.
|
||||
8. **Localization checks** rejecting new hard-coded operator copy and missing translation keys.
|
||||
9. **Visual regression checks** for the shared shell and high-risk standard states.
|
||||
10. **Performance comparison** against a recorded legacy baseline using the same environment and dataset. Comparable new flows may not regress median or p95 response time by more than 10% without an explicit reviewed exception. Performance improvements are reported only from measurements.
|
||||
|
||||
## Cutover gate
|
||||
|
||||
The atomic switch is permitted only when all of the following are true:
|
||||
|
||||
- all 137 migration rows are closed with evidence;
|
||||
- every exposed query and command has a declared and tested capability;
|
||||
- every mutation has validation and required audit coverage;
|
||||
- no blocking or critical defect remains open;
|
||||
- equivalent legacy/new counts and records have been compared for migrated read workflows;
|
||||
- role journeys for moderator, support operator, editor, economy operator, hotel operator, and administrator pass;
|
||||
- localization, accessibility, build, type, lint, test, and visual checks pass;
|
||||
- production-like smoke tests, backup verification, rollback procedure, and health checks have been rehearsed;
|
||||
- the new HK is not dependent on legacy UI routes;
|
||||
- communication and operator runbooks are ready for the clean break.
|
||||
|
||||
## Cutover and rollback
|
||||
|
||||
Before cutover, the new HK is exercised through test/staging or an explicit non-production mechanism. Read-only shadow comparisons may run against representative data. There is no production dual-write.
|
||||
|
||||
At cutover:
|
||||
|
||||
1. `/admin` changes to the new route composition in one release/flag transition.
|
||||
2. `/mod` and removed legacy subroutes become unreachable without compatibility redirects.
|
||||
3. Smoke tests verify authentication, capability filtering, representative reads, one controlled mutation per risk class, audit, and health signals.
|
||||
|
||||
Database changes required before cutover are additive and backward-compatible for the emergency rollback window. A flag or previous release can temporarily restore the legacy application if the cutover fails. During normal operation, only one HK is exposed.
|
||||
|
||||
After the agreed stability window, unreachable legacy code and flags are removed. Destructive schema cleanup is a later migration and is not coupled to the cutover release.
|
||||
|
||||
## Success criteria
|
||||
|
||||
The program is complete when:
|
||||
|
||||
- `/admin` is the single role-adaptive administration surface;
|
||||
- `/mod` and the legacy Housekeeping archive surface are gone;
|
||||
- all 137 legacy pages have an evidenced migration decision;
|
||||
- all exposed data, navigation, commands, widgets, and inbox items are capability-correct;
|
||||
- the operational inbox derives live work without owning duplicate workflow state;
|
||||
- all mutations use the domain command, validation, concurrency, idempotency, and audit path appropriate to their risk;
|
||||
- no mixed legacy/new production experience exists;
|
||||
- measured performance meets the approved comparison gate;
|
||||
- rollback and eventual legacy cleanup are complete.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
### Full greenfield rewrite
|
||||
|
||||
Rejected because it would discard reliable existing services and maximize parity, timing, and regression risk across 137 pages.
|
||||
|
||||
### Cosmetic refactor of the existing HK
|
||||
|
||||
Rejected because it would preserve duplicated `/admin` and `/mod` workflows, inconsistent page boundaries, and manual navigation debt.
|
||||
|
||||
### Separate HK service/application
|
||||
|
||||
Rejected because the current requirement does not justify another deployment, authentication boundary, or distributed consistency problem.
|
||||
|
||||
### Persistent cross-domain task database
|
||||
|
||||
Rejected because it would duplicate ticket, moderation, alert, and anomaly state and create reconciliation failure modes.
|
||||
|
||||
## Final design invariant
|
||||
|
||||
The migration may be incremental internally, but the operator-facing product is not. Until the cutover gate passes, the current HK remains the only normal production surface. After cutover, the new HK is the only surface.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// Schema source of truth for the query builder: src/db/schema.ts
|
||||
// (regenerated via `pnpm db:schema:generate` from the previous schema + live DB).
|
||||
//
|
||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
||||
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
||||
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/drafts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,12 @@
|
||||
-- 0020_performance_indexes.sql
|
||||
-- Adds indexes for the hot CMS read paths (shared DB with the Arcturus
|
||||
-- emulator — additive only, no schema changes to emulator-owned columns).
|
||||
--
|
||||
-- users.credits → credits leaderboard (ORDER BY credits DESC LIMIT 20)
|
||||
-- users_currency(type, amount) → duckets/diamonds leaderboard (WHERE type=? ORDER BY amount DESC LIMIT 20)
|
||||
-- users_settings.respects_received → respects leaderboard (ORDER BY respects_received DESC LIMIT 20)
|
||||
-- camera_web.timestamp → homepage recent photos (ORDER BY timestamp DESC LIMIT 4)
|
||||
CREATE INDEX IF NOT EXISTS `idx_users_credits` ON `users` (`credits`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_users_currency_type_amount` ON `users_currency` (`type`, `amount`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_users_settings_respects_received` ON `users_settings` (`respects_received`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_camera_web_timestamp` ON `camera_web` (`timestamp`);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 0021_add_messenger_offline_user_id_index.sql
|
||||
-- The /me dashboard counts unread offline messages with
|
||||
-- `WHERE user_id = ?`; messenger_offline previously had no index there.
|
||||
CREATE INDEX IF NOT EXISTS `idx_messenger_offline_user_id` ON `messenger_offline` (`user_id`);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add terms/age consent columns expected by the users schema (register flow).
|
||||
ALTER TABLE `users`
|
||||
ADD COLUMN `terms_accepted` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN `age_verified` TINYINT(1) NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Theme Builder: scoped theme system for per-route, per-module, and multi-site theming.
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS theme_scopes (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type ENUM('global','site','module','route') NOT NULL,
|
||||
parent_id BIGINT UNSIGNED NULL,
|
||||
site_domain VARCHAR(255) NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
module_id VARCHAR(100) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY theme_scopes_parent_idx (parent_id),
|
||||
KEY theme_scopes_type_idx (type),
|
||||
UNIQUE KEY theme_scopes_unique_lookup (type, site_domain, route_path, module_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS theme_scope_values (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scope_id BIGINT UNSIGNED NOT NULL,
|
||||
setting_key VARCHAR(100) NOT NULL,
|
||||
setting_val VARCHAR(255) NOT NULL,
|
||||
mode ENUM('light','dark') NOT NULL DEFAULT 'light',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY theme_scope_values_unique (scope_id, setting_key, mode),
|
||||
KEY theme_scope_values_scope_idx (scope_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Seed: create the global scope from existing website_settings theme data.
|
||||
INSERT IGNORE INTO theme_scopes (id, name, type, parent_id, is_active, sort_order)
|
||||
VALUES (1, 'Global', 'global', NULL, 1, 0);
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO `acl_permissions` (`slug`, `title`)
|
||||
VALUES ('housekeeping.preview.access', 'Access Housekeeping preview')
|
||||
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Admin panel", () => {
|
||||
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
|
||||
test("admin page has login form", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication flows", () => {
|
||||
test("login form validates required fields", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("login with invalid credentials shows error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="username"]', "nonexistent");
|
||||
await page.fill('input[name="password"]', "wrongpassword");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("register page has password confirmation field", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("register form validates password match", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.fill('input[name="password"]', "Password123!");
|
||||
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("homepage has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/Magic Hotel|Atom/i);
|
||||
});
|
||||
|
||||
test("register page loads", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page).toHaveTitle(/registreer|register|konto/i);
|
||||
});
|
||||
|
||||
test("login page loads", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveTitle(/inloggen|login/i);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Public navigation", () => {
|
||||
test("homepage loads with expected elements", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("nav")).toBeVisible();
|
||||
await expect(page.locator("footer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("community page loads", async ({ page }) => {
|
||||
await page.goto("/community");
|
||||
await expect(page).toHaveTitle(/community/i);
|
||||
});
|
||||
|
||||
test("shop page loads", async ({ page }) => {
|
||||
await page.goto("/shop");
|
||||
await expect(page.locator("h1, h2").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("404 page for unknown routes", async ({ page }) => {
|
||||
const response = await page.goto("/this-page-does-not-exist");
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'epic-next-app',
|
||||
script: 'node_modules/next/dist/bin/next',
|
||||
args: 'start',
|
||||
cwd: '/var/www/atom-nexst',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
node_args: '--max-old-space-size=1024',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3002
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { WebsiteSetting } from "@/db/schema";
|
||||
import { db } from "./src/lib/db";
|
||||
|
||||
async function main() {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(WebsiteSetting)
|
||||
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
|
||||
console.log("Current:", result);
|
||||
|
||||
if (result[0]?.value !== "/imaging") {
|
||||
await db
|
||||
.update(WebsiteSetting)
|
||||
.set({ value: "/imaging" })
|
||||
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
|
||||
console.log("Updated to /imaging");
|
||||
} else {
|
||||
console.log("Already correct");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,15 +5,13 @@
|
||||
"src/app/**/layout.{ts,tsx}",
|
||||
"src/app/**/route.{ts,tsx}",
|
||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||
"sentry.{server,edge}.config.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignoreDependencies": [
|
||||
"tailwindcss-animate",
|
||||
"@tailwindcss/forms",
|
||||
"@tailwindcss/typography",
|
||||
"pino-pretty",
|
||||
"tailwindcss"
|
||||
"scripts/migrate-aes-cbc-to-gcm.ts",
|
||||
"scripts/build-languages-full.ts",
|
||||
"scripts/translate-furnidata-full.ts",
|
||||
"scripts/align-db-ids-with-furnidata.ts",
|
||||
"scripts/furni-diagnose-now.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
||||
"ignoreDependencies": ["@sentry/nextjs", "pino-pretty", "husky"],
|
||||
"ignoreBinaries": ["sendmail"]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
url: ["http://localhost:3000"],
|
||||
numberOfRuns: 3,
|
||||
},
|
||||
assert: {
|
||||
preset: "lighthouse:no-pwa",
|
||||
assertions: {
|
||||
"categories:performance": ["error", { minScore: 0.9 }],
|
||||
"categories:accessibility": ["error", { minScore: 0.9 }],
|
||||
"categories:best-practices": ["error", { minScore: 0.9 }],
|
||||
"categories:seo": ["error", { minScore: 0.8 }],
|
||||
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
|
||||
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
|
||||
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+115
-50
@@ -1,8 +1,15 @@
|
||||
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const getGitCommit = () => {
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
{
|
||||
@@ -16,31 +23,95 @@ const securityHeaders = [
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
},
|
||||
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {},
|
||||
serverExternalPackages: [
|
||||
"@prisma/adapter-mariadb",
|
||||
"mariadb",
|
||||
"@prisma/client",
|
||||
"lzma",
|
||||
"sharp",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
],
|
||||
|
||||
// Enable React Compiler for automatic memoization
|
||||
reactCompiler: true,
|
||||
|
||||
// Compress responses with gzip
|
||||
output: "standalone",
|
||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
reactStrictMode: true,
|
||||
compress: true,
|
||||
|
||||
// Disable Next.js telemetry
|
||||
productionBrowserSourceMaps: false,
|
||||
serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"],
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/admin/import",
|
||||
destination: "/admin/studio/furni",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/badges",
|
||||
destination: "/admin/studio/badges",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/furni",
|
||||
destination: "/admin/studio/furni",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/furni/upload",
|
||||
destination: "/admin/studio/upload",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/clothing",
|
||||
destination: "/admin/studio/clothing",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/effects",
|
||||
destination: "/admin/studio/effects",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/pets",
|
||||
destination: "/admin/studio/pets",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/clone",
|
||||
destination: "/admin/studio/clone",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/sync",
|
||||
destination: "/admin/studio/sync",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/repair-icons",
|
||||
destination: "/admin/studio/repair-icons",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/import/audit",
|
||||
destination: "/admin/studio/audit",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
turbopack: {
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/src/lib/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
||||
useTypeScriptCli: true,
|
||||
hideLogsAfterAbort: true,
|
||||
},
|
||||
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
},
|
||||
|
||||
// Add caching headers for static assets
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -56,6 +127,28 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Nitro client payload (~2.8GB across swf/nitro-assets): without
|
||||
// caching every client open re-downloads hundreds of files.
|
||||
// Fresh for 7 days, then serve stale + revalidate in background
|
||||
// so asset updates still propagate without blocking players.
|
||||
source: "/swf/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=604800, stale-while-revalidate=2592000",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/nitro-assets/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=604800, stale-while-revalidate=2592000",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/images/(.*)",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
|
||||
@@ -64,34 +157,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via
|
||||
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const config = withNextIntl(nextConfig);
|
||||
|
||||
// Source-map upload + release creation need SENTRY_AUTH_TOKEN.
|
||||
// Without it, keep the SDK wrapper but skip remote Sentry build steps
|
||||
// so CI/prod compile stays quiet (runtime DSN still works independently).
|
||||
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
|
||||
const withBA = withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
|
||||
export default withBA(
|
||||
withSentryConfig(config, {
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
silent: !process.env.CI || !sentryAuthToken,
|
||||
widenClientFileUpload: true,
|
||||
sourcemaps: {
|
||||
disable: !sentryAuthToken,
|
||||
},
|
||||
release: {
|
||||
create: Boolean(sentryAuthToken),
|
||||
},
|
||||
telemetry: false,
|
||||
}),
|
||||
);
|
||||
export default withNextIntl(nextConfig);
|
||||
+70
-100
@@ -3,119 +3,89 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=26.8.1 <27"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"prisma:generate": "prisma generate",
|
||||
"typecheck": "tsc6 --noEmit --incremental false",
|
||||
"biome:check": "biome check --write .",
|
||||
"biome:lint": "biome lint .",
|
||||
"biome:format": "biome format --write .",
|
||||
"knip": "knip",
|
||||
"analyze": "ANALYZE=true pnpm build",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
"biome:lint": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
||||
"lhci:collect": "lhci collect",
|
||||
"lhci:assert": "lhci assert",
|
||||
"lhci:server": "lhci server",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"prepare": "husky"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write",
|
||||
"eslint --fix --max-warnings=50"
|
||||
],
|
||||
"*.{json,md,css,scss,html}": [
|
||||
"biome format --write"
|
||||
]
|
||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@sentry/nextjs": "^10.68.0",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"croner": "^10.0.1",
|
||||
"framer-motion": "^12.42.2",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lenis": "^1.3.25",
|
||||
"lucide-react": "^1.27.0",
|
||||
"lzma": "^2.3.2",
|
||||
"music-metadata": "^11.14.0",
|
||||
"mysql2": "^3.23.2",
|
||||
"next": "^16.2.12",
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"croner": "10.0.1",
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.1",
|
||||
"lenis": "1.3.26",
|
||||
"lucide-react": "1.38.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.1.1",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.24.2",
|
||||
"next": "16.3.3",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "^4.13.4",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"otplib": "^13.4.1",
|
||||
"pino": "^10.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"resend": "^6.18.1",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3"
|
||||
"next-intl": "4.14.1",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.87.0",
|
||||
"resend": "6.25.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/eslint-plugin-next": "^16.2.12",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-security": "^4.0.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^6.29.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.24",
|
||||
"prisma": "^7.9.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
"@biomejs/biome": "2.5.11",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"husky": "9.1.7",
|
||||
"knip": "6.33.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Generated
+1540
-7271
File diff suppressed because it is too large.
Load diff
+60
-15
@@ -1,33 +1,78 @@
|
||||
# pnpm-workspace.yaml
|
||||
|
||||
# pnpm v11 vervanger voor onlyBuiltDependencies
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
prisma: true
|
||||
"@prisma/client": true
|
||||
"@prisma/engines": true
|
||||
sharp: true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
"@sentry/cli": true
|
||||
bcrypt: true
|
||||
|
||||
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
|
||||
minimumReleaseAgeExclude:
|
||||
- "@base-ui/[email protected]"
|
||||
- "@base-ui/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@hookform/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
|
||||
overrides:
|
||||
glob: "^10.4.5"
|
||||
inflight: "npm:lru-cache@^10.2.2"
|
||||
rimraf: "^5.0.7"
|
||||
uuid: "^9.0.1"
|
||||
glob: "^11.0.3"
|
||||
rimraf: "^6.0.1"
|
||||
uuid: "^11.1.0"
|
||||
fast-uri: "^3.1.3"
|
||||
"@hono/node-server": "^1.19.13"
|
||||
postcss: "^8.5.19"
|
||||
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
||||
postcss: "^8.5.26"
|
||||
tmp: "^0.2.6"
|
||||
# NIEUWE SECURITY PATCHES:
|
||||
sharp: "^0.35.3"
|
||||
sharp: "^0.35.4"
|
||||
brace-expansion: "^5.0.8"
|
||||
"@types/react": "19.2.18"
|
||||
"@types/react-dom": "19.2.5"
|
||||
# Tijdelijke mitigatie voor drizzle-kit audit
|
||||
esbuild: "^0.25.9"
|
||||
|
||||
allowedDeprecatedVersions:
|
||||
"@esbuild-kit/esm-loader": "*"
|
||||
"@esbuild-kit/core-utils": "*"
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- "@prisma/engines"
|
||||
- "prisma"
|
||||
|
||||
# Zorgt voor een schone terminal zonder de Next-Auth / Nodemailer waarschuwing
|
||||
peerDependencyRules:
|
||||
allowedVersions:
|
||||
nodemailer: "9.0.3"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
// Prisma 7 config. The datasource URL lives here (not in schema.prisma).
|
||||
// We NEVER run `prisma migrate`/`db push` against this database — it is shared
|
||||
// live with the Arcturus emulator. CMS-only schema changes go in
|
||||
// prisma/migrations/*.sql (idempotent) applied via `pnpm db:migrate`.
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,402 @@
|
||||
{
|
||||
"badge_name_Z75": "Training camp!",
|
||||
"badge_desc_Z75": "Per esser diventato un guerriero DOC!",
|
||||
"badge_name_Z67": "Coniglio Scaccia-Mito",
|
||||
"badge_desc_Z67": "La Sicurezza in Habbo non è una leggenda!",
|
||||
"badge_name_Z64": "Topo da Laboratorio",
|
||||
"badge_desc_Z64": "Beta Tester II",
|
||||
"badge_name_Z63": "Topo da Laboratorio",
|
||||
"badge_desc_Z63": "Beta Tester I",
|
||||
"badge_name_Z39": "Hotel Bau",
|
||||
"badge_desc_Z39": "Tutti a casa finalmente!",
|
||||
"badge_name_Z38": "Direttore Hotel Bau",
|
||||
"badge_desc_Z38": "Hotel a ***3 stelle",
|
||||
"badge_name_Z37": "Direttore Hotel Bau",
|
||||
"badge_desc_Z37": "Hotel a **2 stelle",
|
||||
"badge_name_Z36": "Direttore Hotel Bau",
|
||||
"badge_desc_Z36": "Hotel a *1 stella",
|
||||
"badge_name_Z35": "Hotel Bau",
|
||||
"badge_desc_Z35": "Per aver ritrovato i cuccioli",
|
||||
"badge_name_Z26": "Futuro Re",
|
||||
"badge_desc_Z26": "Sarai tu il prossimo Re?",
|
||||
"badge_name_Z16": "Campagna di Sicurezza Gennaio 2017",
|
||||
"badge_desc_Z16": "Per essere un esperto nel proteggere il mio account!",
|
||||
"badge_name_Z09": "In Campeggio",
|
||||
"badge_desc_Z09": "Campagna Campeggio con i Lupetti - 2009",
|
||||
"badge_name_Z05": "Ex Habbo eXpert",
|
||||
"badge_desc_Z05": "Gli Habbo X erano guide volontarie scelte dello Staff tra il 2006 e il 2008.",
|
||||
"badge_name_Z02": "W la Terra",
|
||||
"badge_desc_Z02": "Per i veri amanti della natura!",
|
||||
"badge_name_Z01": "Wow or not?",
|
||||
"badge_desc_Z01": "Hai impressionato la giuria con la tua sfilata!",
|
||||
"badge_name_YAK": "x",
|
||||
"badge_desc_YAK": "x",
|
||||
"badge_name_XXX": "x",
|
||||
"badge_desc_XXX": "x",
|
||||
"badge_name_XRLTD": "Distintivo Palla di Vetro con Neve",
|
||||
"badge_desc_XRLTD": "Per avere acquistato questo Raro LTD di Natale",
|
||||
"badge_name_XMSR1": "Macchina Magica",
|
||||
"badge_desc_XMSR1": "Per comprare una Macchina Rara Magica",
|
||||
"badge_name_XMS14": "Gnomi all'avventura!",
|
||||
"badge_desc_XMS14": "Per aver vinto la Competizione Video!",
|
||||
"badge_name_XMS13": "Cin cin!",
|
||||
"badge_desc_XMS13": "Attento a non versartelo addosso",
|
||||
"badge_name_XMS12": "Organizzatore di Feste",
|
||||
"badge_desc_XMS12": "... da urlo!",
|
||||
"badge_name_XMS10": "Postazione di Controllo",
|
||||
"badge_desc_XMS10": "Per comprare una Postazione di Controllo Rara",
|
||||
"badge_name_XMS07": "Babbo Natale e i 3 Re Magi",
|
||||
"badge_desc_XMS07": "Babbo Natale ha molte conoscenze...",
|
||||
"badge_name_XMS06": "Natale in Europa",
|
||||
"badge_desc_XMS06": "Un Natale dei più tipici!",
|
||||
"badge_name_XMS05": "Natale sulla Spiaggia",
|
||||
"badge_desc_XMS05": "Festeggia come un Brasiliano!",
|
||||
"badge_name_XMS04": "Natale senza Natale",
|
||||
"badge_desc_XMS04": "Festeggia come in Turchia",
|
||||
"badge_name_XMS03": "Decoratore di Alberi Natalizi",
|
||||
"badge_desc_XMS03": "Per avere decorato l'Albero di Natale Habbo 2013",
|
||||
"badge_name_XMS01": "Bottega di Babbo Natale",
|
||||
"badge_desc_XMS01": "Per avere acquistato l'Affare Stanza natalizio",
|
||||
"badge_name_XMH20": "Pupazzo Gatto Scheletrico",
|
||||
"badge_desc_XMH20": "Per avere acquistato il Pupazzo Gatto Scheletrico",
|
||||
"badge_name_XMH19": "Pupazzo Tristo Mietitore",
|
||||
"badge_desc_XMH19": "Per avere acquistato il Pupazzo Tristo Mietitore",
|
||||
"badge_name_XMH18": "Pupazzo Punk Rock",
|
||||
"badge_desc_XMH18": "Per avere acquistato il Pupazzo Punk Rock",
|
||||
"badge_name_XMH17": "Pupazzo Ragazza Habbo",
|
||||
"badge_desc_XMH17": "Per avere acquistato il Pupazzo Ragazza Habbo",
|
||||
"badge_name_XMH16": "Pupazzo Lucha Libre",
|
||||
"badge_desc_XMH16": "Per avere acquistato il Pupazzo Lucha Libre",
|
||||
"badge_name_XMH15": "Pupazzo Scheletro Tatuato",
|
||||
"badge_desc_XMH15": "Per avere acquistato il Pupazzo Scheletro Tatuato",
|
||||
"badge_name_XMH14": "Pupazzo Ragazzo Habbo",
|
||||
"badge_desc_XMH14": "Per avere acquistato il Pupazzo Ragazzo Habbo",
|
||||
"badge_name_XMH13": "Pupazzo in Giacca e Cravatta",
|
||||
"badge_desc_XMH13": "Per avere acquistato il Pupazzo in Giacca e Cravatta",
|
||||
"badge_name_XMH12": "Pupazzo Catrina",
|
||||
"badge_desc_XMH12": "Per avere acquistato il Pupazzo Catrina",
|
||||
"badge_name_XMH11": "Pupazzo Mariachi",
|
||||
"badge_desc_XMH11": "Per avere acquistato il Pupazzo Mariachi",
|
||||
"badge_name_XMB": "Mr.Pupazzo",
|
||||
"badge_desc_XMB": "Con cilindro e carota è il signore delle nevi!",
|
||||
"badge_name_XmasRoom_Top100": "Top 100 Castello di Natale",
|
||||
"badge_desc_XmasRoom_Top100": "Per essere rientrat@ nella top 100 della Competizione Stanza Castello di Natale",
|
||||
"badge_name_XmasRoom_Top10": "Top 10 Castello di Natale",
|
||||
"badge_desc_XmasRoom_Top10": "Per essere rientrat@ nella top 10 della Competizione Stanza Castello di Natale",
|
||||
"badge_name_XmasRoom": "Partecipante Castello di Natale",
|
||||
"badge_desc_XmasRoom": "Per aver partecipato alla Competizione Stanza Castello di Natale",
|
||||
"badge_name_XMAS2": "Ninnolo",
|
||||
"badge_desc_XMAS2": "Livello III",
|
||||
"badge_name_XMAS1": "Ninnolo",
|
||||
"badge_desc_XMAS1": "Livello II",
|
||||
"badge_name_XMAS0": "Ninnolo",
|
||||
"badge_desc_XMAS0": "Livello I",
|
||||
"badge_name_XMA": "Smile Congelato",
|
||||
"badge_desc_XMA": "Il suo sorriso ti scalda il cuore",
|
||||
"badge_name_XM9": "Palla di Neve",
|
||||
"badge_desc_XM9": "Deve ancora fare parecchia strada per diventare un pupazzo!",
|
||||
"badge_name_XM8": "Boccale di Birra",
|
||||
"badge_desc_XM8": "Habbo Natale 2007",
|
||||
"badge_name_XM7": "Moneta",
|
||||
"badge_desc_XM7": "Habbo Natale 2007",
|
||||
"badge_name_XM6": "Robot di Santa 3000",
|
||||
"badge_desc_XM6": "Habbo Natale 2007",
|
||||
"badge_name_XM5": "Fiocco di Neve",
|
||||
"badge_desc_XM5": "Habbo Natale 2007",
|
||||
"badge_name_XM4": "Natale 2006",
|
||||
"badge_desc_XM4": "Ero su Habbo a Natale del 2006!",
|
||||
"badge_name_XM3": "Renna",
|
||||
"badge_desc_XM3": "Habbo Natale 2005",
|
||||
"badge_name_XM2": "DJ-Bling",
|
||||
"badge_desc_XM2": "Habbo Natale 2005",
|
||||
"badge_name_XM10E": "FREEZE!",
|
||||
"badge_desc_XM10E": "Hai costruito un'Arena Fantastica!",
|
||||
"badge_name_XM10D": "Campione Wired-Freeze",
|
||||
"badge_desc_XM10D": "L'ennesima potenza del divertimento!",
|
||||
"badge_name_XM10C": "Regalo Natalizio Fuori Stagione",
|
||||
"badge_desc_XM10C": "Saluti dai Caraibi!",
|
||||
"badge_name_XM10B": "Pattino d'Oro",
|
||||
"badge_desc_XM10B": "Il Miglior Palazzetto del Ghiaccio",
|
||||
"badge_name_XM10A": "Città Natalizia!",
|
||||
"badge_desc_XM10A": "Per gli addobbi Natalizi più Originali!",
|
||||
"badge_name_XM1": "Rasta Santa",
|
||||
"badge_desc_XM1": "Habbo Natale 2005 e 2006",
|
||||
"badge_name_XGH1": "Il Fantasma del Natale",
|
||||
"badge_desc_XGH1": "Ho trovato il Fantasma del Natale",
|
||||
"badge_name_X535": "Conduttore di slitta - Sig.ra Claus",
|
||||
"badge_desc_X535": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
|
||||
"badge_name_X534": "Conduttore di slitta - Habbo Natale",
|
||||
"badge_desc_X534": "Oh, che bello andare su una slitta trainata da un cavallo..ehi!",
|
||||
"badge_name_X533": "Pranzo di Natale - Sig.ra Claus",
|
||||
"badge_desc_X533": "Non c'è di niente di meglio di un bel Pranzo di Natale",
|
||||
"badge_name_X532": "Pranzo di Natale - Habbo Natale",
|
||||
"badge_desc_X532": "Non c'è di niente di meglio di un bel Pranzo di Natale",
|
||||
"badge_name_X531": "Silent night - Sig.ra Claus",
|
||||
"badge_desc_X531": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come la Sig.ra Claus",
|
||||
"badge_name_X530": "Silent night - Habbo Natale",
|
||||
"badge_desc_X530": "Non hai svegliato nessun Habbo, hai sfiorato la perfezione come Habbo Natale",
|
||||
"badge_name_X529": "Preparazione Regali di Natale - Sig.ra Claus",
|
||||
"badge_desc_X529": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
|
||||
"badge_name_X528": "Preparazione Regali di Natale - Habbo Natale",
|
||||
"badge_desc_X528": "Ti sei assicurato che quest'anno tutti ricevano il giusto regalo di Natale",
|
||||
"badge_name_X527": "Mercato di Natale - Sig.ra Claus",
|
||||
"badge_desc_X527": "Per aver completato il labirinto del mercato di Natale",
|
||||
"badge_name_X526": "Mercato di Natale - Habbo Natale",
|
||||
"badge_desc_X526": "Per aver completato il labirinto del mercato di Natale",
|
||||
"badge_name_X525": "Proprietario di Renna - Sig.ra Claus",
|
||||
"badge_desc_X525": "Vixen, la renna della Sig.ra Claus, è stata riportata sana e salva nella stalla",
|
||||
"badge_name_X524": "Proprietario di Renna - Habbo Natale",
|
||||
"badge_desc_X524": "Dasher, la renna preferita di Habbo Natale, è stata riportata sana e salva nella stalla",
|
||||
"badge_name_X523": "Film di Natale - Sig.ra Claus",
|
||||
"badge_desc_X523": "Sei un vero Fan dei film di Natale",
|
||||
"badge_name_X522": "Film di Natale - Habbo Natale",
|
||||
"badge_desc_X522": "Sei un vero Fan dei film di Natale",
|
||||
"badge_name_X521": "Meraviglioso Albero di Natale - Sig.ra Natale",
|
||||
"badge_desc_X521": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
|
||||
"badge_name_X520": "Meraviglioso Albero di Natale - Habbo Natale",
|
||||
"badge_desc_X520": "Oh Albero di Natale, Oh Albero di Natale, Le tue foglie son così immutabili",
|
||||
"badge_name_X519": "Canti Natalizi - Sig.ra Claus",
|
||||
"badge_desc_X519": "Jingle bell, jingle bell, jingle bell rock",
|
||||
"badge_name_X518": "Canti Natalizi - Habbo Natale",
|
||||
"badge_desc_X518": "Haaaabbo Natale sta arrivando in città!",
|
||||
"badge_name_X2535": "L'Alimentatore Preferito di Esophagor",
|
||||
"badge_desc_X2535": "",
|
||||
"badge_name_X2534": "Stazione dei Treni",
|
||||
"badge_desc_X2534": "",
|
||||
"badge_name_X2533": "Collezione Paese delle Meraviglie di Cacao",
|
||||
"badge_desc_X2533": "",
|
||||
"badge_name_X2532": "Affare Stanza Ufficio di Habbo Natale",
|
||||
"badge_desc_X2532": "",
|
||||
"badge_name_X2531": "Affare Stanza Natale Accogliente di Habbo",
|
||||
"badge_desc_X2531": "",
|
||||
"badge_name_X2530": "Uovo Glassato LTD",
|
||||
"badge_desc_X2530": "",
|
||||
"badge_name_X2529": "Borsa Kitty Rara",
|
||||
"badge_desc_X2529": "",
|
||||
"badge_name_X2528": "Cuscino di Ghiaccio Raro",
|
||||
"badge_desc_X2528": "",
|
||||
"badge_name_X2527": "Habbo Night Hotel Raro",
|
||||
"badge_desc_X2527": "",
|
||||
"badge_name_X2526": "Coda di cavallo Ghiacciata Scintillante Rara",
|
||||
"badge_desc_X2526": "",
|
||||
"badge_name_X2525": "Buon Natale 2025!",
|
||||
"badge_desc_X2525": "",
|
||||
"badge_name_X2521": "A caccia di vacanze!",
|
||||
"badge_desc_X2521": "",
|
||||
"badge_name_X2520": "Alimentatore di Esophagor",
|
||||
"badge_desc_X2520": "",
|
||||
"badge_name_X2518": "Battaglia con Palle di neve!",
|
||||
"badge_desc_X2518": "",
|
||||
"badge_name_X2516": "Spirito delle Feste",
|
||||
"badge_desc_X2516": "",
|
||||
"badge_name_X2515": "Straordinario Creatore delle Feste",
|
||||
"badge_desc_X2515": "",
|
||||
"badge_name_X2514": "Felice & Moderno",
|
||||
"badge_desc_X2514": "",
|
||||
"badge_name_X2513": "Eroe delle Tradizioni Natalizie",
|
||||
"badge_desc_X2513": "",
|
||||
"badge_name_X2512": "Corona dello Scontro di Natale",
|
||||
"badge_desc_X2512": "",
|
||||
"badge_name_X2511": "Habubu Glam",
|
||||
"badge_desc_X2511": "",
|
||||
"badge_name_X2510": "Habubu Cozy",
|
||||
"badge_desc_X2510": "",
|
||||
"badge_name_X2509": "Habubu Dream",
|
||||
"badge_desc_X2509": "",
|
||||
"badge_name_X2508": "Habubu Calm",
|
||||
"badge_desc_X2508": "",
|
||||
"badge_name_X2507": "Habubu Luck",
|
||||
"badge_desc_X2507": "",
|
||||
"badge_name_X2506": "Habubu Hope",
|
||||
"badge_desc_X2506": "",
|
||||
"badge_name_X2505": "Habubu Happy",
|
||||
"badge_desc_X2505": "",
|
||||
"badge_name_X2504": "Habubu Fun",
|
||||
"badge_desc_X2504": "",
|
||||
"badge_name_X2503": "Habubu Love",
|
||||
"badge_desc_X2503": "",
|
||||
"badge_name_X2502": "Squadra - DJ Bling",
|
||||
"badge_desc_X2502": "",
|
||||
"badge_name_X2501": "Squadra - Rasta Santa",
|
||||
"badge_desc_X2501": "",
|
||||
"badge_name_X2330": "Competizione Anatroccolo",
|
||||
"badge_desc_X2330": "",
|
||||
"badge_name_X2329": "Competizione Stanza Sweet Suite NL, TR",
|
||||
"badge_desc_X2329": "",
|
||||
"badge_name_X2328": "Offerta Crafting",
|
||||
"badge_desc_X2328": "",
|
||||
"badge_name_X2327": "Trono Bianco",
|
||||
"badge_desc_X2327": "",
|
||||
"badge_name_X2326": "Buon 2024!",
|
||||
"badge_desc_X2326": "",
|
||||
"badge_name_X2325": "Buon Natale!",
|
||||
"badge_desc_X2325": "",
|
||||
"badge_name_X2324": "Goditi il Natale Habbo",
|
||||
"badge_desc_X2324": "",
|
||||
"badge_name_X2323": "Aiuta Frank Wobbah a cucinare",
|
||||
"badge_desc_X2323": "",
|
||||
"badge_name_X2322": "Salvataggio delle ricette di Frank Wobbah",
|
||||
"badge_desc_X2322": "",
|
||||
"badge_name_X2321": "Missione Babbo Natale per un giorno",
|
||||
"badge_desc_X2321": "",
|
||||
"badge_name_X2320": "Missione Brilla come un Biglietto d'oro",
|
||||
"badge_desc_X2320": "",
|
||||
"badge_name_X2319": "Missione Delizie Arcobaleno",
|
||||
"badge_desc_X2319": "",
|
||||
"badge_name_X2318": "Missione Frutti di Bosco",
|
||||
"badge_desc_X2318": "",
|
||||
"badge_name_X2317": "Missione Mentine Rinfrescanti",
|
||||
"badge_desc_X2317": "",
|
||||
"badge_name_X2316": "Pazzo per la Missione del Cioccolato",
|
||||
"badge_desc_X2316": "",
|
||||
"badge_name_X2315": "Torta Trono Rara",
|
||||
"badge_desc_X2315": "",
|
||||
"badge_name_X2314": "Offerta di Capodanno 2023",
|
||||
"badge_desc_X2314": "",
|
||||
"badge_name_X2313": "Offerta Indumenti Natale 2023",
|
||||
"badge_desc_X2313": "",
|
||||
"badge_name_X2312": "Carosello di Cioccolato Artigianale LTD",
|
||||
"badge_desc_X2312": "",
|
||||
"badge_name_X2311": "Corona d'Oro 24K LTD",
|
||||
"badge_desc_X2311": "",
|
||||
"badge_name_X2310": "Ali sulla Testa Mitiche Rare",
|
||||
"badge_desc_X2310": "",
|
||||
"badge_name_X2309": "Seduta Aerea Rara",
|
||||
"badge_desc_X2309": "",
|
||||
"badge_name_X2308": "Nastro Scartami RARO",
|
||||
"badge_desc_X2308": "",
|
||||
"badge_name_X2307": "Albero di Zucchero Filato RARO",
|
||||
"badge_desc_X2307": "",
|
||||
"badge_name_X2306": "Affare Stanza di Capodanno",
|
||||
"badge_desc_X2306": "",
|
||||
"badge_name_X2305": "Affare Stanza Pista di Pattinaggio sul Ghiaccio",
|
||||
"badge_desc_X2305": "",
|
||||
"badge_name_X2304": "Affare Stanza Ritrovo dell'Elfo",
|
||||
"badge_desc_X2304": "",
|
||||
"badge_name_X2303": "Bottega Moderna di Babbo Natale",
|
||||
"badge_desc_X2303": "",
|
||||
"badge_name_X2302": "Emporio del Cioccolato Magico",
|
||||
"badge_desc_X2302": "",
|
||||
"badge_name_X2301": "Collezione Paese delle Meraviglie di Cacao",
|
||||
"badge_desc_X2301": "",
|
||||
"badge_name_X2234": "Sanrio Christmas Room Competition",
|
||||
"badge_desc_X2234": "",
|
||||
"badge_name_X2232": "Affare Stanza Salone da Pranzo",
|
||||
"badge_desc_X2232": "",
|
||||
"badge_name_X2231": "Offerta Bevande per soldi Reali",
|
||||
"badge_desc_X2231": "",
|
||||
"badge_name_X2230": "Renna Abile",
|
||||
"badge_desc_X2230": "",
|
||||
"badge_name_X2229": "Pinguino Abile",
|
||||
"badge_desc_X2229": "",
|
||||
"badge_name_X2228": "Labirinto dei Pinguini: Livello Facile - Completato",
|
||||
"badge_desc_X2228": "",
|
||||
"badge_name_X2227": "Labirinto dei Pinguini: Livello Medio - Completato",
|
||||
"badge_desc_X2227": "",
|
||||
"badge_name_X2226": "Labirinto dei Pinguini: Livello Folle - Completato",
|
||||
"badge_desc_X2226": "",
|
||||
"badge_name_X2224": "Natale Habbo 2022 - Pinguino di Tokyo",
|
||||
"badge_desc_X2224": "",
|
||||
"badge_name_X2223": "Natale Habbo 2022 - Pinguino Timido",
|
||||
"badge_desc_X2223": "",
|
||||
"badge_name_X2222": "Natale Habbo 2022 - Pinguino Accademico",
|
||||
"badge_desc_X2222": "",
|
||||
"badge_name_X2220": "Natale Habbo 2022 - Pinguino Unicorno",
|
||||
"badge_desc_X2220": "",
|
||||
"badge_name_X2219": "Natale Habbo 2022 - Pinguino Albino",
|
||||
"badge_desc_X2219": "",
|
||||
"badge_name_X2218": "Natale Habbo 2022 - Pinguino Fantasma",
|
||||
"badge_desc_X2218": "",
|
||||
"badge_name_X2217": "Natale Habbo 2022 - Pinguino Egizio",
|
||||
"badge_desc_X2217": "",
|
||||
"badge_name_X2216": "Natale Habbo 2022 - Pinguino Artista",
|
||||
"badge_desc_X2216": "",
|
||||
"badge_name_X2215": "Natale Habbo 2022 - Pinguino Addormentato",
|
||||
"badge_desc_X2215": "",
|
||||
"badge_name_X2214": "Natale Habbo 2022 - Pinguino Testa a Molla",
|
||||
"badge_desc_X2214": "",
|
||||
"badge_name_X2213": "Offerta Cibo Soldi Veri",
|
||||
"badge_desc_X2213": "",
|
||||
"badge_name_X2212": "Uovo Habberge Palla di Neve LTD",
|
||||
"badge_desc_X2212": "",
|
||||
"badge_name_X2211": "Ali d'Angelo LTD",
|
||||
"badge_desc_X2211": "",
|
||||
"badge_name_X2210": "RARO Husky Addestrato",
|
||||
"badge_desc_X2210": "",
|
||||
"badge_name_X2209": "Raro Sauna Nordica",
|
||||
"badge_desc_X2209": "",
|
||||
"badge_name_X2208": "RARO Capelli con Treccine",
|
||||
"badge_desc_X2208": "",
|
||||
"badge_name_X2207": "Raro Look da Albero Festivo",
|
||||
"badge_desc_X2207": "",
|
||||
"badge_name_X2206": "Affare Stanza Sauna Finlandese",
|
||||
"badge_desc_X2206": "",
|
||||
"badge_name_X2205": "Affare Stanza Snowboard sulle Alpi in Inverno",
|
||||
"badge_desc_X2205": "",
|
||||
"badge_name_X2204": "Affare Stanza Natale Bavarese",
|
||||
"badge_desc_X2204": "",
|
||||
"badge_name_X2203": "Affare Stanza Inverno ad Habbo Stonehenge",
|
||||
"badge_desc_X2203": "",
|
||||
"badge_name_X2202": "Affare Stanza Baita del Resort Invernale",
|
||||
"badge_desc_X2202": "",
|
||||
"badge_name_X2201": "Affare Stanza Resort degli Sport Invernali",
|
||||
"badge_desc_X2201": "",
|
||||
"badge_name_X2139": "Buon Natale!",
|
||||
"badge_desc_X2139": "",
|
||||
"badge_name_X2138": "Sulla lista dei buoni di Babbo Natale!",
|
||||
"badge_desc_X2138": "",
|
||||
"badge_name_X2137": "La Squadra di Babbo Natale",
|
||||
"badge_desc_X2137": "",
|
||||
"badge_name_X2136": "Chi è il vero Babbo Natale?",
|
||||
"badge_desc_X2136": "",
|
||||
"badge_name_X2135": "Il Coro Natalizio di Habbo",
|
||||
"badge_desc_X2135": "",
|
||||
"badge_name_X2134": "Battaglia di Palle di Neve!",
|
||||
"badge_desc_X2134": "",
|
||||
"badge_name_X2133": "Oh Albero di Natale",
|
||||
"badge_desc_X2133": "",
|
||||
"badge_name_X2132": "Si prepara una Tempesta!",
|
||||
"badge_desc_X2132": "",
|
||||
"badge_name_X2131": "Pattinando sul Ghiaccio Sottile",
|
||||
"badge_desc_X2131": "",
|
||||
"badge_name_X2130": "Piante Malridotte dall'Inverno",
|
||||
"badge_desc_X2130": "",
|
||||
"badge_name_X2129": "Nobile Uccello d'Oro",
|
||||
"badge_desc_X2129": "",
|
||||
"badge_name_X2128": "Caso Risolto!",
|
||||
"badge_desc_X2128": "",
|
||||
"badge_name_X2127": "Passi nella neve",
|
||||
"badge_desc_X2127": "",
|
||||
"badge_name_X2126": "La Grande Fuga",
|
||||
"badge_desc_X2126": "",
|
||||
"badge_name_X2125": "Il Sospetto Sbagliato",
|
||||
"badge_desc_X2125": "",
|
||||
"badge_name_X2124": "Scappato appena in tempo!",
|
||||
"badge_desc_X2124": "",
|
||||
"badge_name_X2123": "Identità Segreta",
|
||||
"badge_desc_X2123": "",
|
||||
"badge_name_X2122": "Una Mappa Segreta",
|
||||
"badge_desc_X2122": "",
|
||||
"badge_name_X2121": "Zzzz..Zzzz..",
|
||||
"badge_desc_X2121": "",
|
||||
"badge_name_X2120": "Si è intrufolato nella stanza del commesso viaggiatore",
|
||||
"badge_desc_X2120": "",
|
||||
"badge_name_X2119": "Un Look strepitoso!",
|
||||
"badge_desc_X2119": "",
|
||||
"badge_name_X2118": "Chiavi prestate",
|
||||
"badge_desc_X2118": "",
|
||||
"badge_name_X2117": "Il Treno sta arrivando",
|
||||
"badge_desc_X2117": "",
|
||||
"badge_name_X2116": "Bandito del Treno",
|
||||
"badge_desc_X2116": "",
|
||||
"badge_name_X2115": "Il Treno Habbo Express",
|
||||
"badge_desc_X2115": "",
|
||||
"badge_name_X2114": "Uovo Habberge Art Deco LTD",
|
||||
"badge_desc_X2114": "",
|
||||
"badge_name_X2113": "Locomotiva a Vapore Rara",
|
||||
"badge_desc_X2113": "",
|
||||
"badge_name_X2112": "Costume da Treno Raro",
|
||||
"badge_desc_X2112": "",
|
||||
"badge_name_X2111": "Macchina della Cioccolata Calda di Lusso Rara",
|
||||
"badge_desc_X2111": ""
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,365 @@
|
||||
import "./load-env";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { readFurniData, writeFurniData } from "@/lib/services/furni-data";
|
||||
|
||||
/**
|
||||
* Make FurnitureData.json the single source of truth for items_base ids.
|
||||
*
|
||||
* 1. Duplicate ids inside furnidata (same id claimed by several classnames)
|
||||
* are resolved globally and iteratively: the claimant matching the current
|
||||
* DB occupant keeps the id; losing entries are patched in the file back to
|
||||
* their own classname's DB id (and lose their claim).
|
||||
* 2. Every items_base row is then moved (pass-based PK swap, cascading into
|
||||
* every referencing table) to its furnidata id. Moves whose target is
|
||||
* still occupied wait for a later pass; cycles break via scratch ids;
|
||||
* rows squatting on a contested id without any furnidata entry are
|
||||
* displaced to fresh ids past the global maximum.
|
||||
* 3. sprite_id = id and catalog_name = item_name afterwards.
|
||||
*
|
||||
* Run with --apply to write; without it, only reports.
|
||||
*/
|
||||
const APPLY = process.argv.includes("--apply");
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
classname: string;
|
||||
offerid?: unknown;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype?: Entry[] }
|
||||
>;
|
||||
|
||||
const [rows] = (await db.execute(
|
||||
sql`SELECT id, item_name FROM items_base`,
|
||||
)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
|
||||
console.log(`[align] items_base rows: ${rows.length}`);
|
||||
const nameById = new Map<number, string>();
|
||||
const idByName = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
nameById.set(r.id, r.item_name);
|
||||
idByName.set(r.item_name, r.id);
|
||||
}
|
||||
|
||||
const allEntries: Array<{ section: string; e: Entry }> = [];
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
for (const e of furniData[section]?.furnitype ?? []) {
|
||||
if (typeof e?.classname === "string" && Number.isFinite(Number(e?.id))) {
|
||||
allEntries.push({ section, e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── global iterative duplicate-id resolution ────────────────────
|
||||
const desired = new Map<string, number>(); // classname -> furnidata id claim
|
||||
for (const { e } of allEntries) {
|
||||
const cn = e.classname;
|
||||
const id = Number(e.id);
|
||||
if (!cn || !Number.isFinite(id) || id <= 0 || desired.has(cn)) continue;
|
||||
desired.set(cn, id);
|
||||
}
|
||||
console.log(`[align] furnidata classnames: ${desired.size}`);
|
||||
|
||||
const losers: Array<{ cn: string; keepId: number }> = [];
|
||||
for (;;) {
|
||||
const claimsById = new Map<number, string[]>();
|
||||
for (const [cn, id] of desired) {
|
||||
const arr = claimsById.get(id) ?? [];
|
||||
arr.push(cn);
|
||||
claimsById.set(id, arr);
|
||||
}
|
||||
let found = false;
|
||||
for (const [id, claimants] of [...claimsById].sort((a, b) => a[0] - b[0])) {
|
||||
if (claimants.length < 2) continue;
|
||||
const live = claimants.filter((cn) => desired.get(cn) === id);
|
||||
if (live.length < 2) continue;
|
||||
found = true;
|
||||
const dbHolder = nameById.get(id);
|
||||
const winner =
|
||||
dbHolder !== undefined && live.includes(dbHolder) ? dbHolder : live[0];
|
||||
for (const cn of live) {
|
||||
if (cn === winner) continue;
|
||||
const keep = idByName.get(cn);
|
||||
if (keep !== undefined && keep !== id)
|
||||
losers.push({ cn, keepId: keep });
|
||||
desired.delete(cn); // loser loses its claim entirely
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
console.log(
|
||||
`[align] duplicate-id losers (patched to their own DB id): ${losers.length}`,
|
||||
);
|
||||
|
||||
// ── compute moves ───────────────────────────────────────────────
|
||||
let fresh =
|
||||
Math.max(
|
||||
rows.reduce((m, r) => Math.max(m, r.id), 0),
|
||||
[...desired.values()].reduce((m, v) => Math.max(m, v), 0),
|
||||
losers.reduce((m, l) => Math.max(m, l.keepId), 0),
|
||||
) + 1000;
|
||||
|
||||
const moves = new Map<number, number>(); // old_id -> new_id
|
||||
for (const r of rows) {
|
||||
const target = desired.get(r.item_name);
|
||||
if (target !== undefined && target !== r.id) moves.set(r.id, target);
|
||||
}
|
||||
// squatter displacement: a row occupying a contested id with no furnidata
|
||||
// entry of its own must make room
|
||||
for (;;) {
|
||||
const destCount = new Map<number, number>();
|
||||
for (const [, t] of moves) destCount.set(t, (destCount.get(t) ?? 0) + 1);
|
||||
let changed = false;
|
||||
for (const [, target] of moves) {
|
||||
const holder = nameById.get(target);
|
||||
if (holder === undefined) continue; // free
|
||||
if (moves.has(target)) continue; // vacated by its own move
|
||||
if (desired.get(holder) === target) continue; // legit stayer
|
||||
moves.set(target, fresh++);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
// drop moves onto ids that a legit stayer keeps forever
|
||||
for (;;) {
|
||||
let changed = false;
|
||||
for (const [oldId, target] of moves) {
|
||||
const holder = nameById.get(target);
|
||||
if (holder === undefined || moves.has(target)) continue;
|
||||
if (desired.get(holder) === target) {
|
||||
moves.delete(oldId);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
console.log(`[align] planned id moves: ${moves.size}`);
|
||||
let n = 0;
|
||||
for (const [oldId, target] of moves) {
|
||||
if (n++ >= 10) break;
|
||||
console.log(` '${nameById.get(oldId)}' : ${oldId} -> ${target}`);
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("[align] DRY RUN — rerun with --apply to execute");
|
||||
await db.$client.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── patch losing furnidata entries back to their DB id ──────────
|
||||
if (losers.length > 0) {
|
||||
const keepByClass = new Map(losers.map((l) => [l.cn, l.keepId]));
|
||||
for (const { e } of allEntries) {
|
||||
const keep = keepByClass.get(e.classname);
|
||||
if (keep !== undefined && Number(e.id) !== keep) {
|
||||
e.id = keep;
|
||||
e.offerid = keep;
|
||||
}
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
console.log(`[align] furnidata patched: ${losers.length} losing entries`);
|
||||
}
|
||||
|
||||
// ── pass-based PK moves (values stay in unsigned range) ─────────
|
||||
await db.execute(sql`DROP TABLE IF EXISTS _id_map`);
|
||||
await db.execute(sql`DROP TABLE IF EXISTS _id_map_pass`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE _id_map_pass (
|
||||
old_id INT PRIMARY KEY,
|
||||
new_id INT NOT NULL,
|
||||
UNIQUE KEY uniq_new (new_id)
|
||||
) ENGINE=InnoDB`);
|
||||
|
||||
const intCols: Array<[string, string]> = [
|
||||
["items", "item_id"],
|
||||
["room_templates_items", "item_id"],
|
||||
["catalog_items_limited", "item_id"],
|
||||
["crafting_recipes_ingredients", "item_id"],
|
||||
["items_crackable", "item_id"],
|
||||
["gift_wrappers", "sprite_id"],
|
||||
["gift_wrappers", "item_id"],
|
||||
["trax_playlist", "item_id"],
|
||||
["pet_drinks", "item_id"],
|
||||
["pet_foods", "item_id"],
|
||||
["pet_items", "item_id"],
|
||||
["marketplace_items", "item_id"],
|
||||
["calendar_rewards", "item_id"],
|
||||
["builders_club_items", "item_id"],
|
||||
["youtube_playlists", "item_id"],
|
||||
["room_trax_playlist", "item_id"],
|
||||
["recycler_prizes", "item_id"],
|
||||
["website_event_prizes", "item_id"],
|
||||
["website_rare_values", "item_id"],
|
||||
["catalog_products", "item_id"],
|
||||
["room_trade_log_items", "item_id"],
|
||||
["logs_economy", "item_id"],
|
||||
];
|
||||
const strCols: Array<[string, string]> = [
|
||||
["catalog_items", "item_ids"],
|
||||
["catalog_items_bc", "item_ids"],
|
||||
["logs_shop_purchases", "item_ids"],
|
||||
["catalog_version_offers", "item_ids"],
|
||||
];
|
||||
|
||||
const [csRows] = (await db.execute(sql`
|
||||
SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND ((TABLE_NAME = 'catalog_items' AND COLUMN_NAME = 'item_ids')
|
||||
OR (TABLE_NAME = 'catalog_items_bc' AND COLUMN_NAME = 'item_ids')
|
||||
OR (TABLE_NAME = 'logs_shop_purchases' AND COLUMN_NAME = 'item_ids')
|
||||
OR (TABLE_NAME = 'catalog_version_offers' AND COLUMN_NAME = 'item_ids'))
|
||||
`)) as unknown as [
|
||||
Array<{
|
||||
TABLE_NAME: string;
|
||||
COLUMN_NAME: string;
|
||||
CHARACTER_SET_NAME: string;
|
||||
COLLATION_NAME: string;
|
||||
}>,
|
||||
unknown,
|
||||
];
|
||||
const collationByCol = new Map<string, [string, string]>();
|
||||
for (const r of csRows ?? []) {
|
||||
collationByCol.set(`${r.TABLE_NAME}.${r.COLUMN_NAME}`, [
|
||||
r.CHARACTER_SET_NAME,
|
||||
r.COLLATION_NAME,
|
||||
]);
|
||||
}
|
||||
// numeric -> string in the target column's own charset/collation so the
|
||||
// JOIN comparison never mixes collations (CAST alone yields utf8mb4)
|
||||
const numToStr = (table: string, col: string, expr: string) => {
|
||||
const info = collationByCol.get(`${table}.${col}`);
|
||||
if (!info) return sql.raw(`CAST(${expr} AS CHAR)`);
|
||||
return sql.raw(
|
||||
`CONVERT(${expr}, CHAR CHARACTER SET ${info[0]}) COLLATE ${info[1]}`,
|
||||
);
|
||||
};
|
||||
|
||||
const occupiedIds = new Set(rows.map((r) => r.id));
|
||||
let scratch = fresh;
|
||||
|
||||
async function execPass(pairs: Array<[number, number]>): Promise<void> {
|
||||
if (pairs.length === 0) return;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SET FOREIGN_KEY_CHECKS = 0`);
|
||||
for (let i = 0; i < pairs.length; i += 500) {
|
||||
const chunk = pairs.slice(i, i + 500);
|
||||
await tx.execute(sql`
|
||||
INSERT INTO _id_map_pass (old_id, new_id)
|
||||
VALUES ${sql.join(
|
||||
chunk.map(([o, nw]) => sql`(${o}, ${nw})`),
|
||||
sql`, `,
|
||||
)}`);
|
||||
}
|
||||
|
||||
await tx.execute(sql`
|
||||
UPDATE items_base b JOIN _id_map_pass m ON b.id = m.old_id
|
||||
SET b.id = m.new_id`);
|
||||
for (const [table, col] of intCols) {
|
||||
await tx.execute(sql`
|
||||
UPDATE ${sql.raw(table)} t JOIN _id_map_pass m
|
||||
ON t.${sql.raw(col)} = m.old_id
|
||||
SET t.${sql.raw(col)} = m.new_id`);
|
||||
}
|
||||
for (const [table, col] of strCols) {
|
||||
await tx.execute(sql`
|
||||
UPDATE ${sql.raw(table)} t JOIN _id_map_pass m
|
||||
ON t.${sql.raw(col)} = ${numToStr(table, col, "m.old_id")}
|
||||
SET t.${sql.raw(col)} = CAST(m.new_id AS CHAR)`);
|
||||
}
|
||||
for (const [o] of pairs) {
|
||||
await tx.execute(sql`DELETE FROM _id_map_pass WHERE old_id = ${o}`);
|
||||
}
|
||||
await tx.execute(sql`SET FOREIGN_KEY_CHECKS = 1`);
|
||||
});
|
||||
for (const [o, nw] of pairs) {
|
||||
occupiedIds.delete(o);
|
||||
occupiedIds.add(nw);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = new Map(moves);
|
||||
let passes = 0;
|
||||
while (pending.size > 0) {
|
||||
passes++;
|
||||
const safe: Array<[number, number]> = [];
|
||||
for (const [oldId, target] of pending) {
|
||||
if (!occupiedIds.has(target)) safe.push([oldId, target]);
|
||||
}
|
||||
if (safe.length === 0) {
|
||||
// cycle: park the smallest row on a scratch id to break it
|
||||
const oldest = [...pending.keys()].sort((a, b) => a - b)[0];
|
||||
const target = pending.get(oldest);
|
||||
if (target === undefined) break;
|
||||
pending.delete(oldest);
|
||||
await execPass([[oldest, scratch]]);
|
||||
pending.set(scratch, target);
|
||||
scratch++;
|
||||
continue;
|
||||
}
|
||||
await execPass(safe);
|
||||
for (const [oldId] of safe) pending.delete(oldId);
|
||||
console.log(
|
||||
`[align] pass ${passes}: moved ${safe.length} ids (${pending.size} left)`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.execute(sql`DROP TABLE _id_map_pass`);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`UPDATE items_base SET sprite_id = id WHERE sprite_id <> id`,
|
||||
);
|
||||
const [res] = (await tx.execute(sql`
|
||||
UPDATE catalog_items ci JOIN items_base ib
|
||||
ON ci.item_ids = ${numToStr("catalog_items", "item_ids", "ib.id")}
|
||||
SET ci.catalog_name = ib.item_name
|
||||
WHERE ci.catalog_name <> ib.item_name`)) as unknown as [
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
];
|
||||
console.log(
|
||||
`[align] catalog_name normalized rows: ${Number(res.affectedRows ?? 0)}`,
|
||||
);
|
||||
});
|
||||
|
||||
const [maxRows] = (await db.execute(
|
||||
sql`SELECT COALESCE(MAX(id), 0) + 1 AS nxt FROM items_base`,
|
||||
)) as unknown as [Array<{ nxt: number }>, unknown];
|
||||
await db.execute(
|
||||
sql`ALTER TABLE items_base AUTO_INCREMENT = ${sql.raw(String(Number(maxRows[0]?.nxt ?? 1)))}`,
|
||||
);
|
||||
|
||||
// leftover compound lists ("a;b") containing moved ids
|
||||
const [compound] = (await db.execute(sql`
|
||||
SELECT id, item_ids FROM catalog_items WHERE item_ids LIKE '%;%'
|
||||
`)) as unknown as [Array<{ id: number; item_ids: string }>, unknown];
|
||||
for (const row of compound) {
|
||||
const mapped = String(row.item_ids)
|
||||
.split(/[;,]/)
|
||||
.map((p) => {
|
||||
const v = Number(p.trim());
|
||||
return Number.isFinite(v) ? v : p;
|
||||
})
|
||||
.join(";");
|
||||
await db.execute(
|
||||
sql`UPDATE catalog_items SET item_ids = ${mapped} WHERE id = ${row.id}`,
|
||||
);
|
||||
}
|
||||
console.log(`[align] compound item_ids rewritten: ${compound.length}`);
|
||||
console.log(`[align] done in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||
|
||||
await db.$client.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import "dotenv/config";
|
||||
import "./load-env";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -6,7 +6,7 @@ import { mysqlConnectionUrl } from "./db-url";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../drizzle/migrations");
|
||||
const TRACKING_TABLE = "cms_migrations";
|
||||
|
||||
interface MigrationFile {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Full furnidata translation script using LibreTranslate.
|
||||
Translates all customs (missing official translations) for all 13 languages.
|
||||
Run from /var/www/atom-nexst
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import os
|
||||
import time
|
||||
import concurrent.futures
|
||||
import urllib.request
|
||||
import sys
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────────
|
||||
MASTER_PATH = "/var/www/Gamedata/config/FurnitureData.json"
|
||||
OUT_DIR = "/var/www/Gamedata/config"
|
||||
CACHE_PATH = "/var/www/Gamedata/config/.translations_libre_cache.json"
|
||||
LIBRE_URL = "http://127.0.0.1:5000/translate"
|
||||
CONCURRENCY = 6
|
||||
LANGUAGES = [
|
||||
("nl", "nl"),
|
||||
("en", "com"),
|
||||
("de", "de"),
|
||||
("fr", "fr"),
|
||||
("es", "es"),
|
||||
("tr", "com.tr"),
|
||||
("it", "it"),
|
||||
("pt", "com.br"),
|
||||
("fi", "fi"),
|
||||
("pl", "com"),
|
||||
("ru", "com"),
|
||||
("ar", "com"),
|
||||
("ja", "com"),
|
||||
]
|
||||
OFFICIAL_HOTEL_LANGS = {"nl", "en", "de", "fr", "es", "tr", "it", "pt", "fi"}
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json,text/plain,*/*;q=0.9",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
}
|
||||
|
||||
def load_json(path):
|
||||
return json.loads(pathlib.Path(path).read_text())
|
||||
|
||||
def save_json(path, data):
|
||||
tmp = path + ".tmp"
|
||||
pathlib.Path(tmp).write_text(json.dumps(data, ensure_ascii=False))
|
||||
os.rename(tmp, path)
|
||||
os.chmod(path, 0o640)
|
||||
try:
|
||||
import pwd, grp
|
||||
os.chown(path, pwd.getpwnam("www-data").pw_uid, grp.getgrnam("www-data").gr_gid)
|
||||
except:
|
||||
pass
|
||||
|
||||
def build_map(data):
|
||||
m = {}
|
||||
for sec in ("roomitemtypes", "wallitemtypes"):
|
||||
for e in data[sec]["furnitype"]:
|
||||
cn = e["classname"]
|
||||
if cn not in m:
|
||||
m[cn] = {"name": e.get("name", ""), "description": e.get("description", "")}
|
||||
return m
|
||||
|
||||
def is_meaningful(text, classname):
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return False
|
||||
if t == classname:
|
||||
return False
|
||||
if t == f"{classname} desc":
|
||||
return False
|
||||
if t == f"{classname} name":
|
||||
return False
|
||||
if len(t) < 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
def libre_translate(text, target, cache):
|
||||
key = f"{target}|{text}"
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
data = json.dumps({"q": text, "source": "en", "target": target, "format": "text"}).encode()
|
||||
req = urllib.request.Request(LIBRE_URL, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
out = json.loads(r.read().decode()).get("translatedText", text)
|
||||
except Exception as e:
|
||||
print(f" LibreTranslate error for {target} '{text[:40]}': {e}")
|
||||
out = text
|
||||
cache[key] = out
|
||||
return out
|
||||
|
||||
def fetch_official(lang, hotel):
|
||||
if lang not in OFFICIAL_HOTEL_LANGS:
|
||||
return {}
|
||||
url = f"https://www.habbo.{hotel}/gamedata/furnidata_json/1"
|
||||
print(f" Fetching official {lang} from habbo.{hotel}...")
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
print(f" Failed to fetch official {lang}: {e}")
|
||||
return {}
|
||||
fmap = {}
|
||||
for sec in ("roomitemtypes", "wallitemtypes"):
|
||||
for e in data.get(sec, {}).get("furnitype", []):
|
||||
cn = e.get("classname", "")
|
||||
if not cn or cn in fmap:
|
||||
continue
|
||||
fmap[cn] = {"name": e.get("name", ""), "description": e.get("description", "")}
|
||||
print(f" Official {lang}: {len(fmap)} translations")
|
||||
return fmap
|
||||
|
||||
def translate_batch(texts, target, cache, concurrency=CONCURRENCY):
|
||||
if not texts:
|
||||
return {}
|
||||
print(f" Translating {len(texts)} unique texts to {target} with {concurrency} workers...")
|
||||
start = time.time()
|
||||
out = {}
|
||||
missing = [t for t in texts if f"{target}|{t}" not in cache]
|
||||
if not missing:
|
||||
return {t: cache[f"{target}|{t}"] for t in texts}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||||
futs = {ex.submit(libre_translate, t, target, cache): t for t in missing}
|
||||
done = 0
|
||||
for f in concurrent.futures.as_completed(futs):
|
||||
t = futs[f]
|
||||
try:
|
||||
out[t] = f.result()
|
||||
except Exception as e:
|
||||
print(f" Failed {t[:40]}: {e}")
|
||||
out[t] = t
|
||||
done += 1
|
||||
if done % 100 == 0:
|
||||
elapsed = time.time() - start
|
||||
print(f" {done}/{len(missing)} in {elapsed:.1f}s ({elapsed/max(done,1):.2f}s/req)")
|
||||
elapsed = time.time() - start
|
||||
print(f" Done {len(texts)} texts to {target} in {elapsed:.1f}s")
|
||||
return out
|
||||
|
||||
def main():
|
||||
print("Loading master FurnitureData.json...")
|
||||
master = load_json(MASTER_PATH)
|
||||
master_map = build_map(master)
|
||||
print(f"Master: {len(master_map)} entries")
|
||||
|
||||
# Load or create cache
|
||||
cache = {}
|
||||
if os.path.exists(CACHE_PATH):
|
||||
try:
|
||||
cache = load_json(CACHE_PATH)
|
||||
print(f"Loaded cache: {len(cache)} entries")
|
||||
except Exception:
|
||||
print("Cache corrupt, starting fresh")
|
||||
cache = {}
|
||||
|
||||
# For each language
|
||||
for lang, hotel in LANGUAGES:
|
||||
if lang == "en":
|
||||
print(f"\n=== {lang} (source) ===")
|
||||
# en file is just copy of master
|
||||
out_path = f"{OUT_DIR}/FurnitureData_en.json"
|
||||
save_json(out_path, master)
|
||||
print(f" Wrote {out_path}")
|
||||
continue
|
||||
|
||||
out_path = f"{OUT_DIR}/FurnitureData_{lang}.json"
|
||||
print(f"\n=== {lang} (hotel: {hotel}) ===")
|
||||
|
||||
# Fetch official translations if available
|
||||
official = fetch_official(lang, hotel) if lang in OFFICIAL_HOTEL_LANGS else {}
|
||||
|
||||
# Clone master
|
||||
cloned = json.loads(json.dumps(master))
|
||||
translated = 0
|
||||
official_count = 0
|
||||
libre_count = 0
|
||||
|
||||
# Collect all texts needing translation (customs not covered by official)
|
||||
missing_names = set()
|
||||
missing_descs = set()
|
||||
|
||||
for sec in ("roomitemtypes", "wallitemtypes"):
|
||||
for e in cloned[sec]["furnitype"]:
|
||||
cn = e["classname"]
|
||||
t = official.get(cn)
|
||||
if not t and "*" in cn:
|
||||
base = cn.split("*")[0]
|
||||
t = official.get(f"{base}*0") or official.get(base)
|
||||
if not t and "*" not in cn:
|
||||
t = official.get(f"{cn}*0")
|
||||
|
||||
orig_name = e["name"]
|
||||
orig_desc = e.get("description", "")
|
||||
|
||||
# Apply official if available
|
||||
if t:
|
||||
did = False
|
||||
if t.get("name"):
|
||||
e["name"] = t["name"]
|
||||
did = True
|
||||
if t.get("description"):
|
||||
e["description"] = t["description"]
|
||||
did = True
|
||||
if did:
|
||||
translated += 1
|
||||
official_count += 1
|
||||
continue
|
||||
|
||||
# Collect missing for LibreTranslate
|
||||
if is_meaningful(orig_name, cn):
|
||||
missing_names.add(orig_name.strip())
|
||||
if is_meaningful(orig_desc, cn):
|
||||
missing_descs.add(orig_desc.strip())
|
||||
|
||||
print(f" Official applied: {official_count}, missing for Libre: {len(missing_names)} names, {len(missing_descs)} descs")
|
||||
|
||||
# Translate missing via LibreTranslate
|
||||
if missing_names:
|
||||
tr_names = translate_batch(missing_names, lang, cache, CONCURRENCY)
|
||||
for sec in ("roomitemtypes", "wallitemtypes"):
|
||||
for e in cloned[sec]["furnitype"]:
|
||||
cn = e["classname"]
|
||||
orig = e["name"]
|
||||
if is_meaningful(orig, cn):
|
||||
tr = tr_names.get(orig.strip())
|
||||
if tr and tr != orig:
|
||||
e["name"] = tr
|
||||
translated += 1
|
||||
libre_count += 1
|
||||
|
||||
if missing_descs:
|
||||
tr_descs = translate_batch(missing_descs, lang, cache, CONCURRENCY)
|
||||
for sec in ("roomitemtypes", "wallitemtypes"):
|
||||
for e in cloned[sec]["furnitype"]:
|
||||
cn = e["classname"]
|
||||
orig = e.get("description", "")
|
||||
if is_meaningful(orig, cn):
|
||||
tr = tr_descs.get(orig.strip())
|
||||
if tr and tr != orig:
|
||||
e["description"] = tr
|
||||
translated += 1
|
||||
libre_count += 1
|
||||
|
||||
# Save cache after each language
|
||||
save_json(CACHE_PATH, cache)
|
||||
print(f" LibreTranslate applied: {libre_count}, total translated: {translated}")
|
||||
|
||||
# Write output
|
||||
save_json(out_path, cloned)
|
||||
print(f" Wrote {out_path} ({pathlib.Path(out_path).stat().st_size / 1024 / 1024:.1f} MB)")
|
||||
|
||||
# Final cache save
|
||||
save_json(CACHE_PATH, cache)
|
||||
print(f"\n=== All done! Cache: {len(cache)} entries ===")
|
||||
|
||||
# Summary
|
||||
for lang, _ in LANGUAGES:
|
||||
fmap = build_map(load_json(f"{OUT_DIR}/FurnitureData_{lang}.json"))
|
||||
ident = sum(1 for k, v in master_map.items() if fmap.get(k) and fmap[k]["name"] == v["name"])
|
||||
pct = (len(master_map) - ident) / len(master_map) * 100
|
||||
print(f" {lang}: {len(master_map)-ident}/{len(master_map)} translated ({pct:.1f}%)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
import "./load-env";
|
||||
import { buildLocalizedFurniDataFiles } from "../src/lib/services/furni-data-i18n";
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const full = args.includes("--full");
|
||||
const maxPerLang = full
|
||||
? undefined
|
||||
: args.includes("--limit")
|
||||
? parseInt(args[args.indexOf("--limit") + 1] || "2000", 10)
|
||||
: 1500;
|
||||
const concurrency = args.includes("--concurrency")
|
||||
? parseInt(args[args.indexOf("--concurrency") + 1] || "6", 10)
|
||||
: 6;
|
||||
|
||||
console.log(
|
||||
`[build-languages] Starting ${full ? "FULL" : `limited ${maxPerLang} per lang`} build (concurrency ${concurrency})...`,
|
||||
);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const results = await buildLocalizedFurniDataFiles();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`[build-languages] Done in ${elapsed}s`);
|
||||
for (const r of results) {
|
||||
const totalTranslated = r.translatedRoom + r.translatedWall;
|
||||
const pct =
|
||||
r.total > 0 ? ((totalTranslated / r.total) * 100).toFixed(1) : "0.0";
|
||||
console.log(
|
||||
` ${r.lang} (${r.hotel}): ${totalTranslated}/${r.total} translated (${pct}%) -> ${r.file} ${r.ok ? "OK" : `FAIL ${r.error}`}`,
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("[build-languages] Failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const readProjectFile = (path) =>
|
||||
readFileSync(resolve(projectRoot, path), "utf8");
|
||||
|
||||
const pinnedVersion = readProjectFile(".nvmrc").trim();
|
||||
assert.match(
|
||||
pinnedVersion,
|
||||
/^\d+\.\d+\.\d+$/,
|
||||
".nvmrc must pin an exact Node.js version",
|
||||
);
|
||||
|
||||
const major = Number.parseInt(pinnedVersion.split(".")[0], 10);
|
||||
const packageJson = JSON.parse(readProjectFile("package.json"));
|
||||
|
||||
assert.equal(
|
||||
packageJson.engines?.node,
|
||||
`>=${pinnedVersion} <${major + 1}`,
|
||||
"package.json engines.node must match the .nvmrc release line",
|
||||
);
|
||||
assert.equal(
|
||||
Number.parseInt(packageJson.devDependencies?.["@types/node"], 10),
|
||||
major,
|
||||
"@types/node must match the pinned Node.js major",
|
||||
);
|
||||
|
||||
if (!process.argv.includes("--static")) {
|
||||
assert.equal(
|
||||
process.versions.node,
|
||||
pinnedVersion,
|
||||
"the active Node.js runtime must match .nvmrc",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Node.js toolchain is aligned on ${pinnedVersion}`);
|
||||
@@ -0,0 +1,116 @@
|
||||
import "./load-env";
|
||||
import mysql, { type RowDataPacket } from "mysql2/promise";
|
||||
import { mysqlConnectionUrl } from "./db-url";
|
||||
|
||||
type DbError = Error & { code?: string };
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required");
|
||||
|
||||
const connection = await mysql.createConnection(
|
||||
mysqlConnectionUrl(databaseUrl),
|
||||
);
|
||||
let failed = false;
|
||||
|
||||
async function probe(
|
||||
name: string,
|
||||
statement: string,
|
||||
values: Array<string | number> = [],
|
||||
): Promise<RowDataPacket[] | null> {
|
||||
try {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(statement, values);
|
||||
console.log(`[permissions-diag] ${name}: ok (${rows.length} rows)`);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
const dbError = error as DbError;
|
||||
console.error(
|
||||
`[permissions-diag] ${name}: failed code=${dbError.code ?? "unknown"} message=${dbError.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rankRows = await probe(
|
||||
"select rank id",
|
||||
"SELECT id FROM permission_ranks ORDER BY id DESC LIMIT 1",
|
||||
);
|
||||
const rankId = Number(rankRows?.[0]?.id ?? 11);
|
||||
console.log(`[permissions-diag] probing rank id ${rankId}`);
|
||||
|
||||
await probe(
|
||||
"rank detail",
|
||||
`SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
||||
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
||||
auto_gotw_amount, auto_points_amount
|
||||
FROM permission_ranks WHERE id = ?`,
|
||||
[rankId],
|
||||
);
|
||||
|
||||
const definitionColumns = await probe(
|
||||
"permission_definitions columns",
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'permission_definitions'`,
|
||||
);
|
||||
const permissionLimits = await probe(
|
||||
"permission max_value limits",
|
||||
`SELECT MIN(max_value) AS min_value,
|
||||
MAX(max_value) AS max_value,
|
||||
SUM(max_value > 20) AS values_over_20
|
||||
FROM permission_definitions`,
|
||||
);
|
||||
if (permissionLimits?.[0]) {
|
||||
console.log(
|
||||
`[permissions-diag] max_value range ${permissionLimits[0].min_value}..${permissionLimits[0].max_value}; values_over_20=${permissionLimits[0].values_over_20}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rankColumn = `rank_${rankId}`;
|
||||
if (definitionColumns?.some((row) => row.column_name === rankColumn)) {
|
||||
await probe(
|
||||
"normalized permissions",
|
||||
`SELECT permission_key, max_value, \`${rankColumn}\` AS value
|
||||
FROM permission_definitions ORDER BY permission_key ASC`,
|
||||
);
|
||||
} else {
|
||||
await probe(
|
||||
"legacy permissions",
|
||||
"SELECT * FROM permissions WHERE id = ? LIMIT 1",
|
||||
[rankId],
|
||||
);
|
||||
}
|
||||
|
||||
await probe(
|
||||
"rank users",
|
||||
"SELECT id, username, look FROM users WHERE rank = ? ORDER BY username LIMIT 200",
|
||||
[rankId],
|
||||
);
|
||||
await probe(
|
||||
"rank user count",
|
||||
"SELECT COUNT(*) AS total FROM users WHERE rank = ?",
|
||||
[rankId],
|
||||
);
|
||||
await probe(
|
||||
"CMS permissions",
|
||||
"SELECT id, slug, title FROM acl_permissions ORDER BY slug",
|
||||
);
|
||||
const roles = await probe(
|
||||
"CMS role",
|
||||
"SELECT id FROM acl_roles WHERE slug = ? LIMIT 1",
|
||||
[`rank_${rankId}`],
|
||||
);
|
||||
if (roles?.[0]?.id) {
|
||||
await probe(
|
||||
"CMS role permissions",
|
||||
`SELECT p.slug FROM acl_model_permissions mp
|
||||
INNER JOIN acl_permissions p ON mp.permission_id = p.id
|
||||
WHERE mp.model_id = ?`,
|
||||
[roles[0].id],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
if (failed) process.exitCode = 1;
|
||||
@@ -0,0 +1,207 @@
|
||||
import "./load-env";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||
import { readFurniData } from "@/lib/services/furni-data";
|
||||
|
||||
const ICON_DIR = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"swf",
|
||||
"dcr",
|
||||
"hof_furni",
|
||||
"icons",
|
||||
);
|
||||
const NITRO_DIR = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"swf",
|
||||
"dcr",
|
||||
"hof_furni",
|
||||
"nitro",
|
||||
);
|
||||
|
||||
function md5(p: string): string {
|
||||
return createHash("md5").update(readFileSync(p)).digest("hex");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// ── 1. DB state ──────────────────────────────────────────────────
|
||||
const [spriteDrift] = (await db.execute(
|
||||
sql`SELECT COUNT(*) AS n FROM items_base WHERE sprite_id <> id`,
|
||||
)) as unknown as [Array<{ n: number }>, unknown];
|
||||
console.log(
|
||||
`[1] items_base rows with sprite_id <> id: ${Number(spriteDrift[0]?.n ?? 0)}`,
|
||||
);
|
||||
|
||||
const baseRows = await db
|
||||
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
|
||||
.from(ItemsBase);
|
||||
console.log(` items_base total: ${baseRows.length}`);
|
||||
|
||||
const catRows = await db
|
||||
.select({
|
||||
id: CatalogItems.id,
|
||||
itemIds: CatalogItems.itemIds,
|
||||
catalogName: CatalogItems.catalogName,
|
||||
})
|
||||
.from(CatalogItems);
|
||||
console.log(` catalog_items total: ${catRows.length}`);
|
||||
|
||||
// catalog_items.item_ids -> dangling items_base refs
|
||||
let dangling = 0;
|
||||
let nameMismatch = 0;
|
||||
const nameById = new Map(baseRows.map((r) => [r.id, r.itemName]));
|
||||
for (const c of catRows) {
|
||||
const first = Number(String(c.itemIds ?? "").split(/[;,]/)[0]);
|
||||
if (!Number.isFinite(first) || !nameById.has(first)) {
|
||||
dangling++;
|
||||
continue;
|
||||
}
|
||||
if (c.catalogName && nameById.get(first) !== c.catalogName) nameMismatch++;
|
||||
}
|
||||
console.log(`[2] catalog_items with dangling item_ids: ${dangling}`);
|
||||
console.log(
|
||||
` catalog_items with catalog_name <> items_base.item_name: ${nameMismatch}`,
|
||||
);
|
||||
|
||||
// ── 2. Furnidata vs items_base ───────────────────────────────────
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype?: Array<Record<string, unknown>> }
|
||||
>;
|
||||
interface Entry {
|
||||
id: number;
|
||||
classname: string;
|
||||
offerid: number;
|
||||
}
|
||||
const entries: Entry[] = [];
|
||||
const seenClass = new Map<string, number>();
|
||||
let dupClass = 0;
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
for (const e of furniData[section]?.furnitype ?? []) {
|
||||
const id = Number(e?.id);
|
||||
const cn = typeof e?.classname === "string" ? e.classname : "";
|
||||
if (!Number.isFinite(id) || !cn) continue;
|
||||
if (seenClass.has(cn)) dupClass++;
|
||||
seenClass.set(cn, (seenClass.get(cn) ?? 0) + 1);
|
||||
entries.push({ id, classname: cn, offerid: Number(e?.offerid) });
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[3] furnidata entries: ${entries.length} (duplicate classnames: ${dupClass})`,
|
||||
);
|
||||
|
||||
const dbByClass = new Map(baseRows.map((r) => [r.itemName, r.id]));
|
||||
const dbById = new Map(baseRows.map((r) => [r.id, r.itemName]));
|
||||
let fdWrongId = 0;
|
||||
let _fdIdConflict = 0; // id belongs to a different classname in DB
|
||||
let fdMissingInDb = 0;
|
||||
const fdExamplesWrong: string[] = [];
|
||||
for (const e of entries) {
|
||||
const dbId = dbByClass.get(e.classname);
|
||||
if (dbId === undefined) {
|
||||
fdMissingInDb++;
|
||||
continue;
|
||||
}
|
||||
if (dbId !== e.id) {
|
||||
fdWrongId++;
|
||||
if (fdExamplesWrong.length < 10)
|
||||
fdExamplesWrong.push(
|
||||
`${e.classname}: furnidata id=${e.id} vs db id=${dbId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (dbById.get(e.id) !== e.classname) _fdIdConflict++;
|
||||
}
|
||||
console.log(
|
||||
` furnidata entries whose id != items_base.id for same classname: ${fdWrongId}`,
|
||||
);
|
||||
console.log(
|
||||
` furnidata entries not present in items_base at all: ${fdMissingInDb}`,
|
||||
);
|
||||
if (fdExamplesWrong.length)
|
||||
console.log(` examples:\n ${fdExamplesWrong.join("\n ")}`);
|
||||
|
||||
// items_base rows missing from furnidata
|
||||
const fdClasses = new Set(entries.map((e) => e.classname));
|
||||
const missingFd = baseRows.filter((r) => !fdClasses.has(r.itemName));
|
||||
console.log(
|
||||
` items_base rows missing from furnidata: ${missingFd.length}`,
|
||||
);
|
||||
if (missingFd.length > 0)
|
||||
console.log(
|
||||
` examples: ${missingFd
|
||||
.slice(0, 10)
|
||||
.map((r) => `${r.id}:${r.itemName}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
// ── 3. Assets on disk ────────────────────────────────────────────
|
||||
const catalogClasses = new Set<string>();
|
||||
for (const c of catRows) {
|
||||
const nm = nameById.get(Number(String(c.itemIds ?? "").split(/[;,]/)[0]));
|
||||
if (nm) catalogClasses.add(nm.replace(/\*/g, "_"));
|
||||
}
|
||||
|
||||
const files = readdirSync(ICON_DIR);
|
||||
const iconSet = new Set(files);
|
||||
let missingIcons = 0;
|
||||
const missingIconExamples: string[] = [];
|
||||
for (const cls of catalogClasses) {
|
||||
const f = `${cls}_icon.png`;
|
||||
if (!iconSet.has(f)) {
|
||||
missingIcons++;
|
||||
if (missingIconExamples.length < 15) missingIconExamples.push(f);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[4] catalog items without local icon file: ${missingIcons} of ${catalogClasses.size}`,
|
||||
);
|
||||
if (missingIconExamples.length)
|
||||
console.log(` examples: ${missingIconExamples.join(", ")}`);
|
||||
|
||||
// duplicate-content icons across different classnames (suspicious downloads)
|
||||
const byHash = new Map<string, string[]>();
|
||||
for (const f of files) {
|
||||
if (!f.endsWith("_icon.png")) continue;
|
||||
const p = path.join(ICON_DIR, f);
|
||||
const st = statSync(p);
|
||||
if (st.size === 0) continue;
|
||||
const h = md5(p);
|
||||
const arr = byHash.get(h) ?? [];
|
||||
arr.push(f);
|
||||
byHash.set(h, arr);
|
||||
}
|
||||
let dupFiles = 0;
|
||||
const dupGroups: string[][] = [];
|
||||
for (const [, arr] of byHash) {
|
||||
if (arr.length < 2) continue;
|
||||
dupFiles += arr.length - 1;
|
||||
if (dupGroups.length < 8) dupGroups.push(arr);
|
||||
}
|
||||
console.log(
|
||||
` byte-identical icon files across different classnames: ${dupFiles} extra copies`,
|
||||
);
|
||||
for (const g of dupGroups)
|
||||
console.log(
|
||||
` ${g.slice(0, 6).join(" == ")}${g.length > 6 ? ` (+${g.length - 6} more)` : ""}`,
|
||||
);
|
||||
|
||||
let nitroMissing = 0;
|
||||
const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []);
|
||||
for (const cls of catalogClasses) {
|
||||
if (!nitroSet.has(`${cls}.nitro`)) nitroMissing++;
|
||||
}
|
||||
console.log(`[5] catalog items without .nitro bundle: ${nitroMissing}`);
|
||||
|
||||
await db.$client.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/db/schema.ts from:
|
||||
// 1. existing src/db/schema.ts -> TS export names, camelCase fields, column maps, keys
|
||||
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
|
||||
//
|
||||
// The DB is owned by the Arcturus emulator; we never run drizzle-kit migrate/push.
|
||||
// CMS DDL lands in drizzle/migrations/*.sql via `pnpm db:migrate`.
|
||||
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
|
||||
//
|
||||
// Usage: pnpm db:schema:generate
|
||||
import "dotenv/config";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MYSQL2_UNSUPPORTED_OPTIONS = [
|
||||
"connection_limit",
|
||||
"pool_timeout",
|
||||
"connect_timeout",
|
||||
];
|
||||
|
||||
function mysqlConnectionUrl(value) {
|
||||
const url = new URL(value);
|
||||
for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
|
||||
url.searchParams.delete(option);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
||||
const OUT = SCHEMA_TS;
|
||||
|
||||
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
|
||||
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
|
||||
|
||||
/**
|
||||
* @returns {{ name: string, table: string, fields: object[], ids: string[]|null, uniques: string[][] }}
|
||||
*/
|
||||
function parseDrizzleTables(source) {
|
||||
const models = [];
|
||||
const re2 =
|
||||
/^export const (\w+) = mysqlTable\(\s*"([^"]+)"\s*,\s*\{([\s\S]*?)\n\}(?:,\s*\(t\)\s*=>\s*\[([\s\S]*?)\])?\s*\);/gm;
|
||||
|
||||
let match = re2.exec(source);
|
||||
const seen = new Set();
|
||||
while (match !== null) {
|
||||
const [, name, table, body, extras] = match;
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
models.push(parseTableBody(name, table, body, extras ?? ""));
|
||||
}
|
||||
match = re2.exec(source);
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error(
|
||||
`[schema-gen] Failed to parse any mysqlTable exports from ${SCHEMA_TS}`,
|
||||
);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function parseTableBody(name, table, body, extras) {
|
||||
const fields = [];
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\s*$/);
|
||||
if (!m) continue;
|
||||
const fieldName = m[1];
|
||||
const expr = m[2];
|
||||
const colMatch = expr.match(/\(\s*"([^"]+)"/);
|
||||
if (!colMatch) continue;
|
||||
const column = colMatch[1];
|
||||
|
||||
const isBoolean = /\bboolean\s*\(/.test(expr);
|
||||
const enumMatch = expr.match(/mysqlEnum\s*\(\s*"[^"]+"\s*,\s*(\[[^\]]*\])/);
|
||||
let enumValues = null;
|
||||
if (enumMatch) {
|
||||
try {
|
||||
enumValues = JSON.parse(enumMatch[1].replace(/'/g, '"'));
|
||||
} catch {
|
||||
enumValues = [...enumMatch[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
||||
}
|
||||
}
|
||||
|
||||
let defaultContent = null;
|
||||
const defIdx = expr.indexOf(".default(");
|
||||
if (defIdx >= 0) {
|
||||
const start = defIdx + ".default(".length;
|
||||
let depth = 0;
|
||||
for (let i = start; i < expr.length; i++) {
|
||||
const ch = expr[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") {
|
||||
if (depth === 0) {
|
||||
defaultContent = expr.slice(start, i).trim();
|
||||
break;
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.push({
|
||||
fieldName,
|
||||
column,
|
||||
optional: !/\.notNull\s*\(/.test(expr),
|
||||
isId: /\.primaryKey\s*\(/.test(expr),
|
||||
isUnique: /\.unique\s*\(/.test(expr),
|
||||
autoIncrement: /\.autoincrement\s*\(/.test(expr),
|
||||
isBoolean,
|
||||
enumValues,
|
||||
defaultContent,
|
||||
// legacy shape used by columnExpr / fallback
|
||||
prismaType: isBoolean
|
||||
? "Boolean"
|
||||
: enumValues
|
||||
? fieldName === "gender"
|
||||
? "users_gender"
|
||||
: fieldName === "type" && table === "bans"
|
||||
? "bans_type"
|
||||
: "String"
|
||||
: "String",
|
||||
attrs: defaultContent ? `@default(${defaultContent})` : "",
|
||||
dbHint: null,
|
||||
});
|
||||
}
|
||||
|
||||
let ids = null;
|
||||
const uniques = [];
|
||||
if (extras) {
|
||||
const pk = extras.match(
|
||||
/primaryKey\(\s*\{\s*columns:\s*\[([^\]]+)\]\s*\}\s*\)/,
|
||||
);
|
||||
if (pk) {
|
||||
ids = [...pk[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]);
|
||||
}
|
||||
for (const u of extras.matchAll(/uniqueIndex\([^)]*\)\.on\(([^)]+)\)/g)) {
|
||||
uniques.push([...u[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, table, fields, ids, uniques };
|
||||
}
|
||||
|
||||
const models = parseDrizzleTables(schemaSource);
|
||||
|
||||
// ---------- Introspect live MySQL ----------
|
||||
let url;
|
||||
try {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL is required");
|
||||
url = mysqlConnectionUrl(raw);
|
||||
} catch (err) {
|
||||
console.error("[schema-gen] No DATABASE_URL:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
|
||||
const [cols] = await conn.query(
|
||||
`SELECT table_name, column_name, data_type, column_type, is_nullable,
|
||||
column_key, column_default, extra
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()`,
|
||||
);
|
||||
await conn.end();
|
||||
|
||||
const colByTable = new Map();
|
||||
for (const c of cols) {
|
||||
const key = `${c.table_name}.${c.column_name}`;
|
||||
colByTable.set(key, c);
|
||||
}
|
||||
|
||||
// ---------- Build drizzle column builders ----------
|
||||
const used = new Set(["mysqlTable", "primaryKey", "uniqueIndex", "customType"]);
|
||||
let usedSql = false;
|
||||
|
||||
function use(name) {
|
||||
used.add(name);
|
||||
}
|
||||
|
||||
function markSqlUsed() {
|
||||
usedSql = true;
|
||||
}
|
||||
|
||||
function quote(v) {
|
||||
return `"${String(v).replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Map a DB column row to a drizzle column expression string. */
|
||||
function columnExpr(field, dbCol) {
|
||||
const col = field.column;
|
||||
let type = "";
|
||||
|
||||
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
||||
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
||||
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
||||
|
||||
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
||||
use("mysqlEnum");
|
||||
const values = enumMatch
|
||||
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
: (field.enumValues ??
|
||||
(field.prismaType === "users_gender"
|
||||
? ["M", "F"]
|
||||
: ["account", "ip", "machine", "super"]));
|
||||
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
||||
}
|
||||
|
||||
switch (dbCol?.data_type) {
|
||||
case "int":
|
||||
use("int");
|
||||
type = unsigned
|
||||
? `int(${quote(col)}, { unsigned: true })`
|
||||
: `int(${quote(col)})`;
|
||||
break;
|
||||
case "tinyint": {
|
||||
const isBool =
|
||||
dbCol.column_type === "tinyint(1)" &&
|
||||
(field.isBoolean || field.prismaType === "Boolean");
|
||||
if (isBool) {
|
||||
use("boolean");
|
||||
type = `boolean(${quote(col)})`;
|
||||
} else {
|
||||
use("tinyint");
|
||||
type = unsigned
|
||||
? `tinyint(${quote(col)}, { unsigned: true })`
|
||||
: `tinyint(${quote(col)})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "smallint":
|
||||
use("smallint");
|
||||
type = unsigned
|
||||
? `smallint(${quote(col)}, { unsigned: true })`
|
||||
: `smallint(${quote(col)})`;
|
||||
break;
|
||||
case "mediumint":
|
||||
use("mediumint");
|
||||
type = unsigned
|
||||
? `mediumint(${quote(col)}, { unsigned: true })`
|
||||
: `mediumint(${quote(col)})`;
|
||||
break;
|
||||
case "bigint":
|
||||
use("bigint");
|
||||
type = `bigint(${quote(col)}, { mode: "bigint", unsigned: ${unsigned} })`;
|
||||
break;
|
||||
case "varchar":
|
||||
case "enum": {
|
||||
use("varchar");
|
||||
const maxLen = enumMatch
|
||||
? Math.max(
|
||||
...[...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].length),
|
||||
1,
|
||||
)
|
||||
: Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 255);
|
||||
type = `varchar(${quote(col)}, { length: ${maxLen} })`;
|
||||
break;
|
||||
}
|
||||
case "char":
|
||||
use("char");
|
||||
type = `char(${quote(col)}, { length: ${Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 8)} })`;
|
||||
break;
|
||||
case "text":
|
||||
use("text");
|
||||
type = `text(${quote(col)})`;
|
||||
break;
|
||||
case "mediumtext":
|
||||
use("mediumtext");
|
||||
type = `mediumtext(${quote(col)})`;
|
||||
break;
|
||||
case "longtext":
|
||||
use("longtext");
|
||||
type = `longtext(${quote(col)})`;
|
||||
break;
|
||||
case "datetime": {
|
||||
use("datetime");
|
||||
const fsp = dbCol.column_type.match(/datetime\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `datetime(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `datetime(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "timestamp": {
|
||||
use("timestamp");
|
||||
const fsp = dbCol.column_type.match(/timestamp\((\d+)\)/)?.[1];
|
||||
type = fsp
|
||||
? `timestamp(${quote(col)}, { fsp: ${Number(fsp)} })`
|
||||
: `timestamp(${quote(col)})`;
|
||||
break;
|
||||
}
|
||||
case "double":
|
||||
use("double");
|
||||
type = `double(${quote(col)})`;
|
||||
break;
|
||||
case "float":
|
||||
use("float");
|
||||
type = `float(${quote(col)})`;
|
||||
break;
|
||||
case "decimal":
|
||||
use("decimal");
|
||||
type = `decimal(${quote(col)}, { precision: ${Number(decimalMatch?.[1] ?? 10)}, scale: ${Number(decimalMatch?.[2] ?? 0)}, mode: "number" })`;
|
||||
break;
|
||||
case "json":
|
||||
use("json");
|
||||
type = `json(${quote(col)})`;
|
||||
break;
|
||||
case "date":
|
||||
type = `dateAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "time":
|
||||
type = `timeAsDate(${quote(col)})`;
|
||||
break;
|
||||
case "blob":
|
||||
case "tinyblob":
|
||||
case "mediumblob":
|
||||
case "longblob":
|
||||
use("binary");
|
||||
type = `binary(${quote(col)})`;
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col}`,
|
||||
);
|
||||
use("varchar");
|
||||
type = `varchar(${quote(col)}, { length: 255 })`;
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
function modifiers(field, dbCol) {
|
||||
let expr = "";
|
||||
if (dbCol?.extra?.includes("auto_increment") || field.autoIncrement) {
|
||||
expr += `.autoincrement()`;
|
||||
}
|
||||
if (field.isId) {
|
||||
expr += `.primaryKey()`;
|
||||
} else if (dbCol?.column_key === "UNI" && !field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
} else if (field.isUnique) {
|
||||
expr += `.unique()`;
|
||||
}
|
||||
if (!field.optional) {
|
||||
expr += `.notNull()`;
|
||||
}
|
||||
expr += emitDefault(field, dbCol);
|
||||
return expr;
|
||||
}
|
||||
|
||||
function emitDefault(field, dbCol) {
|
||||
const content = field.defaultContent;
|
||||
if (!content) return "";
|
||||
if (
|
||||
content === "sql`CURRENT_TIMESTAMP`" ||
|
||||
content.includes("CURRENT_TIMESTAMP")
|
||||
) {
|
||||
markSqlUsed();
|
||||
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
||||
}
|
||||
if (content === "true" || content === "false") return `.default(${content})`;
|
||||
if (/^-?\d+n$/.test(content)) return `.default(${content})`;
|
||||
if (/^-?\d+$/.test(content)) {
|
||||
return dbCol?.data_type === "bigint"
|
||||
? `.default(${content}n)`
|
||||
: `.default(${content})`;
|
||||
}
|
||||
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
||||
if (
|
||||
(content.startsWith('"') && content.endsWith('"')) ||
|
||||
(content.startsWith("'") && content.endsWith("'"))
|
||||
) {
|
||||
return `.default(${JSON.stringify(content.slice(1, -1))})`;
|
||||
}
|
||||
return `.default(${content})`;
|
||||
}
|
||||
|
||||
function modelTable(model) {
|
||||
const rows = [];
|
||||
for (const f of model.fields) {
|
||||
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
||||
if (!dbCol) {
|
||||
const fallback = fallbackColumn(f) + modifiers(f, null);
|
||||
rows.push(`\t${f.fieldName}: ${fallback},`);
|
||||
console.warn(
|
||||
`[schema-gen] WARN ${model.table}.${f.column} (${f.fieldName}) missing in DB, used fallback`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rows.push(
|
||||
`\t${f.fieldName}: ${columnExpr(f, dbCol)}${modifiers(f, dbCol)},`,
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueRows = [];
|
||||
if (model.ids) {
|
||||
const idCols = model.ids
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (idCols.length)
|
||||
uniqueRows.push(`\tprimaryKey({ columns: [${idCols.join(", ")}] }),`);
|
||||
}
|
||||
for (const u of model.uniques) {
|
||||
const cols = u
|
||||
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
||||
.filter(Boolean)
|
||||
.map((f) => `t.${f.fieldName}`);
|
||||
if (cols.length)
|
||||
uniqueRows.push(
|
||||
`\tuniqueIndex("${model.table}_${u.join("_")}").on(${cols.join(", ")}),`,
|
||||
);
|
||||
}
|
||||
|
||||
if (uniqueRows.length) {
|
||||
return `export const ${model.name} = mysqlTable(
|
||||
"${model.table}",
|
||||
{
|
||||
${rows.join("\n")}
|
||||
},
|
||||
(t) => [
|
||||
${uniqueRows.join("\n")}
|
||||
],
|
||||
);`;
|
||||
}
|
||||
return `export const ${model.name} = mysqlTable("${model.table}", {
|
||||
${rows.join("\n")}
|
||||
});`;
|
||||
}
|
||||
|
||||
function fallbackColumn(field) {
|
||||
const name = field.column;
|
||||
if (field.enumValues) {
|
||||
use("mysqlEnum");
|
||||
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
|
||||
}
|
||||
if (field.isBoolean || field.prismaType === "Boolean") {
|
||||
use("boolean");
|
||||
return `boolean(${quote(name)})`;
|
||||
}
|
||||
use("varchar");
|
||||
return `varchar(${quote(name)}, { length: 255 })`;
|
||||
}
|
||||
|
||||
// ---------- Generate file ----------
|
||||
const body = models.map(modelTable).join("\n\n");
|
||||
|
||||
const IMPORTABLE = [
|
||||
"bigint",
|
||||
"binary",
|
||||
"boolean",
|
||||
"char",
|
||||
"customType",
|
||||
"date",
|
||||
"datetime",
|
||||
"decimal",
|
||||
"double",
|
||||
"float",
|
||||
"int",
|
||||
"json",
|
||||
"longtext",
|
||||
"mediumint",
|
||||
"mediumtext",
|
||||
"mysqlEnum",
|
||||
"mysqlTable",
|
||||
"primaryKey",
|
||||
"smallint",
|
||||
"text",
|
||||
"time",
|
||||
"timestamp",
|
||||
"tinyint",
|
||||
"uniqueIndex",
|
||||
"varchar",
|
||||
];
|
||||
const importList = IMPORTABLE.filter((b) => used.has(b));
|
||||
|
||||
const helpers = [
|
||||
"// MySQL TIME / DATE columns are hydrated as JS Date (epoch 1970-01-01",
|
||||
"// for TIME). Keep this so existing call sites stay unchanged.",
|
||||
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "time";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(11, 19);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
||||
" return new Date(`1970-01-01T${value}Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"const dateAsDate = customType<{ data: Date; driverData: string }>({",
|
||||
" dataType() {",
|
||||
' return "date";',
|
||||
" },",
|
||||
" toDriver(value) {",
|
||||
" return value.toISOString().slice(0, 10);",
|
||||
" },",
|
||||
" fromDriver(value) {",
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
||||
" return new Date(`${value}T00:00:00Z`);",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const sqlImport = usedSql ? 'import { sql } from "drizzle-orm";\n' : "";
|
||||
|
||||
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
||||
// TS field names come from the previous src/db/schema.ts; column types from the
|
||||
// live MySQL DB. Run \`pnpm db:schema:generate\` after schema/map changes.
|
||||
${sqlImport}import {
|
||||
${importList.map((b) => `\t${b},`).join("\n")}
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
${helpers}${body}
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, out);
|
||||
console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`);
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
URL="${FLARESOLVERR_URL:-http://localhost:8191}"
|
||||
MAX_RETRIES="${FLARESOLVERR_MAX_RETRIES:-30}"
|
||||
RETRY_INTERVAL="${FLARESOLVERR_RETRY_INTERVAL:-2}"
|
||||
|
||||
echo "Waiting for FlareSolverr at ${URL}..."
|
||||
|
||||
for i in $(seq 1 "$MAX_RETRIES"); do
|
||||
if curl -sf "${URL}/health" >/dev/null 2>&1; then
|
||||
echo "FlareSolverr is healthy."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt ${i}/${MAX_RETRIES} - FlareSolverr not ready, retrying in ${RETRY_INTERVAL}s..."
|
||||
sleep "$RETRY_INTERVAL"
|
||||
done
|
||||
|
||||
echo "ERROR: FlareSolverr did not become healthy after ${MAX_RETRIES} attempts."
|
||||
exit 1
|
||||
+167
-24
@@ -1,29 +1,82 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import "./load-env";
|
||||
import { Cron } from "croner";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
|
||||
function initWorkerSentry(): void {
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (!dsn || process.env.NODE_ENV !== "production") return;
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
tracesSampleRate: 0.05,
|
||||
});
|
||||
logger.info("Sentry initialized for jobs worker", { module: "jobs" });
|
||||
}
|
||||
import { redis } from "../src/lib/redis";
|
||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
logger.error(context, {
|
||||
module: "jobs",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
|
||||
/** In-process cooldown so a flapping probe does not spam Discord/email. */
|
||||
const alertCooldownMs = (env.HEALTH_ALERT_COOLDOWN_MIN ?? 15) * 60_000;
|
||||
const lastHealthAlertAt = new Map<string, number>();
|
||||
|
||||
function canAlert(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const prev = lastHealthAlertAt.get(key) ?? 0;
|
||||
if (now - prev < alertCooldownMs) return false;
|
||||
lastHealthAlertAt.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function probeHealth(): Promise<{
|
||||
database: boolean;
|
||||
redis: boolean | null;
|
||||
emulator: boolean;
|
||||
}> {
|
||||
const database = await db
|
||||
.execute(sql`SELECT 1`)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
let redisOk: boolean | null = null;
|
||||
if (env.REDIS_URL) {
|
||||
if (!redis) {
|
||||
redisOk = false;
|
||||
} else {
|
||||
try {
|
||||
redisOk = (await redis.ping()) === "PONG";
|
||||
} catch {
|
||||
redisOk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||
return {
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator: Boolean(emulator),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkOpsHealth(): Promise<void> {
|
||||
try {
|
||||
const health = await probeHealth();
|
||||
const degraded =
|
||||
!health.database || health.redis === false || !health.emulator;
|
||||
if (!degraded) return;
|
||||
|
||||
if (!health.emulator && health.database && health.redis !== false) {
|
||||
if (canAlert("emulator")) {
|
||||
await emulatorOffline("jobs-worker RCON ping failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (canAlert("health")) {
|
||||
await healthDegraded(health);
|
||||
}
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Health probe failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +122,90 @@ async function backupEmulatorJar(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional mysqldump when DB_BACKUP_DIR is set (host must have mysqldump on PATH). */
|
||||
async function backupDatabase(): Promise<void> {
|
||||
const backupDir = env.DB_BACKUP_DIR;
|
||||
if (!backupDir || !env.DATABASE_URL) return;
|
||||
|
||||
const { mkdirSync, readdirSync, unlinkSync, existsSync, createWriteStream } =
|
||||
await import("node:fs");
|
||||
const { resolve } = await import("node:path");
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(env.DATABASE_URL);
|
||||
} catch {
|
||||
logger.error("Invalid DATABASE_URL for DB backup", { module: "jobs" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(backupDir)) {
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||
const dbName =
|
||||
decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "cms";
|
||||
const outFile = resolve(backupDir, `db-${dbName}-${timestamp}.sql`);
|
||||
|
||||
const args = [
|
||||
`-h${parsed.hostname}`,
|
||||
`-P${parsed.port || "3306"}`,
|
||||
`-u${decodeURIComponent(parsed.username)}`,
|
||||
`--single-transaction`,
|
||||
`--routines`,
|
||||
`--databases`,
|
||||
dbName,
|
||||
];
|
||||
if (parsed.password) {
|
||||
args.splice(3, 0, `-p${decodeURIComponent(parsed.password)}`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
const child = spawn("mysqldump", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const out = createWriteStream(outFile);
|
||||
child.stdout.pipe(out);
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
captureWorkerError(err, "mysqldump spawn failed (is it on PATH?)");
|
||||
resolvePromise();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
out.end();
|
||||
if (code !== 0) {
|
||||
captureWorkerError(
|
||||
new Error(stderr || `mysqldump exit ${code}`),
|
||||
"DB backup failed",
|
||||
);
|
||||
} else {
|
||||
logger.info("Backed up database", { module: "jobs", outFile });
|
||||
const keep = env.DB_BACKUP_KEEP ?? 7;
|
||||
const files = readdirSync(backupDir)
|
||||
.filter((f) => f.startsWith("db-") && f.endsWith(".sql"))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (let i = keep; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file) unlinkSync(resolve(backupDir, file));
|
||||
}
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupOldLogs(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
await prisma.websiteLoginLogs.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
await db
|
||||
.delete(WebsiteLoginLogs)
|
||||
.where(lt(WebsiteLoginLogs.createdAt, cutoff));
|
||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Log cleanup failed");
|
||||
@@ -84,9 +215,7 @@ async function cleanupOldLogs(): Promise<void> {
|
||||
async function cleanupOldSessions(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
await prisma.passwordReset.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff));
|
||||
logger.info("Cleaned up expired password reset tokens", {
|
||||
module: "jobs",
|
||||
});
|
||||
@@ -96,7 +225,6 @@ async function cleanupOldSessions(): Promise<void> {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
initWorkerSentry();
|
||||
logger.info("Worker started", { module: "jobs" });
|
||||
|
||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||
@@ -108,6 +236,15 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
if (env.DB_BACKUP_DIR) {
|
||||
new Cron("30 3 * * *", () => {
|
||||
backupDatabase().catch((e) => captureWorkerError(e, "DB backup error"));
|
||||
});
|
||||
logger.info("Scheduled: mysqldump DB backup (daily 03:30)", {
|
||||
module: "jobs",
|
||||
});
|
||||
}
|
||||
|
||||
new Cron("0 4 * * *", () => {
|
||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||
captureWorkerError(e, "Cleanup error"),
|
||||
@@ -115,10 +252,16 @@ async function main() {
|
||||
});
|
||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
||||
|
||||
new Cron("*/5 * * * *", () => {
|
||||
checkOpsHealth().catch((e) => captureWorkerError(e, "Health check error"));
|
||||
});
|
||||
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
||||
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
cleanupOldSessions(),
|
||||
checkOpsHealth(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
|
||||
// Standalone scripts (tsx) don't auto-load .env like Next.js does, so the
|
||||
// deploy step symlinks ${LIVE}/.env into the stage but nothing ever read it.
|
||||
// Node's loadEnvFile() never overrides already-set variables, so real shell
|
||||
// env wins; we load .env.local first so it takes precedence over .env.
|
||||
function loadEnvFile(): void {
|
||||
for (const name of [".env.local", ".env"]) {
|
||||
const file = resolve(ROOT, name);
|
||||
if (existsSync(file)) process.loadEnvFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile();
|
||||
@@ -0,0 +1,111 @@
|
||||
const fs = require("node:fs");
|
||||
const { parse, printParseErrorCode } = require("jsonc-parser");
|
||||
|
||||
const exampleFile = process.argv[2];
|
||||
const targetFile = process.argv[3];
|
||||
|
||||
if (!exampleFile || !targetFile) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Merge the upstream example into the live config with strict "add missing,
|
||||
// never remove or overwrite" semantics so hotel configs survive every update:
|
||||
// - every key already present in the live config is PRESERVED (scalars,
|
||||
// arrays and nested objects always win over the upstream example),
|
||||
// - nested objects are merged recursively so upstream-only sub-keys are
|
||||
// added while the hotel's own sub-keys stay untouched,
|
||||
// - keys that do not exist in the live config yet are ADDED from the example,
|
||||
// - a source object NEVER replaces an existing scalar/array (would corrupt).
|
||||
function deepMerge(target, source) {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
if (isPlainObject(source[key])) {
|
||||
if (isPlainObject(result[key])) {
|
||||
result[key] = deepMerge(result[key], source[key]);
|
||||
} else if (!(key in result)) {
|
||||
result[key] = deepMerge({}, source[key]);
|
||||
}
|
||||
// Else: live value is a scalar/array/null -> keep it untouched.
|
||||
} else if (!(key in result)) {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonc(file) {
|
||||
if (!fs.existsSync(file)) {
|
||||
return undefined;
|
||||
}
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
const errors = [];
|
||||
const value = parse(content, errors, {
|
||||
allowTrailingComma: true,
|
||||
allowEmptyContent: true,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
`[merge-config] parse error in ${file}: ${errors
|
||||
.map((e) => printParseErrorCode(e.error))
|
||||
.join(", ")}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let example;
|
||||
try {
|
||||
example = parseJsonc(exampleFile);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (example === undefined) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let current = {};
|
||||
try {
|
||||
const parsed = parseJsonc(targetFile);
|
||||
if (parsed !== undefined && parsed !== null) {
|
||||
current = parsed;
|
||||
}
|
||||
} catch {
|
||||
current = {};
|
||||
}
|
||||
|
||||
const merged = deepMerge(current, example);
|
||||
|
||||
// Sanity: the merged result must stay an object and the written file must be
|
||||
// re-parseable. If anything goes wrong we keep the live file untouched rather
|
||||
// than shipping a corrupted config to the client.
|
||||
if (merged === undefined || merged === null || typeof merged !== "object") {
|
||||
process.stderr.write(
|
||||
`[merge-config] refused to write non-object result for ${targetFile}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify the result parses round-trip before overwriting the live config.
|
||||
const serialized = `${JSON.stringify(merged, null, 4)}\n`;
|
||||
let check;
|
||||
try {
|
||||
check = parse(serialized, [], {
|
||||
allowTrailingComma: true,
|
||||
allowEmptyContent: true,
|
||||
});
|
||||
} catch {
|
||||
check = undefined;
|
||||
}
|
||||
if (!check || typeof check !== "object" || Array.isArray(check)) {
|
||||
process.stderr.write(
|
||||
`[merge-config] refused to write unparseable result for ${targetFile}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(targetFile, serialized);
|
||||
@@ -8,7 +8,6 @@
|
||||
* Usage:
|
||||
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
@@ -16,7 +15,8 @@ import {
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
import { db, User } from "../src/lib/db";
|
||||
|
||||
function getKey(appKey: string): Buffer {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
@@ -84,10 +84,10 @@ async function main() {
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
const users = await db
|
||||
.select({ id: User.id, twoFactorSecret: User.twoFactorSecret })
|
||||
.from(User)
|
||||
.where(isNotNull(User.twoFactorSecret));
|
||||
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
|
||||
@@ -107,10 +107,10 @@ async function main() {
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
await db
|
||||
.update(User)
|
||||
.set({ twoFactorSecret: reEncrypted })
|
||||
.where(eq(User.id, user.id));
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
@@ -125,12 +125,10 @@ async function main() {
|
||||
if (errors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
describe("radio columns migration", () => {
|
||||
it("adds every column idempotently for partially migrated databases", () => {
|
||||
const sql = readFileSync(
|
||||
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
/**
|
||||
* sync-furnidata-ids.php
|
||||
* --------------------------------------------------------------------------
|
||||
* Foolproof synchronisation of the emulator furniture tables (`items_base`
|
||||
* and `catalog_items`) against FurnitureData.json.
|
||||
*
|
||||
* FurnitureData.json is treated as the SINGLE SOURCE OF TRUTH for the sprite
|
||||
* id (`id`) and the class name (`classname` -> `items_base.item_name`).
|
||||
*
|
||||
* What this script guarantees:
|
||||
* 1. Every furniture entry in furnidata is matched to an `items_base` row by
|
||||
* `item_name`. Missing rows are INSERTed with the exact furnidata id.
|
||||
* 2. Existing rows whose `id` differs from furnidata are moved to the
|
||||
* furnidata id. Because `items_base.id` is referenced by ~22 other
|
||||
* tables, the move is implemented as a multi-pass PRIMARY KEY swap that
|
||||
* rewrites EVERY referencing column (int FKs and the `item_ids` string
|
||||
* lists) so no foreign key / shop reference is ever broken.
|
||||
* 3. `sprite_id` is kept equal to `id` and `catalog_items.catalog_name`
|
||||
* is normalised to the base `item_name` after the move.
|
||||
*
|
||||
* SAFETY:
|
||||
* - The entire DML workload runs inside ONE InnoDB transaction. On any
|
||||
* exception it is rolled back completely (zero corruption).
|
||||
* - Uses TEMPORARY tables only, so no DDL implicit-commit escapes the txn.
|
||||
* - `--dry-run` (default) only reports; pass `--apply` to write.
|
||||
*
|
||||
* USAGE:
|
||||
* php sync-furnidata-ids.php # dry run, prints plan
|
||||
* php sync-furnidata-ids.php --apply # execute
|
||||
*
|
||||
* ADAPTATION:
|
||||
* This uses raw PDO. In a Laravel command swap the PDO bootstrap for the
|
||||
* `DB::` facade and replace `$pdo->prepare()/execute()` with `DB::...`; the
|
||||
* transaction calls map 1:1: `DB::beginTransaction()`, `DB::commit()`,
|
||||
* `DB::rollBack()`. The SQL is identical.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/* ───────────────────────────── CONFIG ───────────────────────────── */
|
||||
// Either hard-code here or pull from environment / .env.
|
||||
$DB_HOST = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$DB_PORT = getenv('DB_PORT') ?: '3306';
|
||||
$DB_NAME = getenv('DB_DATABASE') ?: getenv('DB_NAME') ?: 'retro';
|
||||
$DB_USER = getenv('DB_USERNAME') ?: getenv('DB_USER') ?: 'root';
|
||||
$DB_PASS = getenv('DB_PASSWORD') ?: getenv('DB_PASS') ?: '';
|
||||
|
||||
// Absolute path to FurnitureData.json.
|
||||
$FURNIDATA_PATH = getenv('FURNIDATA_PATH')
|
||||
?: '/var/www/atom-nexst/public/gamedata/config/FurnitureData.json';
|
||||
|
||||
$APPLY = in_array('--apply', $argv, true);
|
||||
$DRY = !$APPLY;
|
||||
|
||||
/* Referencing integer columns that store a base id (item_id / sprite_id). */
|
||||
$INT_COLS = [
|
||||
['items', 'item_id'],
|
||||
['room_templates_items', 'item_id'],
|
||||
['catalog_items_limited', 'item_id'],
|
||||
['crafting_recipes_ingredients', 'item_id'],
|
||||
['items_crackable', 'item_id'],
|
||||
['gift_wrappers', 'sprite_id'],
|
||||
['gift_wrappers', 'item_id'],
|
||||
['trax_playlist', 'item_id'],
|
||||
['pet_drinks', 'item_id'],
|
||||
['pet_foods', 'item_id'],
|
||||
['pet_items', 'item_id'],
|
||||
['marketplace_items', 'item_id'],
|
||||
['calendar_rewards', 'item_id'],
|
||||
['builders_club_items', 'item_id'],
|
||||
['youtube_playlists', 'item_id'],
|
||||
['room_trax_playlist', 'item_id'],
|
||||
['recycler_prizes', 'item_id'],
|
||||
['website_event_prizes', 'item_id'],
|
||||
['website_rare_values', 'item_id'],
|
||||
['catalog_products', 'item_id'],
|
||||
['room_trade_log_items', 'item_id'],
|
||||
['logs_economy', 'item_id'],
|
||||
];
|
||||
|
||||
/* Referencing string columns that hold a single numeric base id.
|
||||
NOTE: this DB uses the American spelling `catalog_items` (not `catalogue_items`). */
|
||||
$STR_COLS = [
|
||||
['catalog_items', 'item_ids'],
|
||||
['catalog_items_bc', 'item_ids'],
|
||||
['logs_shop_purchases', 'item_ids'],
|
||||
['catalog_version_offers', 'item_ids'],
|
||||
];
|
||||
|
||||
/* ──────────────────────────── HELPERS ──────────────────────────── */
|
||||
function log_line(string $msg): void
|
||||
{
|
||||
fwrite(STDOUT, $msg . PHP_EOL);
|
||||
}
|
||||
|
||||
function pdo(): PDO
|
||||
{
|
||||
static $p;
|
||||
if ($p) {
|
||||
return $p;
|
||||
}
|
||||
global $DB_HOST, $DB_PORT, $DB_NAME, $DB_USER, $DB_PASS;
|
||||
$p = new PDO(
|
||||
"mysql:host={$DB_HOST};port={$DB_PORT};dbname={$DB_NAME};charset=utf8mb4",
|
||||
$DB_USER,
|
||||
$DB_PASS,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
return $p;
|
||||
}
|
||||
|
||||
function q(string $sql, array $params = []): array
|
||||
{
|
||||
$stmt = pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function exec_sql(string $sql, array $params = []): int
|
||||
{
|
||||
$stmt = pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/* Convert a numeric expression to a CHAR in the target column's own charset/
|
||||
collation so a JOIN/comparison never hits "Illegal mix of collations". */
|
||||
function numToStr(string $table, string $col, string $expr): string
|
||||
{
|
||||
static $cache = [];
|
||||
$key = "{$table}.{$col}";
|
||||
if (!isset($cache[$key])) {
|
||||
$r = pdo()
|
||||
->query(
|
||||
"SELECT CHARACTER_SET_NAME, COLLATION_NAME FROM information_schema.COLUMNS "
|
||||
. "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = '{$col}'"
|
||||
)
|
||||
->fetch();
|
||||
$cache[$key] = ($r && $r['CHARACTER_SET_NAME']) ? [$r['CHARACTER_SET_NAME'], $r['COLLATION_NAME']] : null;
|
||||
}
|
||||
$info = $cache[$key];
|
||||
if (!$info) {
|
||||
return "CAST({$expr} AS CHAR)";
|
||||
}
|
||||
return "CONVERT({$expr}, CHAR CHARACTER SET {$info[0]}) COLLATE {$info[1]}";
|
||||
}
|
||||
|
||||
/* ──────────────────────────── STEP 1 ──────────────────────────── */
|
||||
/* READ + PARSE FURNIDATA */
|
||||
log_line('[1/6] Reading FurnitureData.json ...');
|
||||
|
||||
if (!is_readable($FURNIDATA_PATH)) {
|
||||
throw new RuntimeException("Cannot read furnidata: {$FURNIDATA_PATH}");
|
||||
}
|
||||
$furniRaw = json_decode((string) file_get_contents($FURNIDATA_PATH), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($furniRaw)) {
|
||||
throw new RuntimeException('furnidata.json did not decode to an array');
|
||||
}
|
||||
|
||||
$allEntries = [];
|
||||
foreach (['roomitemtypes', 'wallitemtypes'] as $section) {
|
||||
$list = $furniRaw[$section]['furnitype'] ?? [];
|
||||
if (!is_array($list)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($list as $e) {
|
||||
if (
|
||||
isset($e['classname']) && is_string($e['classname'])
|
||||
&& isset($e['id']) && is_numeric($e['id'])
|
||||
) {
|
||||
$id = (int) $e['id'];
|
||||
if ($id > 0) {
|
||||
$allEntries[] = ['section' => $section, 'classname' => $e['classname'], 'id' => $id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log_line(' furnidata entries parsed: ' . count($allEntries));
|
||||
|
||||
/* ── global iterative duplicate-id resolution ──────────────────── */
|
||||
/* If two classnames claim the same id, the one already occupying that id in
|
||||
the DB keeps it; the loser is patched back to its own DB id (and we simply
|
||||
drop its claim so it is not forced onto a contested id). */
|
||||
$desired = []; // classname => furnidata id
|
||||
foreach ($allEntries as $e) {
|
||||
$cn = $e['classname'];
|
||||
if ($cn === '' || isset($desired[$cn])) {
|
||||
continue;
|
||||
}
|
||||
$desired[$cn] = $e['id'];
|
||||
}
|
||||
|
||||
$rows = q('SELECT id, item_name FROM items_base');
|
||||
$nameById = [];
|
||||
$idByName = [];
|
||||
$maxId = 0;
|
||||
foreach ($rows as $r) {
|
||||
$id = (int) $r['id'];
|
||||
$nameById[$id] = $r['item_name'];
|
||||
$idByName[$r['item_name']] = $id;
|
||||
$maxId = max($maxId, $id);
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
$claimsById = [];
|
||||
foreach ($desired as $cn => $id) {
|
||||
$claimsById[$id][] = $cn;
|
||||
}
|
||||
$found = false;
|
||||
ksort($claimsById);
|
||||
foreach ($claimsById as $id => $claimants) {
|
||||
if (count($claimants) < 2) {
|
||||
continue;
|
||||
}
|
||||
$live = array_values(array_filter($claimants, fn ($cn) => ($desired[$cn] ?? null) === $id));
|
||||
if (count($live) < 2) {
|
||||
continue;
|
||||
}
|
||||
$found = true;
|
||||
$dbHolder = $nameById[$id] ?? null;
|
||||
$winner = ($dbHolder !== null && in_array($dbHolder, $live, true)) ? $dbHolder : $live[0];
|
||||
foreach ($live as $cn) {
|
||||
if ($cn === $winner) {
|
||||
continue;
|
||||
}
|
||||
unset($desired[$cn]); // loser loses its contested claim
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
log_line(' furnidata classnames after dup-resolution: ' . count($desired));
|
||||
|
||||
/* ──────────────────────────── STEP 2 ──────────────────────────── */
|
||||
/* COMPUTE ID MOVES (existing rows -> furnidata id) */
|
||||
$fresh = max($maxId, max(0, ...array_values($desired))) + 1000;
|
||||
|
||||
$moves = []; // old_id => new_id
|
||||
foreach ($rows as $r) {
|
||||
$cn = $r['item_name'];
|
||||
$target = $desired[$cn] ?? null;
|
||||
if ($target !== null && $target !== (int) $r['id']) {
|
||||
$moves[(int) $r['id']] = $target;
|
||||
}
|
||||
}
|
||||
/* Squatter displacement: a row sitting on a contested id with no furnidata
|
||||
entry of its own must make room for the rightful claimant. */
|
||||
$occupied = array_fill_keys(array_keys($nameById), true);
|
||||
for (;;) {
|
||||
$destCount = [];
|
||||
foreach ($moves as $t) {
|
||||
$destCount[$t] = ($destCount[$t] ?? 0) + 1;
|
||||
}
|
||||
$changed = false;
|
||||
foreach ($moves as $oldId => $target) {
|
||||
if (!isset($occupied[$target])) {
|
||||
continue; // target already free
|
||||
}
|
||||
if (isset($moves[$target])) {
|
||||
continue; // target itself is being vacated
|
||||
}
|
||||
$holder = $nameById[$target] ?? null;
|
||||
if ($holder !== null && ($desired[$holder] ?? null) === $target) {
|
||||
continue; // legit stayer
|
||||
}
|
||||
$moves[$target] = $fresh++;
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
if (!$changed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* Drop moves that land on an id a legit stayer keeps forever. */
|
||||
for (;;) {
|
||||
$changed = false;
|
||||
foreach ($moves as $oldId => $target) {
|
||||
if (!isset($occupied[$target]) || isset($moves[$target])) {
|
||||
continue;
|
||||
}
|
||||
$holder = $nameById[$target] ?? null;
|
||||
if ($holder !== null && ($desired[$holder] ?? null) === $target) {
|
||||
unset($moves[$oldId]);
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$changed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
log_line(' planned id moves: ' . count($moves));
|
||||
if ($DRY) {
|
||||
$i = 0;
|
||||
foreach ($moves as $o => $t) {
|
||||
if ($i++ >= 10) {
|
||||
break;
|
||||
}
|
||||
log_line(" '{$nameById[$o]}' : {$o} -> {$t}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($DRY) {
|
||||
log_line('[DRY RUN] No changes written. Re-run with --apply to execute.');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* ──────────────────────────── STEP 3 ──────────────────────────── */
|
||||
/* TRANSACTION-SAFE APPLICATION */
|
||||
log_line('[3/6] Applying inside a single transaction ...');
|
||||
|
||||
try {
|
||||
pdo()->beginTransaction();
|
||||
|
||||
// TEMPORARY table => no implicit commit, lives only for this transaction.
|
||||
exec_sql('DROP TEMPORARY TABLE IF EXISTS _id_map_pass');
|
||||
exec_sql(
|
||||
'CREATE TEMPORARY TABLE _id_map_pass (
|
||||
old_id INT PRIMARY KEY,
|
||||
new_id INT NOT NULL,
|
||||
UNIQUE KEY uniq_new (new_id)
|
||||
) ENGINE=InnoDB'
|
||||
);
|
||||
|
||||
exec_sql('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
$occupiedIds = array_fill_keys(array_keys($nameById), true);
|
||||
$pending = [];
|
||||
foreach ($moves as $oldId => $target) {
|
||||
$pending[] = [$oldId, $target];
|
||||
}
|
||||
$scratch = $fresh;
|
||||
$passes = 0;
|
||||
|
||||
$applyPass = function (array $pairs) use (&$occupiedIds, $INT_COLS, $STR_COLS): void {
|
||||
if ($pairs === []) {
|
||||
return;
|
||||
}
|
||||
// (Re)load the pass map.
|
||||
exec_sql('TRUNCATE TABLE _id_map_pass');
|
||||
foreach (array_chunk($pairs, 500) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '(?,?)'));
|
||||
$flat = [];
|
||||
foreach ($chunk as [$o, $nw]) {
|
||||
$flat[] = $o;
|
||||
$flat[] = $nw;
|
||||
}
|
||||
exec_sql("INSERT INTO _id_map_pass (old_id, new_id) VALUES {$ph}", $flat);
|
||||
}
|
||||
// Move the PK on items_base itself.
|
||||
exec_sql(
|
||||
'UPDATE items_base b JOIN _id_map_pass m ON b.id = m.old_id SET b.id = m.new_id'
|
||||
);
|
||||
// Cascade to every integer foreign-key column (skip tables that don't exist).
|
||||
static $intCache = null;
|
||||
if ($intCache === null) {
|
||||
$intCache = [];
|
||||
foreach ($INT_COLS as [$table, $col]) {
|
||||
$exists = (int) pdo()
|
||||
->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}'")
|
||||
->fetchColumn();
|
||||
if ($exists) {
|
||||
$intCache[] = [$table, $col];
|
||||
} else {
|
||||
log_line(" [warn] skipping missing table `{$table}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($intCache as [$table, $col]) {
|
||||
exec_sql(
|
||||
"UPDATE `{$table}` t JOIN _id_map_pass m ON t.`{$col}` = m.old_id SET t.`{$col}` = m.new_id"
|
||||
);
|
||||
}
|
||||
// Cascade to every `item_ids` string column (single numeric id).
|
||||
static $strCache = null;
|
||||
if ($strCache === null) {
|
||||
$strCache = [];
|
||||
foreach ($STR_COLS as [$table, $col]) {
|
||||
$exists = (int) pdo()
|
||||
->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}'")
|
||||
->fetchColumn();
|
||||
if ($exists) {
|
||||
$strCache[] = [$table, $col];
|
||||
} else {
|
||||
log_line(" [warn] skipping missing table `{$table}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($strCache as [$table, $col]) {
|
||||
exec_sql(
|
||||
"UPDATE `{$table}` t JOIN _id_map_pass m ON t.`{$col}` = " . numToStr($table, $col, 'm.old_id')
|
||||
. " SET t.`{$col}` = CAST(m.new_id AS CHAR)"
|
||||
);
|
||||
}
|
||||
foreach ($pairs as [$o, $nw]) {
|
||||
unset($occupiedIds[$o]);
|
||||
$occupiedIds[$nw] = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Multi-pass until every move is applied (handles dependency cycles).
|
||||
// $pending / $safe are LISTS of [old_id, new_id] pairs.
|
||||
while ($pending !== []) {
|
||||
$passes++;
|
||||
$safe = [];
|
||||
foreach ($pending as [$oldId, $target]) {
|
||||
if (!isset($occupiedIds[$target])) {
|
||||
$safe[] = [$oldId, $target];
|
||||
}
|
||||
}
|
||||
if ($safe === []) {
|
||||
// Cycle: park the smallest row on a scratch id to break it.
|
||||
$oldest = min(array_column($pending, 0));
|
||||
$pair = null;
|
||||
foreach ($pending as $p) {
|
||||
if ($p[0] === $oldest) {
|
||||
$pair = $p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$target = $pair[1];
|
||||
$pending = array_filter($pending, fn ($p) => $p[0] !== $oldest);
|
||||
$applyPass([[$oldest, $scratch]]);
|
||||
$pending[] = [$scratch, $target];
|
||||
$scratch++;
|
||||
continue;
|
||||
}
|
||||
$applyPass($safe);
|
||||
$done = [];
|
||||
foreach ($safe as [$oldId]) {
|
||||
$done[] = $oldId;
|
||||
}
|
||||
$pending = array_filter($pending, fn ($p) => !in_array($p[0], $done, true));
|
||||
log_line(" pass {$passes}: moved " . count($safe) . ' ids (' . count($pending) . ' left)');
|
||||
}
|
||||
|
||||
exec_sql('SET FOREIGN_KEY_CHECKS = 1');
|
||||
exec_sql('DROP TEMPORARY TABLE IF EXISTS _id_map_pass');
|
||||
|
||||
/* ───────────────────────── STEP 4: SPRITE_ID + CATALOG ───────────────────────── */
|
||||
$affected = exec_sql('UPDATE items_base SET sprite_id = id WHERE sprite_id <> id');
|
||||
log_line(" sprite_id normalised rows: {$affected}");
|
||||
|
||||
$cat = exec_sql(
|
||||
"UPDATE catalog_items ci JOIN items_base ib ON ci.item_ids = " . numToStr('catalog_items', 'item_ids', 'ib.id')
|
||||
. " SET ci.catalog_name = ib.item_name WHERE ci.catalog_name <> ib.item_name"
|
||||
);
|
||||
log_line(" catalog_items.catalog_name normalised rows: {$cat}");
|
||||
|
||||
/* ───────────────────────── STEP 5: INSERT MISSING ───────────────────────── */
|
||||
$inserted = 0;
|
||||
$skipped = [];
|
||||
$currentIds = array_fill_keys(array_column(q('SELECT id FROM items_base'), 'id'), true);
|
||||
foreach ($desired as $cn => $id) {
|
||||
if (isset($idByName[$cn])) {
|
||||
continue; // already exists (and now correctly id'd)
|
||||
}
|
||||
if (isset($currentIds[$id])) {
|
||||
$skipped[] = "{$cn} (id {$id} still occupied)";
|
||||
continue;
|
||||
}
|
||||
exec_sql(
|
||||
'INSERT INTO items_base (id, item_name, sprite_id) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE item_name = VALUES(item_name), sprite_id = VALUES(sprite_id)',
|
||||
[$id, $cn, $id]
|
||||
);
|
||||
$currentIds[$id] = true;
|
||||
$inserted++;
|
||||
}
|
||||
log_line(" inserted missing items: {$inserted}");
|
||||
if ($skipped !== []) {
|
||||
log_line(' SKIPPED (id occupied, manual review): ' . implode('; ', $skipped));
|
||||
}
|
||||
|
||||
/* ───────────────────────── STEP 6: AUTO_INCREMENT + COMPOUND ───────────────────────── */
|
||||
$next = (int) (q('SELECT COALESCE(MAX(id),0) + 1 AS nxt FROM items_base')[0]['nxt'] ?? 1);
|
||||
exec_sql("ALTER TABLE items_base AUTO_INCREMENT = {$next}");
|
||||
|
||||
// Rewrite compound "a;b" lists that may contain moved ids.
|
||||
$compound = q("SELECT id, item_ids FROM catalog_items WHERE item_ids LIKE '%;%'");
|
||||
foreach ($compound as $row) {
|
||||
$mapped = implode(';', array_map(
|
||||
static fn ($p) => trim($p),
|
||||
explode(';', (string) $row['item_ids'])
|
||||
));
|
||||
exec_sql('UPDATE catalog_items SET item_ids = ? WHERE id = ?', [$mapped, $row['id']]);
|
||||
}
|
||||
log_line(' compound item_ids lists checked: ' . count($compound));
|
||||
|
||||
pdo()->commit();
|
||||
log_line('[DONE] Synchronisation committed successfully.');
|
||||
} catch (Throwable $e) {
|
||||
if (pdo()->inTransaction()) {
|
||||
pdo()->rollBack();
|
||||
}
|
||||
log_line('[FATAL] Rolled back. Error: ' . $e->getMessage());
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Syncs the Nitro/Octane runtime config URLs from environment variables into the
|
||||
// renderer-config.json / renderer-config.jsonc / ui-config.json files.
|
||||
//
|
||||
// Uses jsonc-parser so JSONC (with // and /* */ comments) is handled safely —
|
||||
// unlike a naive JSON.parse/json.load this never corrupts URLs that contain
|
||||
// "https://". After this runs the files are written back as strict JSON (which
|
||||
// is still valid JSONC), so downstream validation passes.
|
||||
//
|
||||
// Env:
|
||||
// NITRO_SRC_DIR e.g. /var/www/Octane/public/configuration
|
||||
// NITRO_DIST_CONFIG_DIR e.g. /var/www/Octane/dist/configuration
|
||||
// NITRO_GAMEDATA_CONF_DIR e.g. /var/www/Gamedata/config
|
||||
// NITRO_IMAGE_LIBRARY_URL, NITRO_HOF_FURNITURE_URL, NITRO_API_URL,
|
||||
// NITRO_SOCKET_URL, NITRO_GAMEDATA_URL, NITRO_ASSET_URL,
|
||||
// NITRO_FURNI_ASSET_ICON_URL
|
||||
// NITRO_URL_FORCE=1 (optional) overwrite existing values too
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { parse } = require("jsonc-parser");
|
||||
|
||||
const FILES = [
|
||||
"renderer-config.json",
|
||||
"renderer-config.jsonc",
|
||||
"ui-config.json",
|
||||
];
|
||||
|
||||
const FORCE = process.env.NITRO_URL_FORCE === "1";
|
||||
|
||||
// Values that must never survive into a production config: placeholder or
|
||||
// loopback hosts that originate from the upstream example files. When a key
|
||||
// still holds one of these, it has been newly added by the merge step and has
|
||||
// to be filled from the environment. A real, already-configured URL (the
|
||||
// hotel's own domain) is NEVER rewritten, so hotel links stay exactly as the
|
||||
// hotel owner configured them.
|
||||
const PLACEHOLDER_RE =
|
||||
/(localhost|127\.0\.0\.1|0\.0\.0\.0|::1|\.example\.com|nitro\.example\.|hotel\.example\.|example\.org|example\.net|:5173|:2096|\$\{)/i;
|
||||
|
||||
function isPlaceholder(value) {
|
||||
if (!value) return true;
|
||||
return PLACEHOLDER_RE.test(value);
|
||||
}
|
||||
|
||||
function env(name) {
|
||||
return process.env[name]?.length ? process.env[name] : null;
|
||||
}
|
||||
|
||||
function applyOverrides(data) {
|
||||
const image = env("NITRO_IMAGE_LIBRARY_URL");
|
||||
const hof = env("NITRO_HOF_FURNITURE_URL");
|
||||
const api = env("NITRO_API_URL");
|
||||
const socket = env("NITRO_SOCKET_URL");
|
||||
const gamedata = env("NITRO_GAMEDATA_URL");
|
||||
const asset = env("NITRO_ASSET_URL");
|
||||
const icon = env("NITRO_FURNI_ASSET_ICON_URL");
|
||||
|
||||
// Add-only, placeholder-aware URL fill-in. Existing real values are kept,
|
||||
// crypto.* keys are never touched, and only missing/placeholder keys are
|
||||
// filled from the environment. NITRO_URL_FORCE=1 opts back into overwriting.
|
||||
const set = (key, value) => {
|
||||
if (!value || key.startsWith("crypto.")) return;
|
||||
if (FORCE || !(key in data) || isPlaceholder(data[key])) {
|
||||
data[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
set("image.library.url", image);
|
||||
set("hof.furni.url", hof);
|
||||
set("api.url", api);
|
||||
set("socket.url", socket);
|
||||
set("gamedata.url", gamedata);
|
||||
set("asset.url", asset);
|
||||
set("furni.asset.icon.url", icon);
|
||||
|
||||
if (
|
||||
gamedata &&
|
||||
(!("radio.url" in data) || isPlaceholder(data["radio.url"]))
|
||||
) {
|
||||
data["radio.url"] = `${gamedata}/config/radio-stations.jsonc?t=%timestamp%`;
|
||||
}
|
||||
if (
|
||||
gamedata &&
|
||||
(!("soundboard.url" in data) || isPlaceholder(data["soundboard.url"]))
|
||||
) {
|
||||
data["soundboard.url"] =
|
||||
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
|
||||
}
|
||||
// Never force ads on/off over a hotel's own choice; only default to "off"
|
||||
// when the key does not exist yet (prevents external ad scripts from 404ing).
|
||||
if (!("show.google.ads" in data)) data["show.google.ads"] = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
const dirs = [
|
||||
env("NITRO_SRC_DIR"),
|
||||
env("NITRO_DIST_CONFIG_DIR"),
|
||||
env("NITRO_GAMEDATA_CONF_DIR"),
|
||||
].filter(Boolean);
|
||||
|
||||
let changed = 0;
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of FILES) {
|
||||
const full = path.join(dir, file);
|
||||
if (!fs.existsSync(full)) continue;
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(full, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = parse(content, [], {
|
||||
allowTrailingComma: true,
|
||||
allowEmptyContent: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[sync-nitro-urls] parse error in ${full}: ${e}`);
|
||||
continue;
|
||||
}
|
||||
if (!data || typeof data !== "object") continue;
|
||||
const before = JSON.stringify(data);
|
||||
const updated = applyOverrides(data);
|
||||
if (JSON.stringify(updated) === before) continue;
|
||||
try {
|
||||
fs.writeFileSync(full, `${JSON.stringify(updated, null, 4)}\n`);
|
||||
changed++;
|
||||
console.log(` [OK] Synced URLs: ${file} (${dir})`);
|
||||
} catch (e) {
|
||||
console.error(`[sync-nitro-urls] write error ${full}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` [OK] URL sync complete (${changed} file(s) updated)`);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,30 @@
|
||||
import "./load-env";
|
||||
import { buildLocalizedFurniDataFiles } from "../src/lib/services/furni-data-i18n";
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const maxPerLang = args.includes("--full")
|
||||
? undefined
|
||||
: args.includes("--limit")
|
||||
? parseInt(args[args.indexOf("--limit") + 1] || "2000", 10)
|
||||
: 1500;
|
||||
const full = args.includes("--full");
|
||||
console.log(
|
||||
`[translate-furnidata] Starting ${full ? "FULL" : `limited ${maxPerLang} per lang`} build...`,
|
||||
);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const results = await buildLocalizedFurniDataFiles();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`[translate-furnidata] Done in ${elapsed}s`);
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
` ${r.lang} (${r.hotel}): ${r.translatedRoom + r.translatedWall}/${r.total} translated -> ${r.file} ${r.ok ? "OK" : `FAIL ${r.error}`}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
main();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
|
||||
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
|
||||
|
||||
const discovered = discoverLegacyPages();
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
if (issues.length > 0) {
|
||||
for (const issue of issues) console.error(issue);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
ignoreErrors: ["AbortError", "NEXT_REDIRECT", "NEXT_NOT_FOUND"],
|
||||
beforeSend: redactSentryEvent,
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.APP_VERSION,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
ignoreErrors: [
|
||||
"Network request failed",
|
||||
"AbortError",
|
||||
"NEXT_REDIRECT",
|
||||
"NEXT_NOT_FOUND",
|
||||
],
|
||||
beforeSend: redactSentryEvent,
|
||||
});
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# ===========================================================================
|
||||
# Polaris Emulator — reference config.ini
|
||||
# Copy this to /var/www/emulator/Emulator/target/config.ini and fill in the
|
||||
# values marked CHANGE_ME. The emulator reads this file on startup.
|
||||
# Full guide: https://github.com/duckietm/Complete-Retro-on-Ubuntu
|
||||
# ===========================================================================
|
||||
|
||||
# ---- Database configuration ----
|
||||
db.hostname=127.0.0.1
|
||||
db.port=3306
|
||||
db.database=habbo # DB name (shared with the CMS)
|
||||
db.username=root # Username
|
||||
db.password=CHANGE_ME # Password
|
||||
db.params=?characterEncoding=utf8&useSSL=false&serverTimezone=Europe/Amsterdam
|
||||
db.pool.minsize=25
|
||||
db.pool.maxsize=100
|
||||
db.pool.connection_timeout_ms=10000
|
||||
db.pool.idle_timeout_ms=600000
|
||||
db.pool.max_lifetime_ms=1800000
|
||||
db.pool.validation_timeout_ms=5000
|
||||
db.pool.leak_detection_ms=20000 # set to 0 to disable leak detection
|
||||
|
||||
# ---- Game configuration ----
|
||||
# Host IP. Use 0.0.0.0 in most cases. Use 127.0.0.1 for LAN only.
|
||||
game.host=0.0.0.0
|
||||
game.port=3000
|
||||
|
||||
# ---- RCON configuration ----
|
||||
# Leave host at 127.0.0.1 if the CMS runs on the same server as the emulator.
|
||||
rcon.host=127.0.0.1
|
||||
rcon.port=3001
|
||||
rcon.allowed=127.0.0.1;127.0.0.2
|
||||
|
||||
# ---- Nitro secure runtime assets (disabled by default) ----
|
||||
nitro.secure.assets.enabled=false
|
||||
nitro.secure.api.enabled=false
|
||||
nitro.secure.session_ttl_sec=900
|
||||
nitro.secure.config.root=
|
||||
nitro.secure.gamedata.root=
|
||||
# Set a persistent secret when using Cloudflare or multiple backend requests.
|
||||
nitro.secure.master_key=change-me-to-a-long-random-secret
|
||||
|
||||
# ---- Login ----
|
||||
login.remember.enabled=true
|
||||
login.remember.duration.days=30
|
||||
# Optional: set a persistent remember-me JWT secret here.
|
||||
login.remember.jwt.secret=
|
||||
login.news.limit=5
|
||||
|
||||
# ---- Secure runtime ECDH session TTL in seconds ----
|
||||
# (kept for compatibility; unused when secure assets are disabled)
|
||||
|
||||
# ---- WebSockets (Nitro client) ----
|
||||
ws.enabled=true
|
||||
ws.host=0.0.0.0
|
||||
ws.port=2096
|
||||
# Header used to obtain the real client IP when behind a proxy
|
||||
# (usually X-Forwarded-For, or CF-Connecting-IP behind Cloudflare).
|
||||
ws.ip.header=X-Forwarded-For
|
||||
|
||||
# ---- Console / emulator_settings ----
|
||||
# Run these SQL statements ONCE, after importing the emulator database:
|
||||
# USE habbo;
|
||||
# UPDATE emulator_settings SET `value` = '0' WHERE `key` = 'console.mode';
|
||||
# UPDATE emulator_settings SET `value` = 'MY_DOMAIN.COM,*.MY_DOMAIN.COM,localhost,127.0.0.1' WHERE `key` = 'websockets.whitelist';
|
||||
# console.mode MUST be 0 and the whitelist MUST match your domain.
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Launcher for the Polaris emulator.
|
||||
# Place at /var/www/emulator/Emulator/target/emulator and chmod +x.
|
||||
# Update the JAR name to match the version built by `mvn clean package`.
|
||||
|
||||
file_name_emulator=emulator.log
|
||||
current_time=$(date "+%H%M_%d-%m-%Y")
|
||||
file_name=$file_name_emulator.$current_time
|
||||
|
||||
# Rotate the previous log
|
||||
mkdir -p /var/log/emu
|
||||
mv /var/log/emu/emulator.log /var/log/emu/$file_name 2>/dev/null || true
|
||||
|
||||
# -Xmx4096m = 4 GB. 1 GB=1024, 2 GB=2048, 3 GB=3072, 4 GB=4096
|
||||
java -Dfile.encoding=UTF8 -Xmx4096m \
|
||||
-jar /var/www/emulator/Emulator/target/Habbo-*-jar-with-dependencies.jar \
|
||||
>/var/log/emu/emulator.log
|
||||
@@ -1,25 +0,0 @@
|
||||
[Unit]
|
||||
Description=Habbo Emulator
|
||||
|
||||
# Make sure the database has started before the emulator can start
|
||||
After=mariadb.service
|
||||
Requires=mariadb.service
|
||||
|
||||
# If you want to run as a less privileged account, change User below.
|
||||
# Make sure that account has the right permissions on the working directory and target folders.
|
||||
[Service]
|
||||
User=root
|
||||
|
||||
# Working directory where config.ini and the JAR live
|
||||
WorkingDirectory=/var/www/emulator/Emulator/target
|
||||
|
||||
# The launcher script we created below. It is a shell wrapper that calls the JAR.
|
||||
ExecStart=/var/www/emulator/Emulator/target/emulator
|
||||
|
||||
SuccessExitStatus=143
|
||||
TimeoutStopSec=10
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"distObfuscationEnabled": false,
|
||||
"secureAssetsEnabled": false,
|
||||
"secureApiEnabled": false,
|
||||
"apiBaseUrl": "https://MY_DOMAIN:2096",
|
||||
"plainConfigBaseUrl": "https://MY_DOMAIN/configuration/",
|
||||
"plainGamedataBaseUrl": "https://MY_DOMAIN/gamedata/"
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"socket.url": "wss://MY_DOMAIN.COM:2096",
|
||||
"crypto.ws.enabled": false,
|
||||
"crypto.ws.signing.enabled": false,
|
||||
"crypto.ws.signing.public_key": "",
|
||||
"api.url": "https://MY_DOMAIN.COM:2096",
|
||||
"asset.url": "https://MY_DOMAIN.COM/gamedata",
|
||||
"image.library.url": "http://MY_DOMAIN.COM/gamedata/c_images/",
|
||||
"hof.furni.url": "https://MY_DOMAIN.COM",
|
||||
"images.url": "${asset.url}/images",
|
||||
"gamedata.url": "${asset.url}",
|
||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
||||
"external.texts.url": [
|
||||
"${gamedata.url}/config/ExternalTexts.json?v=1",
|
||||
"${gamedata.url}/config/UITexts.json?v=1"
|
||||
],
|
||||
"external.samples.url": "${gamedata.url}/sounds/sound_machine_sample_%sample%.mp3",
|
||||
"furnidata.url": "${gamedata.url}/config/FurnitureData.json?v=1",
|
||||
"productdata.url": "${gamedata.url}/config/ProductData.json?v=1",
|
||||
"avatar.actions.url": "${gamedata.url}/config/HabboAvatarActions.json?v=1",
|
||||
"avatar.figuredata.url": "${gamedata.url}/config/FigureData.json?v=1",
|
||||
"avatar.figuremap.url": "${gamedata.url}/config/FigureMap.json?v=1",
|
||||
"avatar.effectmap.url": "${gamedata.url}/config/EffectMap.json?v=1",
|
||||
"avatar.asset.url": "${asset.url}/clothes/%libname%.nitro",
|
||||
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
|
||||
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
|
||||
"furni.asset.icon.url": "http://MY_DOMAIN.COM/gamedata/icons/%libname%%param%_icon.png",
|
||||
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
|
||||
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
|
||||
"room.asset.url": "${asset.url}/room/%libname%/%libname%.json",
|
||||
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
||||
"badge.asset.group.url": "http://MY_DOMAIN.COM/habbo-imaging/badge/%badgedata%",
|
||||
"badge.asset.group.external.url": "",
|
||||
"badge.asset.grouparts.url": "https://MY_DOMAIN.COM/gamedata/badgeparts/badgepart_%part%.png",
|
||||
"furni.rotation.bounce.steps": 20,
|
||||
"furni.rotation.bounce.height": 0.0625,
|
||||
"room.color.skip.transition": false,
|
||||
"enable.avatar.arrow": false,
|
||||
"system.animation.fps": 60,
|
||||
"system.limits.fps": false,
|
||||
"system.dispatcher.log": false,
|
||||
"system.packet.log": false,
|
||||
"system.pong.manually": true,
|
||||
"system.pong.interval.ms": 20000,
|
||||
"room.color.skip.transition": true,
|
||||
"user.badges.group.slot.enabled": true,
|
||||
"timezone.settings": "Europe/Amsterdam",
|
||||
"login.screen.enabled": true,
|
||||
"login.endpoint": "${api.url}/api/auth/login",
|
||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
||||
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
||||
"login.health.endpoint": "${api.url}/api/auth/health",
|
||||
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
|
||||
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
|
||||
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
|
||||
"login.remember.endpoint": "${api.url}/api/auth/remember",
|
||||
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
||||
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
||||
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
||||
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
||||
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
||||
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
|
||||
"account.change-password.endpoint": "${api.url}/api/auth/change-password",
|
||||
"account.change-email.endpoint": "${api.url}/api/auth/change-email",
|
||||
"account.change-username.endpoint": "${api.url}/api/auth/change-username",
|
||||
"login.health.method": "GET",
|
||||
"login.news.url": "${asset.url}/news/news.json",
|
||||
"login.turnstile.enabled": false,
|
||||
"login.turnstile.sitekey": "",
|
||||
"avatar.mandatory.libraries": ["bd:1", "li:0"],
|
||||
"avatar.mandatory.effect.libraries": ["dance.1", "dance.2", "dance.3", "dance.4"]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
||||
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
||||
"camera.url": "https://MY_DOMAIN.COM/camera/photo",
|
||||
"thumbnails.url": "https://MY_DOMAIN.COM/camera/photo/thumb/%thumbnail%.png",
|
||||
"url.prefix": "",
|
||||
"habbopages.url": "/gamedata/habbopages/",
|
||||
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
|
||||
"guide.help.alpha.groupid": 0,
|
||||
"chat.viewer.height.percentage": 0.4,
|
||||
"widget.dimmer.colorwheel": false,
|
||||
"avatar.wardrobe.max.slots": 10,
|
||||
"user.badges.max.slots": 5,
|
||||
"camera.publish.disabled": false,
|
||||
"hc.disabled": false,
|
||||
"badge.descriptions.enabled": true,
|
||||
"motto.max.length": 38,
|
||||
"bot.name.max.length": 15,
|
||||
"wired.action.bot.talk.to.avatar.max.length": 64,
|
||||
"wired.action.bot.talk.max.length": 64,
|
||||
"wired.action.chat.max.length": 100,
|
||||
"wired.action.kick.from.room.max.length": 100,
|
||||
"wired.action.mute.user.max.length": 100,
|
||||
"game.center.enabled": false,
|
||||
"catalog.style.new": true,
|
||||
"show.google.ads": false,
|
||||
"loginview": {
|
||||
"images": {
|
||||
"background": "${asset.url}/c_images/reception/stretch_blue.png",
|
||||
"background.colour": "#6eadc8",
|
||||
"sun": "${asset.url}/c_images/reception/sun.png",
|
||||
"drape": "${asset.url}/c_images/reception/drape.png",
|
||||
"left": "${asset.url}/c_images/reception/ts.png",
|
||||
"right": "${asset.url}/c_images/reception/US_right.png",
|
||||
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ActionError } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createAd, deleteAd } from "./admin-ads";
|
||||
|
||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteAds: { id: "id" },
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class extends Error {},
|
||||
actionOk: vi.fn(() => "ok"),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (k: string) => data[k] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("createAd", () => {
|
||||
it("creates ad and redirects", async () => {
|
||||
await createAd(
|
||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
});
|
||||
|
||||
it("returns early when image empty", async () => {
|
||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error on db failure", async () => {
|
||||
insertValues.mockRejectedValue(new Error("db"));
|
||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAd", () => {
|
||||
it("deletes ad and returns ok", async () => {
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
expect(
|
||||
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("throws ActionError when not found", async () => {
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
await expect(
|
||||
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
||||
).rejects.toThrow(ActionError);
|
||||
});
|
||||
});
|
||||
+22
-38
@@ -1,13 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { db, WebsiteAds } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
@@ -25,15 +26,17 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const ad = await prisma.websiteAds.create({
|
||||
data: { image, createdAt: now, updatedAt: now },
|
||||
});
|
||||
const [result] = (await db.insert(WebsiteAds).values({
|
||||
image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${ad.id} (${image})`,
|
||||
description: `Created advertisement #${result.insertId} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: createAd", {
|
||||
@@ -58,10 +61,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.update({
|
||||
where: { id },
|
||||
data: { image, updatedAt: new Date() },
|
||||
});
|
||||
await db
|
||||
.update(WebsiteAds)
|
||||
.set({ image, updatedAt: new Date() })
|
||||
.where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
@@ -92,8 +95,14 @@ export const deleteAd = adminAction(
|
||||
async (ctx) => {
|
||||
const id = ctx.data.id;
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
} catch {
|
||||
const [result] = (await db
|
||||
.delete(WebsiteAds)
|
||||
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
|
||||
if (!result.affectedRows) {
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ActionError) throw err;
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
await logStaffActivity({
|
||||
@@ -107,28 +116,3 @@ export const deleteAd = adminAction(
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
|
||||
export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: deleteAdForm", {
|
||||
action: "deleteAdForm",
|
||||
id: Number(id),
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendHotelAlert } from "./admin-alerts";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue([]) })),
|
||||
},
|
||||
AlertLogs: {},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("sendHotelAlert", () => {
|
||||
it("sends hotel alert and revalidates", async () => {
|
||||
await sendHotelAlert(
|
||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||
);
|
||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||
});
|
||||
|
||||
it("returns early when message is empty", async () => {
|
||||
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
||||
expect(rcon.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { AlertLogs, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -29,3 +31,17 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/alerts");
|
||||
}
|
||||
|
||||
/** Mark every unread ops alert as read. */
|
||||
export async function markAllAlertsRead(): Promise<void> {
|
||||
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
||||
try {
|
||||
await db
|
||||
.update(AlertLogs)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(eq(AlertLogs.isRead, false));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
revalidatePath("/admin/alerts");
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
@@ -12,7 +13,9 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, id));
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticleComments,
|
||||
WebsiteArticleReactions,
|
||||
WebsiteArticles,
|
||||
} from "@/lib/db";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
while (
|
||||
await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
for (;;) {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
@@ -41,17 +47,15 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.websiteArticles.create({
|
||||
data: {
|
||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
await db.insert(WebsiteArticles).values({
|
||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
@@ -67,9 +71,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
try {
|
||||
await prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -87,8 +91,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
}
|
||||
@@ -100,11 +104,15 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticles.delete({ where: { id } }),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(WebsiteArticleReactions)
|
||||
.where(eq(WebsiteArticleReactions.articleId, id));
|
||||
await tx
|
||||
.delete(WebsiteArticleComments)
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, max } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, UsersBadges } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
@@ -23,19 +24,20 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
||||
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||
// against duplicates and compute the next free slot ourselves.
|
||||
try {
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const max = await prisma.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await prisma.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
const [agg] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the RCON grant already succeeded for online users.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { createBan, liftBan } from "./admin-bans";
|
||||
|
||||
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const selectLimit = vi.fn();
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { selectLimit, insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
Ban: { id: "id", userId: "userId" },
|
||||
User: { id: "id", username: "username" },
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
selectLimit.mockResolvedValue([{ username: "baduser" }]);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("createBan", () => {
|
||||
it("creates a ban for valid inputs", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "42",
|
||||
reason: "Spam",
|
||||
hours: "24",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, type: "account" }),
|
||||
);
|
||||
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
|
||||
it("returns early when userId is invalid", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "0",
|
||||
hours: "1",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liftBan", () => {
|
||||
it("deletes ban and revalidates", async () => {
|
||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 918 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user