AquaTrans and stuff (#131)

This commit is contained in:
Azalea 2025-03-21 18:35:01 -04:00 committed by GitHub
commit 7fb46441f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 1481 additions and 1455 deletions

View File

@ -11,6 +11,7 @@
import { pfp, tooltip } from "./libs/ui"
import { ANNOUNCEMENT } from "./libs/config";
import { t } from "./libs/i18n";
import Transfer from "./pages/Transfer/Transfer.svelte";
console.log(`%c
┏━┓ ┳━┓━┓┏━
@ -76,6 +77,7 @@
<Route path="/u/:username/:game" component={UserHome} />
<Route path="/settings" component={Settings} />
<Route path="/pictures" component={MaiPhoto} />
<Route path="/transfer" component={Transfer} />
</Router>
<style lang="sass">

View File

@ -12,6 +12,11 @@
export let confirm: ConfirmProps | null = null
export let error: string | null
export let loading: boolean = false
function doConfirm(fn?: () => void) {
confirm = null
fn && fn()
}
</script>
{#if confirm}
@ -22,15 +27,9 @@
<div class="actions">
{#if confirm.cancel}
<!-- Svelte LSP is very annoying here -->
<button on:click={() => {
confirm && confirm.cancel && confirm.cancel()
// Set to null
confirm = null
}}>{t('action.cancel')}</button>
<button on:click={() => doConfirm(confirm?.cancel)}>{t('action.cancel')}</button>
{/if}
<button on:click={() => confirm && confirm.confirm()} class:error={confirm.dangerous}>{t('action.confirm')}</button>
<button on:click={() => doConfirm(confirm?.confirm)} class:error={confirm.dangerous}>{t('action.confirm')}</button>
</div>
</div>
</div>

View File

@ -6,6 +6,7 @@
import StatusOverlays from "../StatusOverlays.svelte";
import { GAME } from "../../libs/sdk";
import GameSettingFields from "./GameSettingFields.svelte";
import { download } from "../../libs/ui";
const profileFields = [
['name', t('settings.mai2.name')],
@ -42,15 +43,6 @@
.catch(e => error = e.message)
.finally(() => submitting = "")
}
function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
</script>
<div class="fields" out:fade={FADE_OUT} in:fade={FADE_IN}>

View File

@ -4,13 +4,16 @@
import { DISCORD_INVITE } from "../../libs/config";
export let error: string;
export let expected: boolean = false;
</script>
<div class="overlay" transition:fade>
<div>
<h2 class="error">{t('status.error')}</h2>
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
<span>{t('status.detail', { detail: error })}</span>
{#if !expected}
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
{/if}
<span class="detail">{error}</span>
<div class="actions">
<button on:click={() => location.reload()} class="error">
@ -27,4 +30,10 @@
button
width: 100%
.detail
white-space: pre-line
font-size: 0.9em
line-height: 1.2
opacity: 0.8
</style>

View File

@ -1,3 +1,5 @@
export type Dict = Record<string, any>
export interface TrendEntry {
date: string
rating: number
@ -48,7 +50,7 @@ export interface CardSummary {
export interface ConfirmProps {
title: string
message: string
confirm: () => void
confirm?: () => void
cancel?: () => void
dangerous?: boolean
}

View File

@ -8,13 +8,14 @@ import type {
TrendEntry,
AquaNetUser, GameOption,
UserBox,
UserItem
UserItem,
Dict
} from './generalTypes'
import type { GameName } from './scoring'
interface RequestInitWithParams extends RequestInit {
interface ExtReqInit extends RequestInit {
params?: { [index: string]: string }
localCache?: boolean
json?: any
}
/**
@ -37,188 +38,113 @@ export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) =>
/**
* Fetch with url parameters
*/
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
export function fetchWithParams(input: URL | RequestInfo, init?: ExtReqInit): Promise<Response> {
return fetch(reconstructUrl(input, u => {
u.search = new URLSearchParams(init?.params ?? {}).toString()
}), init)
}
const cache: { [index: string]: any } = {}
/**
* Do something with the response when it's not ok
*
* @param res Response object
*/
async function ensureOk(res: Response) {
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
export async function post(endpoint: string, params: Record<string, any> = {}, init?: RequestInitWithParams): Promise<any> {
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
}
/**
* Post to an endpoint and return the response in JSON while doing error checks
* and handling token (and token expiry) automatically.
*
* @param endpoint The endpoint to post to (e.g., '/pull')
* @param params An object containing the request body or any necessary parameters
* @param init Additional fetch/init configuration
* @returns The JSON response from the server
*/
export async function post(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
return postHelper(endpoint, params, init).then(it => it.json())
}
/**
* Actual impl of post(). This does not return JSON but returns response object.
*/
async function postHelper(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
if (init?.json) {
init.body = JSON.stringify(init.json)
init.headers = { 'Content-Type': 'application/json', ...init.headers }
init.json = undefined
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
const res = await fetchWithParams(AQUA_HOST + endpoint, { method: 'POST', params, ...init })
.catch(e => { console.error(e); throw new Error("Network error") })
await ensureOk(res)
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
return res
}
export async function get(endpoint: string, params:any,init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
const decoder = new TextDecoder()
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(init)]
if (cached) return cached
/**
* Post with a stream response. Similar to post(), but the response will stream messages to onChunk.
*/
export async function postStream(endpoint: string, params: Dict = {}, onChunk: (data: any) => void, init?: ExtReqInit): Promise<void> {
const res = await postHelper(endpoint, params, init)
if (!res.body) {
console.error('Response body is not a stream')
return
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'GET',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
// The response body is a ReadableStream. We'll read chunks as they arrive.
const reader = res.body?.getReader()
if (!reader) return
let buffer = ''
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
// Decode any new data, parse full lines, keep the rest in buffer
buffer += decoder.decode(value, { stream: true })
let fullLines = buffer.split('\n')
buffer = fullLines.pop() ?? ''
for (const line of fullLines) {
if (!line.trim()) continue // skip empty lines
onChunk(JSON.parse(line))
}
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
// If there's leftover data in 'buffer' after stream ends, parse
if (buffer.trim())
onChunk(JSON.parse(buffer.trim()))
} finally {
reader.releaseLock()
}
const ret = res.json()
cache[endpoint + JSON.stringify(init)] = ret
return ret
}
export async function put(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'PUT',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
}
export async function realPost(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
return res.json()
}
/**
@ -229,6 +155,7 @@ export async function realPost(endpoint: string, params: any, init?: RequestInit
async function register(user: { username: string, email: string, password: string, turnstile: string }) {
return await post('/api/v2/user/register', user)
}
async function login(user: { email: string, password: string, turnstile: string }) {
const data = await post('/api/v2/user/login', user)
@ -263,11 +190,11 @@ export const USER = {
export const USERBOX = {
getProfile: (): Promise<{ user: UserBox, items: UserItem[] }> =>
get('/api/v2/game/chu3/user-box', {}),
post('/api/v2/game/chu3/user-box', {}),
setUserBox: (d: { field: string, value: number | string }) =>
post(`/api/v2/game/chu3/user-detail-set`, d),
getUserProfile: (username: string): Promise<UserBox> =>
get(`/api/v2/game/chu3/user-detail`, {username})
post(`/api/v2/game/chu3/user-detail`, {username})
}
export const CARD = {
@ -295,9 +222,9 @@ export const GAME = {
export: (game: GameName): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/export`),
import: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import`, {}, { body: JSON.stringify(data) }),
post(`/api/v2/game/${game}/import`, {}, { json: data }),
importMusicDetail: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import-music-detail`, {}, {body: JSON.stringify(data), headers: {'Content-Type': 'application/json'}}),
post(`/api/v2/game/${game}/import-music-detail`, {}, { json: data }),
setRival: (game: GameName, rivalUserName: string, isAdd: boolean) =>
post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }),
}
@ -317,3 +244,12 @@ export const SETTING = {
detailSet: (game: string, field: string, value: any) =>
post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
}
export const TRANSFER = {
check: (d: AllNetClient): Promise<TrCheckGood> =>
post('/api/v2/transfer/check', {}, { json: d }),
pull: (d: AllNetClient, callback: (data: TrStreamMessage) => void) =>
postStream('/api/v2/transfer/pull', {}, callback, { json: d }),
push: (d: AllNetClient, data: string) =>
post('/api/v2/transfer/push', {}, { json: { client: d, data } }),
}

View File

@ -212,3 +212,53 @@ export function pfp(node: HTMLImageElement, me?: AquaNetUser) {
node.src = me?.profilePicture ? `${AQUA_HOST}/uploads/net/portrait/${me.profilePicture}` : DEFAULT_PFP
node.onerror = e => pfpNotFound(e as Event)
}
export function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
export async function selectJsonFile(): Promise<any> {
return new Promise((resolve, reject) => {
// Create a hidden file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.style.display = 'none';
// Listen for when the user selects a file
input.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return reject(new Error("No file selected"));
}
const file = target.files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const jsonData = JSON.parse(reader.result as string);
resolve(jsonData);
} catch (error) {
reject(new Error("Error parsing JSON: " + error));
}
};
reader.onerror = () => {
reject(new Error("Error reading file"));
};
reader.readAsText(file);
});
// Append the input to the DOM, trigger click, and then remove it
document.body.appendChild(input);
input.click();
document.body.removeChild(input);
});
}

View File

@ -0,0 +1,26 @@
<script lang="ts">
export let desc: string
export let value: string
export let placeholder: string
export let flex: number = 60
export let disabled: boolean = false
export let validate: (value: string) => boolean = () => true
</script>
<div class="field" style="flex: {flex}">
<label for={desc}>{desc}</label>
<input type="text" placeholder={placeholder} bind:value={value} id="{desc}" on:change
class:error={value && !validate(value)} {disabled}/>
</div>
<style lang="sass">
.field
display: inline-flex
flex-direction: column
gap: 0.5rem
label
font-weight: bold
</style>

View File

@ -0,0 +1,129 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { t, ts } from "../../libs/i18n";
import TransferServer from "./TransferServer.svelte";
import { DATA_HOST } from "../../libs/config";
import type { ConfirmProps } from "../../libs/generalTypes";
import StatusOverlays from "../../components/StatusOverlays.svelte";
let tabs = ['chu3', 'mai2', 'ongeki']
let game: Record<string, { game: string, version: string }> = {
'chu3': { game: "SDHD", version: "2.30" },
'mai2': { game: "SDGA", version: "1.50" },
'ongeki': { game: "SDDT", version: "1.45" }
}
let tab = 0
let src = JSON.parse(localStorage.getItem('src') ?? `{"dns": "", "card": "", "keychip": ""}`)
let dst = JSON.parse(localStorage.getItem('dst') ?? `{"dns": "", "card": "", "keychip": ""}`)
let [srcTested, dstTested] = [false, false]
let gameInfo = JSON.parse(localStorage.getItem('gameInfo') ?? `{"game": "", "version": ""}`)
let srcEl: TransferServer, dstEl: TransferServer
let srcExportedData: string
let [error, loading] = ["", false]
let confirm: ConfirmProps | null = null
function defaultGame() {
gameInfo.game = game[tabs[tab]].game
gameInfo.version = game[tabs[tab]].version
}
function onChange() {
localStorage.setItem('src', JSON.stringify(src))
localStorage.setItem('dst', JSON.stringify(dst))
localStorage.setItem('gameInfo', JSON.stringify(gameInfo))
}
function actuallyStartTransfer() {
srcEl.pull()
.then(() => dstEl.push(srcExportedData))
.then(() => confirm = {
title: "Done!",
message: `Transfer completed successfully! Your data on ${dst.dns} is overwritten with your data from ${src.dns}.`
})
.catch(e => error = e)
.finally(() => loading = false)
}
function startTransfer() {
if (!(srcTested && dstTested)) return alert("Please test both servers first!")
if (loading) return alert("Transfer already in progress!")
console.log("Starting transfer...")
loading = true
if (dstEl.exportedData) return actuallyStartTransfer()
// Ask user to make sure to backup their data
confirm = {
title: "Confirm transfer",
message: "It seems like you haven't backed up your destination data. Are you sure you want to proceed? (This will overwrite your destination server's data)",
dangerous: true,
confirm: actuallyStartTransfer,
cancel: () => { loading = false }
}
}
defaultGame()
</script>
<StatusOverlays bind:confirm={confirm} {error} />
<main class="content">
<div class="outer-title-options">
<h2>🏳️‍⚧️ AquaTrans™ Data Transfer?</h2>
<nav>
{#each tabs as tabName, i}
<div transition:slide={{axis: 'x'}} class:active={tab === i}
on:click={() => tab = i} on:keydown={e => e.key === 'Enter' && (tab = i)}
role="button" tabindex="0">
{ts(`settings.tabs.${tabName}`)}
</div>
{/each}
</nav>
</div>
<div class="prompt">
<p>👋 Welcome to the AquaTrans™ server data transfer tool!</p>
<p>You can use this to export data from any server, and input data into any server using the connection credentials (card number, server address, and keychip id).</p>
<p>This tool will simulate a game client and pull your data from the source server, and push your data to the destination server.</p>
<p>Please fill out the info below to get started!</p>
</div>
<TransferServer bind:src={src} bind:gameInfo={gameInfo} on:change={onChange}
bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} />
<div class="arrow" class:disabled={!(srcTested && dstTested)}>
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow" on:click={startTransfer}>
</div>
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
bind:tested={dstTested} bind:this={dstEl} isSrc={false} />
</main>
<style lang="sass">
.arrow
width: 100%
display: flex
justify-content: center
margin-top: -40px
margin-bottom: -40px
z-index: 1
&.disabled
filter: grayscale(1)
// CSS animation to let the image opacity breathe
img
animation: breathe 1s infinite alternate
@keyframes breathe
0%
opacity: 0.5
100%
opacity: 1
</style>

View File

@ -0,0 +1,21 @@
interface AllNetSrc {
card: string
dns: string
keychip: string
}
interface AllNetGame {
game: string
version: string
}
interface AllNetClient extends AllNetSrc, AllNetGame {}
interface TrCheckGood {
gameUrl: string
userId: number
}
type TrStreamMessage = { message: string } | { error: string } | { data: string }

View File

@ -0,0 +1,198 @@
<script lang="ts">
import StatusOverlays from "../../components/StatusOverlays.svelte";
import { TRANSFER } from "../../libs/sdk";
import { download, selectJsonFile } from "../../libs/ui";
import InputTextShort from "./InputTextShort.svelte";
export let src: AllNetSrc
export let gameInfo: AllNetGame
export let isSrc: boolean = true
export let tested: boolean = false
let [loading, error, expectedError] = [false, "", ""]
function testConnection() {
if (loading) return
// Preliminiary checks
if (!src.dns || !src.keychip || !src.card || !gameInfo.game || !gameInfo.version) {
error = "Please fill out all fields"
return
}
loading = true
console.log("Testing connection...")
TRANSFER.check({...src, ...gameInfo}).then(res => {
console.log("Connection test result:", res)
tested = true
}).catch(err => expectedError = err.message).finally(() => loading = false)
}
let messages: string[] = []
export let exportedData: string = ""
export function pull(): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (loading || !tested) return reject("Please test connection first")
if (exportedData) return resolve(exportedData)
console.log("Exporting data...")
TRANSFER.pull({...src, ...gameInfo}, (msg: TrStreamMessage) => {
console.log("Export progress: ", JSON.stringify(msg))
if ('message' in msg) messages = [...messages, msg.message]
if ('error' in msg) {
expectedError = msg.error
reject(msg.error)
}
if ('data' in msg) {
// file name: Export YYYY-MM-DD {server host} {game} {card last 6}.json
let date = new Date().toISOString().split('T')[0]
let host = new URL(src.dns).hostname
download(msg.data, `Export ${date} ${host} ${gameInfo.game} ${src.card.slice(-6)}.json`)
exportedData = msg.data
resolve(msg.data)
}
}).catch(err => { expectedError = err; reject(err) })
})
}
function pushBtn() {
if (loading || !tested) return
selectJsonFile().then(obj => push(JSON.stringify(obj)))
}
export function push(data: string) {
if (loading || !tested) return
console.log("Import data...")
loading = true
return TRANSFER.push({...src, ...gameInfo}, data).then(() => {
console.log("Data imported successfully")
messages = ["Data imported successfully"]
}).catch(err => expectedError = err.message).finally(() => loading = false)
}
</script>
<StatusOverlays {loading} {error} />
<div class="server source" class:src={isSrc} class:hasError={expectedError} class:tested={tested}>
<h3>{isSrc ? "Source" : "Target"} Server</h3>
{#if expectedError}
<blockquote class="error-msg">{expectedError}</blockquote>
{/if}
<!-- First input line -->
<div class="inputs">
<InputTextShort desc="Server Address" placeholder="e.g. http://aquadx.hydev.org"
bind:value={src.dns} on:change validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} disabled={tested} />
<InputTextShort desc="Keychip ID" placeholder="e.g. A0299792458"
bind:value={src.keychip} on:change validate={v => /^([A-Z0-9]{11}|[A-Z0-9]{4}-[A-Z0-9]{11})$/.test(v)} disabled={tested} />
</div>
<!-- Second input line -->
<div class="inputs">
<div class="game-version">
<InputTextShort desc="Game" placeholder="e.g. SDHD"
bind:value={gameInfo.game} on:change disabled={tested} />
<InputTextShort desc="Version" placeholder="e.g. 2.30"
bind:value={gameInfo.version} on:change disabled={tested} />
</div>
<InputTextShort desc="Card Number" placeholder="e.g. 27182818284590452353"
bind:value={src.card} on:change disabled={tested} />
</div>
<!-- Streaming messages -->
{#if messages.length > 0}
<div class="stream-messages">
{#each messages.slice(Math.max(messages.length - 5, 0), undefined) as msg}
<p>{msg}</p>
{/each}
</div>
{/if}
<!-- Buttons -->
<div class="inputs buttons">
{#if !tested}
<button class="flex-1" on:click={testConnection} disabled={loading}>Test Connection</button>
{:else}
<button class="flex-1" on:click={pull}>Export Data</button>
<button class="flex-1" on:click={pushBtn}>Import Data</button>
{/if}
</div>
</div>
<style lang="sass">
@use "../../vars"
@use "sass:color"
.error-msg
white-space: pre-wrap
margin: 0
.server
display: flex
flex-direction: column
gap: 1rem
// --c-src: 202, 168, 252
--c-src: 179, 198, 255
// animation: hue-rotate 10s infinite linear
// &.src
// --c-src: 173, 192, 247
// animation: hue-rotate 10s infinite linear reverse
&.tested
--c-src: 169, 255, 186
&.hasError
--c-src: 255, 174, 174
animation: none
padding: 1rem
border-radius: vars.$border-radius
// background-color: vars.$ov-light
background: #252525
// Pink outline
border: 1px solid rgba(var(--c-src), 0.5)
box-shadow: 0 0 1rem 0 rgba(var(--c-src), 0.25)
h3
margin: 0
font-size: 1.5rem
text-align: center
// @keyframes hue-rotate
// 0%
// filter: hue-rotate(0deg)
// 100%
// filter: hue-rotate(360deg)
.inputs
display: flex
flex-wrap: wrap
gap: 1rem
.game-version
flex: 60
display: flex
gap: 1rem
:global(> *)
width: 100px
&.buttons
margin-top: 0.5rem
.stream-messages
font-size: 0.8rem
opacity: 0.8
margin-top: 0.5rem
padding: 0 0.5rem
</style>

View File

@ -4,7 +4,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
plugins {
val ktVer = "2.1.0"
val ktVer = "2.1.10"
java
kotlin("plugin.lombok") version ktVer
@ -117,6 +117,10 @@ springBoot {
mainClass.set("icu.samnyan.aqua.EntryKt")
}
application {
mainClass = "icu.samnyan.aqua.EntryKt"
}
hibernate {
enhancement {
enableLazyInitialization = true

View File

@ -14,7 +14,7 @@ and change `allnet.server.host` to your LAN IP address (e.g. 192.168.0.?). You c
> [!NOTE]
> The guide above will create a new MariaDB database.
> If you were using SQLite Aqua before, it is not supported in AquaDX. Please export your data and import it to MariaDB.
> If you were using SQLite Aqua before, it is not supported in AquaDX. Please export your data and import it to your new instance.
> If you were using MySQL Aqua before, you can migrate to MariaDB using [this guide here](docs/mysql_to_mariadb.md).
### Configuration
@ -42,3 +42,9 @@ docker compose up
### Building
You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
## Why drop SQLite support?
If you wonder why I dropped SQLite support, ask SQLite devs why they still haven't supported adding a single constraint to a table without all the hassle of creating a new one and migrating all data over and finally deleting the original.
![](sqlite-sucks.png)

BIN
docs/sqlite-sucks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

View File

@ -192,8 +192,7 @@ val Any?.str get() = toString()
// Collections
fun <T> ls(vararg args: T) = args.toList()
inline fun <reified T> arr(vararg args: T) = arrayOf(*args)
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) =
(if (this is MutableMap) this else mut).apply { putAll(map) }
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) = mut.apply { putAll(map) }
operator fun <K, V> MutableMap<K, V>.plusAssign(map: Map<K, V>) { putAll(map) }
fun <K, V: Any> Map<K, V?>.vNotNull(): Map<K, V> = filterValues { it != null }.mapValues { it.value!! }
fun <T> MutableList<T>.popAll(list: List<T>) = list.also { removeAll(it) }
@ -238,6 +237,7 @@ fun Str.path() = Path.of(this)
operator fun Path.div(part: Str) = resolve(part)
operator fun File.div(fileName: Str) = File(this, fileName)
fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/"
fun Str.ensureNoEndingSlash() = if (endsWith('/')) dropLast(1) else this
fun <T: Any> T.logger() = LoggerFactory.getLogger(this::class.java)

34
src/main/java/ext/Http.kt Normal file
View File

@ -0,0 +1,34 @@
package ext
import icu.samnyan.aqua.sega.util.ZLib
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
val client = HttpClient.newBuilder().build()
fun HttpRequest.Builder.send() = client.send(this.build(), HttpResponse.BodyHandlers.ofByteArray())
fun HttpRequest.Builder.header(pair: Pair<Any, Any>) = this.header(pair.first.toString(), pair.second.toString())
fun String.request() = HttpRequest.newBuilder(URI.create(this))
fun HttpRequest.Builder.post(body: Any? = null) = this.POST(when (body) {
is ByteArray -> HttpRequest.BodyPublishers.ofByteArray(body)
is String -> HttpRequest.BodyPublishers.ofString(body)
is HttpRequest.BodyPublisher -> body
else -> throw Exception("Unsupported body type")
}).send()
inline fun <reified T> HttpResponse<String>.json(): T? = body()?.json()
fun HttpRequest.Builder.postZ(body: String) = run {
header("Content-Type" to "application/json")
header("Content-Encoding" to "deflate")
post(ZLib.compress(body.toByteArray()))
}
fun <T> HttpResponse<T>.header(key: String) = headers().firstValue(key).orElse(null)
fun HttpResponse<ByteArray>.bodyString() = body()?.toString(Charsets.UTF_8)
fun HttpResponse<ByteArray>.bodyZ() = body()?.let { ZLib.decompress(it)?.decodeToString() }
fun HttpResponse<ByteArray>.bodyMaybeZ() = if (header("Content-Encoding") == "deflate") bodyZ() else bodyString()

View File

@ -43,16 +43,18 @@ else JACKSON.readValue(this, cls)
fun <T> T.toJson() = JACKSON.writeValueAsString(this)
inline fun <reified T> String.json() = try {
JACKSON.readValue(this, T::class.java)
if (isEmpty() || this == "null") null
else JACKSON.readValue(this, T::class.java)
}
catch (e: Exception) {
println("Failed to parse JSON: $this")
throw e
}
fun String.jsonMap(): Map<String, Any?> = json()
fun String.jsonArray(): List<Map<String, Any?>> = json()
fun String.jsonMap(): Map<String, Any?> = json() ?: emptyMap()
fun String.jsonArray(): List<Map<String, Any?>> = json() ?: emptyList()
fun String.jsonMaybeMap(): Map<String, Any?>? = json()
fun String.jsonMaybeArray(): List<Map<String, Any?>>? = json()
// KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class)

View File

@ -1,9 +1,9 @@
package icu.samnyan.aqua
import icu.samnyan.aqua.sega.aimedb.AimeDbServer
import icu.samnyan.aqua.sega.maimai2.worldslink.MaimaiFutari
import icu.samnyan.aqua.spring.AutoChecker
import org.springframework.boot.SpringApplication
import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.scheduling.annotation.EnableScheduling
import java.io.File
@ -13,10 +13,7 @@ import java.io.File
class Entry
fun main(args: Array<String>) {
if (args.getOrNull(0) == "futari") {
// Run futari main
return MaimaiFutari().start()
}
AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
// If data/ is not found, create it
File("data").mkdirs()

View File

@ -7,12 +7,12 @@ data class Chu3DataExport(
override var gameId: String = "SDHD",
override var userData: Chu3UserData,
var userGameOption: UserGameOption,
var userActivityList: List<UserActivity>,
var userActivityList: List<Chu3UserActivity>,
var userCharacterList: List<UserCharacter>,
var userChargeList: List<UserCharge>,
var userCourseList: List<UserCourse>,
var userDuelList: List<UserDuel>,
var userItemList: List<UserItem>,
var userItemList: List<Chu3UserItem>,
var userMapList: List<UserMap>,
var userMusicDetailList: List<UserMusicDetail>,
var userPlaylogList: List<UserPlaylog>,

View File

@ -17,13 +17,13 @@ import java.util.List;
public class ChuniDataImport {
private String gameId;
private ExternalUserData userData;
private List<UserActivity> userActivityList;
private List<Chu3UserActivity> userActivityList;
private List<UserCharacter> userCharacterList;
private List<UserCharge> userChargeList;
private List<UserCourse> userCourseList;
private List<UserDuel> userDuelList;
private UserGameOption userGameOption;
private List<UserItem> userItemList;
private List<Chu3UserItem> userItemList;
private List<UserMap> userMapList;
private List<UserMusicDetail> userMusicDetailList;
private List<UserPlaylog> userPlaylogList;

View File

@ -31,10 +31,10 @@ class Chu3Import(
artemisRenames = mapOf(
"chuni_item_character" to ImportClass(UserCharacter::class),
"chuni_item_duel" to ImportClass(UserDuel::class),
"chuni_item_item" to ImportClass(UserItem::class, mapOf("isValid" to "valid")),
"chuni_item_item" to ImportClass(Chu3UserItem::class, mapOf("isValid" to "valid")),
// "chuni_item_login_bonus" to ImportClass(UserLoginBonus::class, mapOf("isWatched" to "watched")),
"chuni_item_map_area" to ImportClass(UserMap::class),
"chuni_profile_activity" to ImportClass(UserActivity::class, mapOf("activityId" to "id")),
"chuni_profile_activity" to ImportClass(Chu3UserActivity::class, mapOf("activityId" to "id")),
"chuni_profile_charge" to ImportClass(UserCharge::class),
"chuni_profile_data" to ImportClass(Chu3UserData::class, mapOf("user" to null, "version" to null, "isNetMember" to null)),
"chuni_profile_option" to ImportClass(UserGameOption::class, mapOf("version" to null)),

View File

@ -0,0 +1,56 @@
package icu.samnyan.aqua.net.transfer
import ext.header
import ext.post
import ext.request
import icu.samnyan.aqua.sega.aimedb.AimeDbClient
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNetResp
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{11})")
class AllNetClient(val dns: String, val keychip: String, val game: String, val version: String, val card: String) {
init {
// Check if keychip is valid
// TODO : Use a more appropriate exception
if (!keychipPattern.matches(keychip)) throw Exception("Invalid keychip")
}
override fun toString() = "AllNetClient($dns, $keychip, $game, $version, $card)"
val keychipShort by lazy {
// A123-45678901337 -> A1234567890
if (keychip.length == 11) keychip
else keychip.substring(0, 4) + keychip.substring(5, 12)
}
val aime by lazy { AimeDbClient(game, keychipShort, dns.substringAfter("://").substringBefore(":").substringBefore("/")) }
// Send AllNet PowerOn request to obtain game URL
val gameUrl by lazy {
"$dns/sys/servlet/PowerOn".request()
.header("User-Agent" to "AquaTrans/1.0")
.header("Content-Type" to "application/x-www-form-urlencoded")
.header("Pragma" to "DFI")
.post(AllNetBillingDecoder.encodeAllNet(mapOf(
"game_id" to game,
"ver" to version,
"serial" to keychipShort,
"ip" to "127.0.0.1", "firm_ver" to "60001", "boot_ver" to "0000",
"encode" to "UTF-8", "format_ver" to "3", "hops" to "1", "token" to "2864179931"
)))
?.also {
println(it)
}
?.decodeAllNetResp()?.get("uri")
?: throw Exception("PowerOn Failed: No game URL returned")
}
val userId by lazy { aime.execLookupOrRegister(card) }
fun findDataBroker(log: (String) -> Unit) = when (game) {
"SDHD" -> ChusanDataBroker(this, log)
"SDEZ", "SDGA" -> MaimaiDataBroker(this, log)
"SDDT" -> OngekiDataBroker(this, log)
else -> throw IllegalArgumentException("Unsupported game: $game")
}
}

View File

@ -0,0 +1,169 @@
package icu.samnyan.aqua.net.transfer
import ext.*
import icu.samnyan.aqua.sega.chusan.model.request.Chu3UserAll
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserActivity
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.UserMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserAll
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserFavorite
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserItem
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail
import icu.samnyan.aqua.sega.ongeki.model.request.UpsertUserAll
import icu.samnyan.aqua.sega.ongeki.model.userdata.UserItem
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import icu.samnyan.aqua.sega.util.jackson.IMapper
import icu.samnyan.aqua.sega.util.jackson.StringMapper
abstract class DataBroker(
val allNet: AllNetClient,
val log: (String) -> Unit,
) {
abstract val mapper: IMapper
abstract val url: String
inline fun <reified T> String.getNullable(key: String, data: JDict): T? = "$url/$this".request()
.postZ(mapper.write(data))
.bodyMaybeZ()
?.jsonMaybeMap()?.get(key)
?.let { mapper.convert<T>(it) }
?.also {
if (it is List<*>) log("$this: ${it.size}")
else log("$this")
}
inline fun <reified T> String.get(key: String, data: JDict): T = getNullable(key, data) ?: run {
log("$this")
if (this == "GetUserDataApi") 404 - "Failed to get $this (User not found?)"
else 417 - "Failed to get $this"
}
fun prePull(): Pair<Map<String, Long>, MutableMap<String, Long>> {
log("Game URL: ${allNet.gameUrl}")
log("User ID: ${allNet.userId}")
val userId = mapOf("userId" to allNet.userId)
val paged = userId + mapOf("nextIndex" to 0, "maxCount" to 10000000)
return userId to paged
}
abstract fun pull(): String
fun push(data: String) {
log("Pushing data")
"$url/UpsertUserAllApi".request().postZ(mapper.write(mapOf(
"userId" to allNet.userId,
"upsertUserAll" to data.jsonMap()
))).bodyMaybeZ()?.also { log(it) }
}
}
class ChusanDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(allNet, log) {
override val mapper = StringMapper()
override val url by lazy { "${allNet.gameUrl.ensureEndingSlash()}ChuniServlet" }
class UserMusicWrapper(var userMusicDetailList: List<UserMusicDetail>)
override fun pull(): String {
val (userId, paged) = prePull()
return mapper.write(Chu3UserAll().apply {
userData = ls("GetUserDataApi".get("userData", userId))
userGameOption = ls("GetUserOptionApi".get("userGameOption", userId))
userCharacterList = "GetUserCharacterApi".get("userCharacterList", paged)
userActivityList = (1..5).flatMap {
"GetUserActivityApi".get<List<Chu3UserActivity>>("userActivityList", userId + mapOf("kind" to it))
}
userItemList = (1..12).flatMap {
"GetUserItemApi".get<List<Chu3UserItem>>("userItemList", paged + mapOf("nextIndex" to 10000000000 * it))
}
userRecentRatingList = "GetUserRecentRatingApi".get("userRecentRatingList", userId)
userMusicDetailList = "GetUserMusicApi".get<List<UserMusicWrapper>>("userMusicList", paged)
.flatMap { it.userMusicDetailList }
userCourseList = "GetUserCourseApi".get("userCourseList", paged)
userFavoriteMusicList = "GetUserFavoriteItemApi".get("userFavoriteItemList", paged + mapOf("kind" to 1))
// TODO userMapAreaList = "GetUserMapAreaApi"
// TODO userNetBattleData = ls("GetUserNetBattleDataApi".get("userNetBattleData", userId))
userUnlockChallengeList = "GetUserUCApi".get("userUnlockChallengeList", userId)
})
}
}
class MaimaiDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(allNet, log) {
override val mapper = BasicMapper()
override val url by lazy { "${allNet.gameUrl.ensureEndingSlash()}Maimai2Servlet" }
class UserMusicWrapper(var userMusicDetailList: List<Mai2UserMusicDetail>)
override fun pull(): String {
val (userId, paged) = prePull()
return Mai2UserAll().apply {
userData = ls("GetUserDataApi".get("userData", userId))
userOption = ls("GetUserOptionApi".get("userOption", userId))
userExtend = ls("GetUserExtendApi".get("userExtend", userId))
userRatingList = ls("GetUserRatingApi".get("userRating", userId))
userActivityList = ls("GetUserActivityApi".get("userActivity", userId))
userMusicDetailList = "GetUserMusicApi".get<List<UserMusicWrapper>>("userMusicList", paged)
.flatMap { it.userMusicDetailList }
userFriendSeasonRankingList = "GetUserFriendSeasonRankingApi".get("userFriendSeasonRankingList", paged)
userCharacterList = "GetUserCharacterApi".get("userCharacterList", paged)
userItemList = (1..12).flatMap {
"GetUserItemApi".get<List<Mai2UserItem>>("userItemList", paged + mapOf("nextIndex" to 10000000000 * it))
}
userCourseList = "GetUserCourseApi".get("userCourseList", paged)
userFavoriteList = (1..5).mapNotNull {
"GetUserFavoriteApi".getNullable<Mai2UserFavorite>("userFavorite", userId + mapOf("itemKind" to it))
}
userGhost = "GetUserGhostApi".get("userGhostList", userId)
userMapList = "GetUserMapApi".get("userMapList", paged)
userLoginBonusList = "GetUserLoginBonusApi".get("userLoginBonusList", userId)
// TODO: userFavoriteMusicList
}.toJson()
}
}
class OngekiDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(allNet, log) {
override val mapper = BasicMapper()
override val url by lazy { allNet.gameUrl.ensureNoEndingSlash() }
override fun pull(): String {
val (userId, paged) = prePull()
return UpsertUserAll().apply {
userData = ls("GetUserDataApi".get("userData", userId))
userOption = ls("GetUserOptionApi".get("userOption", userId))
userMusicItemList = "GetUserMusicItemApi".get("userMusicItemList", paged)
userBossList = "GetUserBossApi".get("userBossList", userId)
userMusicDetailList = "GetUserMusicApi".get("userMusicList", paged)
userTechCountList = "GetUserTechCountApi".get("userTechCountList", userId)
userCardList = "GetUserCardApi".get("userCardList", paged)
userCharacterList = "GetUserCharacterApi".get("userCharacterList", paged)
userStoryList = "GetUserStoryApi".get("userStoryList", userId)
userChapterList = "GetUserChapterApi".get("userChapterList", userId)
userMemoryChapterList = "GetUserMemoryChapterApi".get("userMemoryChapterList", userId)
userDeckList = "GetUserDeckByKeyApi".get("userDeckList", userId + mapOf("authKey" to ""))
userTrainingRoomList = "GetUserTrainingRoomByKeyApi".get("userTrainingRoomList", userId + mapOf("authKey" to ""))
userActivityList = "GetUserActivityApi".get("userActivityList", userId + mapOf("kind" to 1))
userRatinglogList = "GetUserRatinglogApi".get("userRatinglogList", userId)
userRecentRatingList = "GetUserRecentRatingApi".get("userRecentRatingList", userId)
userItemList = ls(2, 3, 4, 8, 9, 11, 12, 13, 14, 15, 16, 17, 19, 20).flatMap {
"GetUserItemApi".get<List<UserItem>>("userItemList", paged + mapOf("nextIndex" to 10000000000 * it))
}
userEventPointList = "GetUserEventPointApi".get("userEventPointList", userId)
userMissionPointList = "GetUserMissionPointApi".get("userMissionPointList", userId)
userLoginBonusList = "GetUserLoginBonusApi".get("userLoginBonusList", userId)
userScenarioList = "GetUserScenarioApi".get("userScenarioList", userId)
userTradeItemList = "GetUserTradeItemApi".get("userTradeItemList", userId + mapOf("startChapterId" to 0, "endChapterId" to 99999))
userEventMusicList = "GetUserEventMusicApi".get("userEventMusicList", userId)
userTechEventList = "GetUserTechEventRankingApi".get("userTechEventRankingList", userId)
userKopList = "GetUserKopApi".get("userKopList", userId)
}.toJson()
}
}
}

View File

@ -0,0 +1,65 @@
package icu.samnyan.aqua.net.transfer
import ext.*
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.bind.annotation.RestController
import java.io.PrintWriter
@RestController
@API("/api/v2/transfer")
class TransferApis {
val log = logger()
@API("/check")
fun check(@RB allNet: AllNetClient) = try {
log.info("Transfer check: $allNet")
mapOf("gameUrl" to allNet.gameUrl, "userId" to allNet.userId)
} catch (e: Exception) {
400 - "Transfer check failed. Please check your host and keychip.\n${e.message}"
}
fun HttpServletResponse.initStream(): PrintWriter {
contentType = "text/event-stream; charset=utf-8"
characterEncoding = "UTF-8"
return writer
}
fun PrintWriter.sendJson(m: Any) {
println(m.toJson())
flush()
}
fun PrintWriter.log(m: String) = sendJson(mapOf("message" to m))
@API("/pull")
fun pull(@RB allNet: AllNetClient, response: HttpServletResponse) {
val stream = response.initStream()
try {
log.info("Transfer pull: $allNet")
stream.log("Starting pull...")
val broker = allNet.findDataBroker { stream.log(it) }
val out = broker.pull()
stream.log("Pull complete")
stream.sendJson(mapOf("data" to out))
} catch (e: Exception) {
log.error("Transfer pull error", e)
stream.sendJson(mapOf("error" to e.message))
} finally {
stream.close()
}
}
class PushReq(val client: AllNetClient, val data: String)
@API("/push")
fun push(@RB obj: PushReq) = try {
val allNet = obj.client
log.info("Transfer push: $allNet")
val broker = allNet.findDataBroker { log.info(it) }
broker.push(obj.data)
mapOf("status" to "ok")
} catch (e: Exception) {
log.error("Transfer push error", e)
400 - "Transfer push error: ${e.message}"
}
}

View File

@ -12,10 +12,8 @@ import io.netty.buffer.Unpooled
import io.netty.channel.ChannelHandler
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.nio.charset.StandardCharsets.US_ASCII
import java.time.LocalDateTime
import kotlin.jvm.optionals.getOrNull
@ -31,13 +29,24 @@ class AimeDB(
): ChannelInboundHandlerAdapter() {
val logger = logger()
data class AimeBaseInfo(val gameId: String, val keychipId: String)
fun getBaseInfo(input: ByteBuf) = AimeBaseInfo(
gameId = input.toString(0x0a, 0x0e - 0x0a, StandardCharsets.US_ASCII),
keychipId = input.toString(0x14, 0x1f - 0x14, StandardCharsets.US_ASCII)
data class AimeBaseInfo(
val magic: UInt, val version: UInt, val responseCode: UInt, val length: UInt,
val status: UInt, val gameId: String, val storeId: UInt, val keychipId: String
)
fun ByteBuf.decodeHeader() = AimeBaseInfo(
magic = readShortLE().toUInt(), // 00 2b
version = readShortLE().toUInt(), // 02 2b
responseCode = readShortLE().toUInt(), // 04 2b
length = readShortLE().toUInt(), // 06 2b
status = readShortLE().toUInt(), // 08 2b
gameId = readPaddedString(6u), // 0a 6b
storeId = readIntLE().toUInt(), // 10 4b
keychipId = readPaddedString(12u) // 14 12b
)
fun ByteBuf.readPaddedString(maxLen: UInt) = readBytes(maxLen.toInt()).toString(US_ASCII).trimEnd('\u0000')
data class Handler(val name: String, val fn: (ByteBuf) -> ByteBuf?)
final val handlers = mapOf(
@ -62,10 +71,10 @@ class AimeDB(
try {
val type = msg["type"] as Int
val data = msg["data"] as ByteBuf
val base = getBaseInfo(data)
val base = data.decodeHeader()
val handler = handlers[type] ?: return logger.error("AimeDB: Unknown request type 0x${type.toString(16)}")
logger.info("AimeDB /${handler.name} : (game ${base.gameId}, keychip ${base.keychipId})")
logger.info("AimeDB /${handler.name} : $base")
// Check keychip
// We do not check for type 0x13 because of a bug in duolinguo.dll
@ -127,8 +136,7 @@ class AimeDB(
*/
fun doFelicaLookupV2(msg: ByteBuf): ByteBuf {
val idm = msg.slice(0x30, 0x38 - 0x30).getLong(0)
val dfc = msg.slice(0x38, 0x40 - 0x38).getLong(0)
logger.info("> Felica Lookup v2 (idm $idm, dfc $dfc)")
logger.info("> Felica Lookup v2 (idm $idm)")
// Get the decimal represent of the hex value, same from minime
val accessCode = idm.toString().replace("-", "").padStart(20, '0')

View File

@ -0,0 +1,97 @@
package icu.samnyan.aqua.sega.aimedb
import ext.minus
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufUtil
import io.netty.buffer.Unpooled
import java.net.Socket
import java.net.SocketTimeoutException
class AimeDbClient(val gameId: String, val keychipShort: String, val server: String) {
// https://sega.bsnk.me/allnet/aimedb/common/#packet-header
fun createRequest(type: UShort, writer: ByteBuf.() -> Unit) =
AimeDbEncryption.encrypt(Unpooled.buffer(1024).clear().run {
writeShortLE(0xa13e) // 00 2b: Magic
writeShortLE(0x3087) // 02 2b: Version
writeShortLE(type.toInt()) // 04 2b: Type
writeShortLE(0) // 06 2b: Length
writeShortLE(0) // 08 2b: Result
writeAscii(gameId, 6) // 0A 6b: Game ID
writeIntLE(299) // 10 4b: Store ID (Place ID)
writeAscii(keychipShort, 12) // 14 12b: Keychip ID
writer() // Write Payload
setShortLE(6, writerIndex()) // Update Length
copy(0, writerIndex()) // Trim unused bytes
})
private fun ByteBuf.writeAscii(value: String, length: Int) =
writeBytes(value.toByteArray(Charsets.US_ASCII).copyOf(length))
fun createReqLookupV2(accessCode: String) =
createRequest(0x0fu) {
// Access code is a 20-digit number, should be converted to a 10-byte array
writeBytes(ByteBufUtil.decodeHexDump(accessCode.padStart(20, '0')))
writeByte(0) // 0A 1b: Company code
writeByte(0) // 0B 1b: R/W Firmware version
writeIntLE(0) // 0C 4b: Serial number
}
fun createReqFelicaLookupV2(felicaIdm: String) =
createRequest(0x11u) {
writeBytes(ByteArray(16)) // 00 16b: Random Challenge
// 10 8b: Felica IDm
writeBytes(ByteBufUtil.decodeHexDump(felicaIdm.padStart(16, '0')))
writeBytes(ByteArray(8)) // 18 8b: Felica PMm
writeBytes(ByteArray(16)) // 20 16b: Card key version
writeBytes(ByteArray(16)) // 30 16b: Write count
writeBytes(ByteArray(8)) // 40 8b: MACA
writeByte(0) // 48 1b: Company code
writeByte(0) // 49 1b: R/W Firmware version
writeShortLE(0) // 4A 2b: DFC
writeIntLE(0) // 4C 4b: Unknown padding
}
fun createReqRegister(accessCode: String) =
createRequest(0x05u) {
// Access code is a 20-digit number, should be converted to a 10-byte array
writeBytes(ByteBufUtil.decodeHexDump(accessCode.padStart(20, '0')))
writeByte(0) // 0A 1b: Company code
writeByte(0) // 0B 1b: R/W Firmware version
writeIntLE(0) // 0C 4b: Serial number
}
fun send(buf: ByteBuf): ByteBuf = Socket(server, 22345).use {
it.soTimeout = 3000
it.getOutputStream().write(buf.array())
it.getInputStream().use { r ->
Unpooled.buffer().apply {
val buffer = ByteArray(1024)
try {
while (r.read(buffer) != -1) writeBytes(buffer)
} catch (_: SocketTimeoutException) { }
}
}
}.let { AimeDbEncryption.decrypt(it) }
fun execLookup(card: String) =
send(when (card.length) {
20 -> createReqLookupV2(card)
16 -> createReqFelicaLookupV2(card)
else -> 400 - "Invalid card. Please input either 20-digit numeric access code (e.g. 5010000...0) or 16-digit hex Felica ID (e.g. 012E123456789ABC)."
}).getUnsignedIntLE(0x20).let {
if (it == 0xffffffff) -1L else it
}
fun execRegister(card: String) =
when (card.length) {
20 -> card
16 -> ByteBufUtil.hexDump(send(createReqFelicaLookupV2(card)).slice(0x2c, 10))
else -> 400 - "Invalid card. Please input a 20-digit numeric access code (e.g. 5010000...0)."
}.let { send(createReqRegister(it)).getUnsignedIntLE(0x20) }
fun execLookupOrRegister(card: String) =
execLookup(card).let {
if (it == -1L) execRegister(card)
else it
}
}

View File

@ -2,7 +2,7 @@ package icu.samnyan.aqua.sega.allnet
import ext.*
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.sega.util.AllNetBillingDecoder.decodeAllNet
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNet
import icu.samnyan.aqua.sega.util.AquaConst
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse

View File

@ -0,0 +1,50 @@
package icu.samnyan.aqua.sega.allnet
import ext.bodyString
import ext.header
import icu.samnyan.aqua.sega.util.ZLib
import java.net.http.HttpResponse
import java.util.*
import kotlin.text.Charsets.UTF_8
object AllNetBillingDecoder {
fun String.urlToMap() = split("&").map { it.split("=") }.filter { it.size == 2 }.associate { it[0] to it[1] }
/**
* Decode the input byte array from Base64 MIME encoding and decompress the decoded byte array
*/
fun decode(src: ByteArray, base64: Boolean, nowrap: Boolean): Map<String, String> {
// Decode the input byte array from Base64 MIME encoding
val bytes = if (!base64) src else Base64.getMimeDecoder().decode(src)
// Decompress the decoded byte array
val output = ZLib.decompress(bytes, nowrap).toString(UTF_8).trim()
// Split the string by '&' symbol to separate key-value pairs
return output.urlToMap()
}
fun encode(src: Map<String, String>, base64: Boolean): ByteArray {
// Join the key-value pairs with '&' symbol
val output = src.map { "${it.key}=${it.value}" }.joinToString("&")
// Compress the joined string
val bytes = ZLib.compress(output.toByteArray(UTF_8))
// Encode the compressed byte array to Base64 MIME encoding
return if (!base64) bytes else Base64.getEncoder().encode(bytes)
}
@JvmStatic
fun decodeAllNet(src: ByteArray) = decode(src, base64 = true, nowrap = false)
fun encodeAllNet(src: Map<String, String>) = encode(src, base64 = true)
@JvmStatic
fun decodeBilling(src: ByteArray) = decode(src, base64 = false, nowrap = true)
fun HttpResponse<ByteArray>.decodeAllNetResp() =
if (header("Pragma") == "DFI") decodeAllNet(body())
else bodyString()?.urlToMap()
}

View File

@ -2,7 +2,7 @@ package icu.samnyan.aqua.sega.billing
import ext.logger
import ext.toUrl
import icu.samnyan.aqua.sega.util.AllNetBillingDecoder.decodeBilling
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeBilling
import jakarta.annotation.PostConstruct
import jakarta.servlet.http.HttpServletRequest
import org.eclipse.jetty.http.HttpVersion

View File

@ -62,21 +62,22 @@ class ChusanController(
val token = TokenChecker.getCurrentSession()?.token?.substring(0, 6) ?: "NO-TOKEN"
log.info("Chu3 < $api : ${data.toJson()} : [$token]")
val noop = """{"returnCode":"1","apiName":"$api"}"""
if (api !in noopEndpoint && !handlers.containsKey(api)) {
log.warn("Chu3 > $api not found")
return """{"returnCode":"1","apiName":"$api"}"""
return noop
}
// Only record the counter metrics if the API is known.
Metrics.counter("aquadx_chusan_api_call", "api" to api).increment()
if (api in noopEndpoint) {
log.info("Chu3 > $api no-op")
return """{"returnCode":"1"}"""
return noop
}
return try {
Metrics.timer("aquadx_chusan_api_latency", "api" to api).recordCallable {
serialize(api, handlers[api]!!(ctx)).also {
serialize(api, handlers[api]!!(ctx) ?: noop).also {
if (api !in setOf("GetUserItemApi", "GetGameEventApi"))
log.info("Chu3 > $api : $it")
}

View File

@ -5,7 +5,7 @@ import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.chusan.ChusanController
import icu.samnyan.aqua.sega.chusan.ChusanData
import icu.samnyan.aqua.sega.chusan.model.request.UserCMissionResp
import icu.samnyan.aqua.sega.chusan.model.userdata.UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.UserMusicDetail
import icu.samnyan.aqua.sega.general.model.response.UserRecentRating
import java.time.format.DateTimeFormatter
@ -179,7 +179,7 @@ fun ChusanController.chusanInit() {
db.userData.findByCard_ExtId(uid)()?.card?.aquaUser?.gameOptions?.let {
if (it.chusanInfinitePenguins && kind == 5) {
items.removeAll { it.itemId in penguins }
items.addAll(penguins.map { UserItem(kind, it, 999, true) })
items.addAll(penguins.map { Chu3UserItem(kind, it, 999, true) })
}
}

View File

@ -1,12 +1,11 @@
package icu.samnyan.aqua.sega.chusan.handler
import com.fasterxml.jackson.core.type.TypeReference
import ext.*
import icu.samnyan.aqua.sega.chusan.ChusanController
import icu.samnyan.aqua.sega.chusan.model.request.UpsertUserGacha
import icu.samnyan.aqua.sega.chusan.model.request.UserEmoney
import icu.samnyan.aqua.sega.chusan.model.userdata.UserCardPrintState
import icu.samnyan.aqua.sega.chusan.model.userdata.UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import java.time.LocalDateTime
fun ChusanController.cmApiInit() {
@ -29,7 +28,7 @@ fun ChusanController.cmApiInit() {
val (gachaId, placeId) = parsing { data["gachaId"]!!.int to data["placeId"]!!.int }
val u = db.userData.findByCard_ExtId(uid)() ?: return@api null
val upsertUserGacha = parsing { mapper.convert(data["cmUpsertUserGacha"], UpsertUserGacha::class.java) }
val upsertUserGacha = parsing { mapper.convert<UpsertUserGacha>(data["cmUpsertUserGacha"]!!) }
upsertUserGacha.gameGachaCardList?.let { lst ->
db.userCardPrintState.saveAll(lst.map {
@ -63,7 +62,7 @@ fun ChusanController.cmApiInit() {
}
"CMUpsertUserPrintCancel" {
val orderIdList: List<Long> = cmMapper.convert(data["orderIdList"], object : TypeReference<List<Long>>() {})
val orderIdList: List<Long> = cmMapper.convert<List<Long>>(parsing { data["orderIdList"]!! })
db.userCardPrintState.saveAll(orderIdList.mapNotNull {
// TODO: The original code by Eori writes findById but I don't think that is correct...
@ -76,8 +75,8 @@ fun ChusanController.cmApiInit() {
}
"CMUpsertUserPrintSubtract" api@ {
val userCardPrintState = cmMapper.convert(data["userCardPrintState"], UserCardPrintState::class.java)
val userItemList = cmMapper.convert(data["userItemList"], object : TypeReference<List<UserItem>>() {})
val userCardPrintState = cmMapper.convert<UserCardPrintState>(parsing { data["userCardPrintState"]!! })
val userItemList = cmMapper.convert<List<Chu3UserItem>>(parsing { data["userItemList"]!! })
val u = db.userData.findByCard_ExtId(uid)() ?: return@api null

View File

@ -2,7 +2,7 @@ package icu.samnyan.aqua.sega.chusan.handler
import ext.*
import icu.samnyan.aqua.sega.chusan.ChusanController
import icu.samnyan.aqua.sega.chusan.model.request.UpsertUserAll
import icu.samnyan.aqua.sega.chusan.model.request.Chu3UserAll
import icu.samnyan.aqua.sega.chusan.model.userdata.*
import icu.samnyan.aqua.sega.general.model.response.UserRecentRating
@ -17,7 +17,7 @@ fun ChusanController.upsertApiInit() {
}
"UpsertUserAll" api@ {
val req = mapper.convert(data["upsertUserAll"], UpsertUserAll::class.java)
val req = parsing { mapper.convert<Chu3UserAll>(data["upsertUserAll"]!!) }
req.run {
// UserData

View File

@ -40,11 +40,11 @@ interface Chu3UserLoginBonusRepo : JpaRepository<UserLoginBonus, Long> {
fun findLoginBonus(userId: Int, version: Int, presetId: Long): Optional<UserLoginBonus>
}
interface Chu3UserActivityRepo : Chu3UserLinked<UserActivity> {
fun findTopByUserAndActivityIdAndKindOrderByIdDesc(user: Chu3UserData, activityId: Int, kind: Int): Optional<UserActivity>
fun findByUserAndActivityIdAndKind(user: Chu3UserData, activityId: Int, kind: Int): UserActivity?
interface Chu3UserActivityRepo : Chu3UserLinked<Chu3UserActivity> {
fun findTopByUserAndActivityIdAndKindOrderByIdDesc(user: Chu3UserData, activityId: Int, kind: Int): Optional<Chu3UserActivity>
fun findByUserAndActivityIdAndKind(user: Chu3UserData, activityId: Int, kind: Int): Chu3UserActivity?
fun findAllByUser_Card_ExtIdAndKind(extId: Long, kind: Int): List<UserActivity>
fun findAllByUser_Card_ExtIdAndKind(extId: Long, kind: Int): List<Chu3UserActivity>
}
interface Chu3UserCardPrintStateRepo : Chu3UserLinked<UserCardPrintState> {
@ -89,14 +89,14 @@ interface Chu3UserGeneralDataRepo : Chu3UserLinked<UserGeneralData> {
fun findByUser_Card_ExtIdAndPropertyKey(extId: Long, key: String): Optional<UserGeneralData>
}
interface Chu3UserItemRepo : Chu3UserLinked<UserItem> {
fun findAllByUser(user: Chu3UserData): List<UserItem>
fun findTopByUserAndItemIdAndItemKindOrderByIdDesc(user: Chu3UserData, itemId: Int, itemKind: Int): Optional<UserItem>
fun findByUserAndItemIdAndItemKind(user: Chu3UserData, itemId: Int, itemKind: Int): UserItem?
interface Chu3UserItemRepo : Chu3UserLinked<Chu3UserItem> {
fun findAllByUser(user: Chu3UserData): List<Chu3UserItem>
fun findTopByUserAndItemIdAndItemKindOrderByIdDesc(user: Chu3UserData, itemId: Int, itemKind: Int): Optional<Chu3UserItem>
fun findByUserAndItemIdAndItemKind(user: Chu3UserData, itemId: Int, itemKind: Int): Chu3UserItem?
fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int, pageable: Pageable): Page<UserItem>
fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int, pageable: Pageable): Page<Chu3UserItem>
fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int): List<UserItem>
fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int): List<Chu3UserItem>
}
interface Chu3UserMapRepo : Chu3UserLinked<UserMap> {

View File

@ -37,13 +37,13 @@ data class MusicIdWrapper(
val musicId: Int = 0,
)
class UpsertUserAll(
class Chu3UserAll(
var userData: List<Chu3UserData>? = null,
var userGameOption: List<UserGameOption>? = null,
var userCharacterList: List<UserCharacter>? = null,
var userItemList: List<UserItem>? = null,
var userItemList: List<Chu3UserItem>? = null,
var userMusicDetailList: List<UserMusicDetail>? = null,
var userActivityList: List<UserActivity>? = null,
var userActivityList: List<Chu3UserActivity>? = null,
var userRecentRatingList: List<UserRecentRating>? = null,
var userPlaylogList: List<UserPlaylog>? = null,
var userChargeList: List<UserCharge>? = null,

View File

@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
import icu.samnyan.aqua.sega.chusan.model.GameGachaCard
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData
import icu.samnyan.aqua.sega.chusan.model.userdata.UserGacha
import icu.samnyan.aqua.sega.chusan.model.userdata.UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import java.io.Serializable
class UpsertUserGacha : Serializable {
@ -13,7 +13,7 @@ class UpsertUserGacha : Serializable {
var userCharacterList: List<Any>? = null
var userCardList: List<Any>? = null
var gameGachaCardList: List<GameGachaCard>? = null
var userItemList: List<UserItem>? = null
var userItemList: List<Chu3UserItem>? = null
@JsonProperty("isNewCharacterList")
var isNewCharacterList: String? = null

View File

@ -8,7 +8,7 @@ import jakarta.persistence.UniqueConstraint
@Entity(name = "ChusanUserActivity")
@Table(name = "chusan_user_activity", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "kind", "activity_id"])])
class UserActivity : Chu3UserEntity() {
class Chu3UserActivity : Chu3UserEntity() {
var kind = 0
@JsonProperty("id")
@Column(name = "activity_id")

View File

@ -7,7 +7,7 @@ import jakarta.persistence.UniqueConstraint
@Entity(name = "ChusanUserItem")
@Table(name = "chusan_user_item", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "item_id", "item_kind"])])
class UserItem(
class Chu3UserItem(
var itemKind: Int = 0,
var itemId: Int = 0,
var stock: Int = 1,

View File

@ -30,7 +30,7 @@ typealias PagePost = (MutJDict) -> Unit
data class PagedProcessor(val add: JDict?, val fn: PagedHandler, var post: PagePost? = null)
// A very :3 way of declaring APIs
abstract class MeowApi(val serialize: (String, Any?) -> String) {
abstract class MeowApi(val serialize: (String, Any) -> String) {
val initH = mutableMapOf<String, SpecialHandler>()
infix operator fun String.invoke(fn: SpecialHandler) = initH.set("${this}Api", fn)
infix fun String.static(fn: () -> Any) = serialize(this, fn()).let { resp -> this { resp } }

View File

@ -4,12 +4,10 @@ package icu.samnyan.aqua.sega.maimai2
import ext.*
import icu.samnyan.aqua.sega.general.PagedHandler
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRivalMusic
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRivalMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserIntimate
import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusic
import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx
import java.time.LocalDate
import java.util.*
fun Maimai2ServletController.initApis() {
// Used because maimai does not actually require paging implementation
@ -157,7 +155,7 @@ fun Maimai2ServletController.initApis() {
val rivalId = parsing { data["rivalId"]!!.long }
val lst = db.userMusicDetail.findByUserId(rivalId)
val res = lst.associate { it.musicId to UserRivalMusic(it.musicId, LinkedList()) }
val res = lst.associate { it.musicId to UserRivalMusic(it.musicId) }
lst.forEach {
res[it.musicId]!!.userRivalMusicDetailList.add(
@ -195,6 +193,7 @@ fun Maimai2ServletController.initApis() {
}
// Kaleidoscope, added on 1.50
// [{gateId, phaseId}]
"GetGameKaleidxScope" { mapOf("gameKaleidxScopeList" to ls(
mapOf("gateId" to 1, "phaseId" to findPhase(LocalDate.of(2025, 1, 18))),
mapOf("gateId" to 2, "phaseId" to 2),
@ -203,6 +202,8 @@ fun Maimai2ServletController.initApis() {
mapOf("gateId" to 5, "phaseId" to 2),
mapOf("gateId" to 6, "phaseId" to 2),
)) }
// Request: {userId}
// Response: {userId, userKaleidxScopeList}
"GetUserKaleidxScope".unpaged {
val u = db.userData.findByCardExtId(uid)() ?: (404 - "User not found")
val lst = db.userKaleidx.findByUser(u)
@ -213,6 +214,8 @@ fun Maimai2ServletController.initApis() {
lst
}
// Request: {userId, version, userData: [UserDetail], userPlaylogList: [UserPlaylog]}
// Response: {userId, userItemList: [UserItem]}
// Added on 1.50
"GetUserNewItemList" { mapOf("userId" to uid, "userItemList" to empty) }

View File

@ -68,9 +68,10 @@ class Maimai2ServletController(
@API("/{api}")
fun handle(@PathVariable api: String, @RequestBody data: Map<String, Any>, req: HttpServletRequest): Any {
logger.info("Mai2 < $api : ${data.toJson()}") // TODO: Optimize logging
val noop = """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}"""
if (api !in noopEndpoint && !handlers.containsKey(api)) {
logger.warn("Mai2 > $api not found")
return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}"""
return noop
}
// Only record the counter metrics if the API is known.
@ -78,13 +79,13 @@ class Maimai2ServletController(
if (api in noopEndpoint) {
logger.info("Mai2 > $api no-op")
return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}"""
return noop
}
return try {
Metrics.timer("aquadx_maimai2_api_latency", "api" to api).recordCallable {
val ctx = RequestContext(req, data.mut)
serialize(api, handlers[api]!!(ctx)).also {
serialize(api, handlers[api]!!(ctx) ?: noop).also {
logger.info("Mai2 > $api : ${it.truncate(1000)}")
}
}

View File

@ -1,98 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.handler;
import com.fasterxml.jackson.core.JsonProcessingException;
import icu.samnyan.aqua.net.db.AquaNetUser;
import icu.samnyan.aqua.net.utils.PathProps;
import icu.samnyan.aqua.sega.general.BaseHandler;
import icu.samnyan.aqua.sega.general.dao.CardRepository;
import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPortrait;
import icu.samnyan.aqua.sega.util.jackson.BasicMapper;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.stereotype.Component;
import java.io.FileInputStream;
import java.nio.file.Paths;
import java.util.*;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Component("Maimai2GetUserPortraitHandler")
public class GetUserPortraitHandler implements BaseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetUserPortraitHandler.class);
private final BasicMapper mapper;
private final boolean enable;
private final CardRepository cardRepo;
private final String portraitPath;
public GetUserPortraitHandler(BasicMapper mapper,
@Value("${game.maimai2.userPhoto.enable:true}") boolean enable,
CardRepository cardRepo,
PathProps paths) {
this.mapper = mapper;
this.enable = enable;
this.cardRepo = cardRepo;
this.portraitPath = paths.getAquaNetPortrait();
}
@Override
public String handle(Map<String, ?> request) throws JsonProcessingException {
if (enable) {
var userId = ((Number) request.get("userId")).longValue();
var list = new ArrayList<UserPortrait>();
var card = cardRepo.findByExtId(userId);
var user = card.map(Card::getAquaUser);
var profilePicture = user.map(AquaNetUser::getProfilePicture).orElse(null);
try {
if (!StringUtils.isEmpty(profilePicture)) {
var filePath = Paths.get(portraitPath, profilePicture);
var buffer = new byte[10240];
var stream = new FileInputStream(filePath.toFile());
while (stream.available() > 0) {
var read = stream.read(buffer, 0, 10240);
var encodeBuffer = read == 10240 ? buffer : Arrays.copyOfRange(buffer, 0, read);
var userPortrait = new UserPortrait();
userPortrait.setFileName("portrait.jpg");
userPortrait.setPlaceId(0);
userPortrait.setUserId(userId);
userPortrait.setClientId("");
userPortrait.setUploadDate("1970-01-01 09:00:00.0");
userPortrait.setDivData(Utf8.decode(Base64.getEncoder().encode(encodeBuffer)));
userPortrait.setDivNumber(list.size());
list.add(userPortrait);
}
stream.close();
for (var i = 0; i < list.size(); i++) {
var userPortrait = list.get(i);
userPortrait.setDivLength(list.size());
}
var map = new HashMap<String, Object>();
map.put("length", list.size());
map.put("userPortraitList", list);
var respJson = mapper.write(map);
return respJson;
}
} catch (Exception e) {
logger.error("Result: User photo save failed", e);
}
}
return "{\"length\":0,\"userPortraitList\":[]}";
}
}

View File

@ -0,0 +1,63 @@
package icu.samnyan.aqua.sega.maimai2.handler
import ext.invoke
import ext.logger
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserPortrait
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.codec.Utf8
import org.springframework.stereotype.Component
import java.io.FileInputStream
import java.nio.file.Paths
import java.util.*
@Component("Maimai2GetUserPortraitHandler")
class GetUserPortraitHandler(
val cardRepo: CardRepository,
@param:Value("\${game.maimai2.userPhoto.enable:true}") val enable: Boolean,
paths: PathProps
) : BaseHandler {
val portraitPath = paths.aquaNetPortrait
val log = logger()
override fun handle(request: Map<String, Any>): Any? {
if (!enable) return """{"length":0,"userPortraitList":[]}"""
val uid = (request["userId"] as Number).toLong()
val list = ArrayList<Mai2UserPortrait>()
val profilePicture = cardRepo.findByExtId(uid)()?.aquaUser?.profilePicture?.ifBlank { null }
?: return """{"length":0,"userPortraitList":[]}"""
try {
val filePath = Paths.get(portraitPath, profilePicture)
val buffer = ByteArray(10240)
FileInputStream(filePath.toFile()).use { stream ->
while (stream.available() > 0) {
val read = stream.read(buffer, 0, 10240)
val buf = if (read == 10240) buffer else Arrays.copyOfRange(buffer, 0, read)
list.add(Mai2UserPortrait().apply {
userId = uid
divData = Utf8.decode(Base64.getEncoder().encode(buf))
divNumber = list.size
})
}
}
list.forEach { it.divLength = list.size }
return mapOf(
"length" to list.size,
"userPortraitList" to list
)
} catch (e: Exception) {
log.error("Result: User photo get failed", e)
return """{"length":0,"userPortraitList":[]}"""
}
}
}

View File

@ -3,7 +3,7 @@ package icu.samnyan.aqua.sega.maimai2.handler
import ext.invoke
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRating
import icu.samnyan.aqua.sega.maimai2.model.UserRating
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae
import icu.samnyan.aqua.sega.util.jackson.BasicMapper

View File

@ -1,11 +1,8 @@
package icu.samnyan.aqua.sega.maimai2.handler
import ext.div
import ext.isoDateTime
import ext.logger
import ext.path
import ext.*
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPhoto
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserPhoto
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.springframework.stereotype.Component
import java.io.IOException
@ -23,9 +20,7 @@ class UploadUserPhotoHandler(private val mapper: BasicMapper) :
// Maimai DX sends split base64 data for one jpeg image.
// So, make a temp file and keep append bytes until last part received.
// If finished, rename it to other name so user can keep save multiple scorecards in a single day.
val uploadUserPhoto = mapper.convert(request, UploadUserPhoto::class.java)
val up = uploadUserPhoto.userPhoto
val up = parsing { mapper.convert(request["userPhoto"]!!, Mai2UserPhoto::class.java) }
try {
val tmpFile = tmpDir / "${up.userId}-${up.trackNo}.tmp"

View File

@ -1,12 +1,13 @@
package icu.samnyan.aqua.sega.maimai2.handler
import ext.logger
import ext.long
import ext.millis
import ext.parsing
import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPlaylog
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import icu.samnyan.aqua.spring.Metrics
@ -33,9 +34,10 @@ class UploadUserPlaylogHandler(
}
override fun handle(request: Map<String, Any>): String {
val req = mapper.convert(request, UploadUserPlaylog::class.java)
val uid = parsing { request["userId"]!!.long }
val playlog = parsing { mapper.convert(request["userPlaylog"]!!, Mai2UserPlaylog::class.java) }
val version = tryParseGameVersion(req.userPlaylog.version)
val version = tryParseGameVersion(playlog.version)
if (version != null) {
val session = TokenChecker.getCurrentSession()
val gameId = if (session?.gameId in VALID_GAME_IDS) session!!.gameId else ""
@ -47,9 +49,9 @@ class UploadUserPlaylogHandler(
// Check duplicate
val isDup = playlogRepo.findByUser_Card_ExtIdAndMusicIdAndUserPlayDate(
req.userId,
req.userPlaylog.musicId,
req.userPlaylog.userPlayDate
uid,
playlog.musicId,
playlog.userPlayDate
).size > 0
if (isDup) {
log.info("Duplicate playlog detected")
@ -57,14 +59,14 @@ class UploadUserPlaylogHandler(
}
// Save if the user is registered
val u = userDataRepository.findByCardExtId(req.userId).getOrNull()
if (u != null) playlogRepo.save(req.userPlaylog.apply { user = u })
val u = userDataRepository.findByCardExtId(uid).getOrNull()
if (u != null) playlogRepo.save(playlog.apply { user = u })
// If the user hasn't registered (first play), save the playlog to a backlog
else {
playBacklog.putIfAbsent(req.userId, mutableListOf())
playBacklog[req.userId]?.apply {
add(BacklogEntry(millis(), req.userPlaylog))
playBacklog.putIfAbsent(uid, mutableListOf())
playBacklog[uid]?.apply {
add(BacklogEntry(millis(), playlog))
if (size > 6) clear() // Prevent abuse
}
}

View File

@ -2,10 +2,11 @@ package icu.samnyan.aqua.sega.maimai2.handler
import ext.div
import ext.logger
import ext.parsing
import ext.path
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPortrait
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserPortrait
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
@ -34,8 +35,7 @@ class UploadUserPortraitHandler(
// Maimai DX sends split base64 data for one jpeg image.
// So, make a temp file and keep append bytes until last part received.
// If finished, rename it to other name so user can keep save multiple scorecards in a single day.
val up = mapper.convert(request, UploadUserPortrait::class.java).userPortrait
val up = parsing { mapper.convert(request["userPortrait"]!!, Mai2UserPortrait::class.java) }
val id = up.userId
val num = up.divNumber

View File

@ -3,13 +3,12 @@ package icu.samnyan.aqua.sega.maimai2.handler
import com.fasterxml.jackson.core.JsonProcessingException
import ext.invoke
import ext.mapApply
import ext.minus
import ext.unique
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.general.service.CardService
import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPlaylogHandler.Companion.playBacklog
import icu.samnyan.aqua.sega.maimai2.model.*
import icu.samnyan.aqua.sega.maimai2.model.request.UpsertUserAll
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UpsertUserAll
import icu.samnyan.aqua.sega.maimai2.model.userdata.*
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import lombok.AllArgsConstructor
@ -31,16 +30,13 @@ class UpsertUserAllHandler(
@Throws(JsonProcessingException::class)
override fun handle(request: Map<String, Any>): Any? {
val upsertUserAll = mapper.convert(request, UpsertUserAll::class.java)
val upsertUserAll = mapper.convert(request, Mai2UpsertUserAll::class.java)
val userId = upsertUserAll.userId
val req = upsertUserAll.upsertUserAll
// If user is guest, just return OK response.
if ((userId and 281474976710657L) == 281474976710657L) return SUCCESS
// UserData
if (req.userData == null) 400 - "Invalid Request"
val userData = repos.userData.findByCardExtId(userId)()
val u = repos.userData.saveAndFlush(req.userData[0].apply {
id = userData?.id ?: 0

View File

@ -1,107 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.handler;
import com.fasterxml.jackson.core.JsonProcessingException;
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserCardRepo;
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo;
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPrintDetailRepo;
import icu.samnyan.aqua.sega.general.BaseHandler;
import icu.samnyan.aqua.sega.maimai2.model.request.UpsertUserPrint;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserCard;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail;
import icu.samnyan.aqua.sega.util.jackson.BasicMapper;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Component("Maimai2UpsertUserPrintHandler")
public class UpsertUserPrintHandler implements BaseHandler {
private static final Logger logger = LoggerFactory.getLogger(UpsertUserPrintHandler.class);
private final BasicMapper mapper;
private final Mai2UserCardRepo userCardRepository;
private final Mai2UserPrintDetailRepo userPrintDetailRepository;
private final Mai2UserDataRepo userDataRepository;
private long expirationTime;
public UpsertUserPrintHandler(BasicMapper mapper,
@Value("${game.cardmaker.card.expiration:15}") long expirationTime,
Mai2UserCardRepo userCardRepository,
Mai2UserPrintDetailRepo userPrintDetailRepository,
Mai2UserDataRepo userDataRepository
) {
this.mapper = mapper;
this.expirationTime = expirationTime;
this.userCardRepository = userCardRepository;
this.userPrintDetailRepository = userPrintDetailRepository;
this.userDataRepository = userDataRepository;
}
@Override
public String handle(Map<String, ?> request) throws JsonProcessingException {
long userId = ((Number) request.get("userId")).longValue();
Mai2UserDetail userData;
Optional<Mai2UserDetail> userOptional = userDataRepository.findByCardExtId(userId);
if (userOptional.isPresent()) {
userData = userOptional.get();
} else {
logger.error("User not found. userId: {}", userId);
return null;
}
UpsertUserPrint upsertUserPrint = mapper.convert(request, UpsertUserPrint.class);
Mai2UserPrintDetail userPrintDetail = upsertUserPrint.getUserPrintDetail();
Mai2UserCard newUserCard = userPrintDetail.getUserCard();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS");
String currentDateTime = LocalDateTime.now().format(formatter);
String expirationDateTime = LocalDateTime.now().plusDays(expirationTime).format(formatter);
String randomSerialId =
String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L)) +
String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L));
newUserCard.setUser(userData);
userPrintDetail.setUser(userData);
newUserCard.setStartDate(currentDateTime);
newUserCard.setEndDate(expirationDateTime);
userPrintDetail.setSerialId(randomSerialId);
Optional<Mai2UserCard> userCardOptional = userCardRepository.findByUserAndCardId(newUserCard.getUser(), newUserCard.getCardId());
if (userCardOptional.isPresent()) {
Mai2UserCard userCard = userCardOptional.get();
newUserCard.setId(userCard.getId());
}
userCardRepository.save(newUserCard);
userPrintDetailRepository.save(userPrintDetail);
Map<String, Object> resultMap = new LinkedHashMap<>();
resultMap.put("returnCode", 1);
resultMap.put("orderId", 0);
resultMap.put("serialId", randomSerialId);
resultMap.put("startDate", currentDateTime);
resultMap.put("endDate", expirationDateTime);
return mapper.write(resultMap);
}
}

View File

@ -0,0 +1,54 @@
package icu.samnyan.aqua.sega.maimai2.handler
import ext.invoke
import ext.logger
import ext.long
import ext.parsing
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.ThreadLocalRandom
@Component("Maimai2UpsertUserPrintHandler")
class UpsertUserPrintHandler(
val mapper: BasicMapper,
val db: Mai2Repos,
@param:Value("\${game.cardmaker.card.expiration:15}") val expirationTime: Long,
) : BaseHandler {
val log = logger()
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")
override fun handle(request: Map<String, Any>): Any? {
val userId = parsing { request["userId"]!!.long }
val userData = db.userData.findByCardExtId(userId)() ?: return null
val userPrint = parsing { mapper.convert(request["userPrintDetail"]!!, Mai2UserPrintDetail::class.java) }
val newCard = userPrint.userCard ?: return null
newCard.user = userData
newCard.startDate = LocalDateTime.now().format(formatter)
newCard.endDate = LocalDateTime.now().plusDays(expirationTime).format(formatter)
newCard.id = db.userCard.findByUserAndCardId(newCard.user, newCard.cardId)()?.id ?: 0
db.userCard.save(newCard)
userPrint.user = userData
userPrint.serialId = buildString {
append(String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L)))
append(String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L)))
}
db.userPrintDetail.save(userPrint)
return mapOf(
"returnCode" to 1,
"orderId" to 0,
"serialId" to userPrint.serialId,
"startDate" to newCard.startDate,
"endDate" to newCard.endDate
)
}
}

View File

@ -0,0 +1,13 @@
package icu.samnyan.aqua.sega.maimai2.model
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae
class UserRating {
var rating = 0
var ratingList: List<Mai2UserRate> = emptyList()
var newRatingList: List<Mai2UserRate> = emptyList()
var nextRatingList: List<Mai2UserRate> = emptyList()
var nextNewRatingList: List<Mai2UserRate> = emptyList()
var udemae: Mai2UserUdemae = Mai2UserUdemae()
}

View File

@ -0,0 +1,12 @@
package icu.samnyan.aqua.sega.maimai2.model
class UserRivalMusic(
var musicId: Int,
var userRivalMusicDetailList: MutableList<UserRivalMusicDetail> = mutableListOf()
)
class UserRivalMusicDetail(
var level: Int,
var achievement: Int,
var deluxscoreMax: Int
)

View File

@ -0,0 +1,53 @@
package icu.samnyan.aqua.sega.maimai2.model.request
import icu.samnyan.aqua.sega.maimai2.model.UserRating
import icu.samnyan.aqua.sega.maimai2.model.userdata.*
class Mai2UpsertUserAll(
var userId: Long,
var upsertUserAll: Mai2UserAll
)
class Mai2UserAll {
var userData: List<Mai2UserDetail> = emptyList()
var userOption: List<Mai2UserOption>? = null
var userExtend: List<Mai2UserExtend>? = null
var userCharacterList: List<Mai2UserCharacter>? = null
var userGhost: List<Mai2UserGhost>? = null
var userMapList: List<Mai2UserMap>? = null
var userLoginBonusList: List<Mai2UserLoginBonus>? = null
var userRatingList: List<UserRating>? = null
var userItemList: List<Mai2UserItem>? = null
var userMusicDetailList: List<Mai2UserMusicDetail>? = null
var userCourseList: List<Mai2UserCourse>? = null
var userFriendSeasonRankingList: List<Mai2UserFriendSeasonRanking>? = null
var userChargeList: List<Mai2UserCharge>? = null
var userFavoriteList: List<Mai2UserFavorite>? = null
var userActivityList: List<Mai2UserActivity>? = null
var userGamePlaylogList: List<Map<String, Any>>? = null
var userFavoritemusicList: List<Mai2UserFavoriteItem>? = null
var userKaleidxScopeList: List<Mai2UserKaleidx>? = null
var userIntimateList: List<Mai2UserIntimate>? = null
var isNewCharacterList: String? = null
var isNewMapList: String? = null
var isNewLoginBonusList: String? = null
var isNewItemList: String? = null
var isNewMusicDetailList: String? = null
var isNewCourseList: String? = null
var isNewFavoriteList: String? = null
var isNewFriendSeasonRankingList: String? = null
var isNewFavoritemusicList: String? = null
var isNewKaleidxScopeList: String? = null
}
class Mai2UserFavoriteItem {
var orderId = 0
var id = 0
}
class Mai2UserActivity {
var playList: List<Mai2UserAct> = emptyList()
var musicList: List<Mai2UserAct> = emptyList()
}

View File

@ -0,0 +1,25 @@
package icu.samnyan.aqua.sega.maimai2.model.request
class Mai2UserPhoto {
var orderId = 0
var userId: Long = 0
var divNumber = 0
var divLength = 0
var divData: String? = null
var placeId = 0
var clientId: String? = null
var uploadDate: String? = null
var playlogId: Long = 0
var trackNo = 0
}
class Mai2UserPortrait {
var userId: Long = 0
var divNumber = 0
var divLength = 0
var divData: String? = null
var placeId = 0
var clientId: String = ""
var uploadDate: String = "1970-01-01 09:00:00.0"
var fileName: String = "portrait.jpg"
}

View File

@ -1,18 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPhoto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadUserPhoto implements Serializable {
private UserPhoto userPhoto;
}

View File

@ -1,20 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadUserPlaylog implements Serializable {
private long userId;
private Mai2UserPlaylog userPlaylog;
}

View File

@ -1,18 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPortrait;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadUserPortrait implements Serializable {
private UserPortrait userPortrait;
}

View File

@ -1,25 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserAll;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpsertUserAll implements Serializable {
private long userId;
private long playlogId;
@JsonProperty("isEventMode")
private boolean isEventMode;
@JsonProperty("isFreePlay")
private boolean isFreePlay;
private UserAll upsertUserAll;
}

View File

@ -1,22 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Map;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpsertUserPrint implements Serializable {
private long userId;
private long orderId;
private Map<String, Object> userPrintReserve;
private Mai2UserPrintDetail userPrintDetail;
}

View File

@ -1,50 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import icu.samnyan.aqua.sega.maimai2.model.response.data.Mai2UserActivity;
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRating;
import icu.samnyan.aqua.sega.maimai2.model.userdata.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserAll implements Serializable {
private List<Mai2UserDetail> userData;
private List<Mai2UserExtend> userExtend;
private List<Mai2UserOption> userOption;
private List<Mai2UserCharacter> userCharacterList;
private List<Mai2UserGhost> userGhost;
private List<Mai2UserMap> userMapList;
private List<Mai2UserLoginBonus> userLoginBonusList;
private List<UserRating> userRatingList;
private List<Mai2UserItem> userItemList;
private List<Mai2UserMusicDetail> userMusicDetailList;
private List<Mai2UserCourse> userCourseList;
private List<Mai2UserFriendSeasonRanking> userFriendSeasonRankingList;
private List<Mai2UserCharge> userChargeList;
private List<Mai2UserFavorite> userFavoriteList;
private List<Mai2UserActivity> userActivityList;
private List<Map<String, Object>> userGamePlaylogList;
private List<UserFavoriteItem> userFavoritemusicList;
private List<Mai2UserKaleidx> userKaleidxScopeList;
private List<Mai2UserIntimate> userIntimateList;
private String isNewCharacterList;
private String isNewMapList;
private String isNewLoginBonusList;
private String isNewItemList;
private String isNewMusicDetailList;
private String isNewCourseList;
private String isNewFavoriteList;
private String isNewFriendSeasonRankingList;
private String isNewFavoritemusicList;
private String isNewKaleidxScopeList;
}

View File

@ -1,15 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserFavoriteItem implements Serializable {
private int orderId;
private int id;
}

View File

@ -1,26 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserPhoto implements Serializable {
private int orderId;
private long userId;
private int divNumber;
private int divLength;
private String divData;
private int placeId;
private String clientId;
private String uploadDate;
private long playlogId;
private int trackNo;
}

View File

@ -1,24 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserPortrait implements Serializable {
private long userId;
private int divNumber;
private int divLength;
private String divData;
private int placeId;
private String clientId;
private String uploadDate;
private String fileName;
}

View File

@ -1,19 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import java.util.List;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserAct;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Mai2UserActivity {
private List<Mai2UserAct> playList;
private List<Mai2UserAct> musicList;
}

View File

@ -1,24 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import java.util.List;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRating {
private int rating;
private List<Mai2UserRate> ratingList;
private List<Mai2UserRate> newRatingList;
private List<Mai2UserRate> nextRatingList;
private List<Mai2UserRate> nextNewRatingList;
private Mai2UserUdemae udemae;
}

View File

@ -1,15 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRivalMusic {
private int musicId;
private List<UserRivalMusicDetail> userRivalMusicDetailList;
}

View File

@ -1,14 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRivalMusicDetail {
private int level;
private int achievement;
private int deluxscoreMax;
}

View File

@ -1,96 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.worldslink
import ext.*
import icu.samnyan.aqua.net.utils.PathProps
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.io.BufferedWriter
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.io.path.readText
// KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class)
private val KJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
coerceInputValues = true
}
// Maximum time to live for a recruit record
const val MAX_TTL = 30 * 1000
@RestController
@RequestMapping(path = ["/mai2-futari"])
class FutariLobby(val paths: PathProps) {
// <IP Address, RecruitInfo>
val recruits = mutableMapOf<UInt, RecruitRecord>()
// Append writer
lateinit var writer: BufferedWriter
val mutex = ReentrantLock()
val log = logger()
init {
paths.init()
writer = FileOutputStream(File(paths.futariRecruitLog), true).bufferedWriter()
}
fun log(data: String) = mutex.withLock {
log.info(data)
writer.write(data)
writer.newLine()
writer.flush()
}
fun log(data: RecruitRecord, msg: String) =
log("${LocalDateTime.now().isoDateTime()}: $msg: ${KJson.encodeToString(data)}")
val RecruitRecord.ip get() = RecruitInfo.MechaInfo.IpAddress
@API("recruit/start")
fun startRecruit(@RB data: String) {
val d = parsing { KJson.decodeFromString<RecruitRecord>(data) }.apply { Time = millis() }
val exists = d.ip in recruits
recruits[d.ip] = d
if (!exists) log(d, "StartRecruit")
d.RecruitInfo.MechaInfo.UserIDs = d.RecruitInfo.MechaInfo.UserIDs.map { it.str.hashToUInt().toLong() }
}
@API("recruit/finish")
fun finishRecruit(@RB data: String) {
val d = parsing { KJson.decodeFromString<RecruitRecord>(data) }
if (d.ip !in recruits) 400 - "Recruit not found"
// if (d.Keychip != recruits[d.ip]!!.Keychip) 400 - "Keychip mismatch"
recruits.remove(d.ip)
log(d, "EndRecruit")
}
@API("recruit/list")
fun listRecruit(): String {
val time = millis()
recruits.filterValues { time - it.Time > MAX_TTL }.keys.forEach { recruits.remove(it) }
return recruits.values.toList().joinToString("\n") { KJson.encodeToString(it) }
}
@API("server-list")
fun serverList() = paths.futariRelayInfo.path().readText().trim()
}
fun main(args: Array<String>) {
val json = """{"RecruitInfo":{"MechaInfo":{"IsJoin":true,"IpAddress":1820162433,"MusicID":11692,"Entrys":[true,false],"UserIDs":[281474976710657,281474976710657],"UserNames":["",""],"IconIDs":[1,1],"FumenDifs":[0,-1],"Rateing":[0,0],"ClassValue":[0,0],"MaxClassValue":[0,0],"UserType":[0,0]},"MusicID":11692,"GroupID":0,"EventModeID":false,"JoinNumber":1,"PartyStance":0,"_startTimeTicks":638725464510308001,"_recvTimeTicks":0}}"""
println(json.jsonMap().toJson())
val data = KJson.decodeFromString<RecruitRecord>(json)
println(json)
println(KJson.encodeToString(data))
println(data)
}

View File

@ -1,187 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.worldslink
import ext.logger
import ext.md5
import ext.millis
import ext.thread
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
import kotlin.collections.set
import kotlin.concurrent.withLock
const val PROTO_VERSION = 1
const val MAX_STREAMS = 10
const val SO_TIMEOUT = 20000
//const val SO_TIMEOUT = 10000000
fun ctlMsg(cmd: UInt, data: String? = null) = Msg(cmd, data = data)
data class ActiveClient(
val clientKey: String,
val socket: Socket,
val reader: BufferedReader,
val writer: BufferedWriter,
val thread: Thread = Thread.currentThread(),
// <Stream ID, Destination client stub IP>
val tcpStreams: MutableMap<UInt, UInt> = mutableMapOf(),
val pendingStreams: MutableSet<UInt> = mutableSetOf(),
) {
val log = logger()
val stubIp = keychipToStubIp(clientKey)
val writeMutex = ReentrantLock()
var lastHeartbeat = millis()
fun send(msg: Msg) {
writeMutex.withLock {
try {
writer.write(msg.toString())
writer.newLine()
writer.flush()
}
catch (e: Exception) {
log.error("Error sending message", e)
socket.close()
thread.interrupt()
}
}
}
}
fun ActiveClient.handle(msg: Msg) {
// Find target by dst IP address or TCP stream ID
val target = (msg.sid?.let { tcpStreams[it] } ?: msg.dst)?.let { clients[it] }
when (msg.cmd) {
Command.CTL_HEARTBEAT -> {
lastHeartbeat = millis()
send(ctlMsg(Command.CTL_HEARTBEAT))
}
Command.DATA_BROADCAST -> {
// Broadcast to all clients. This is only used in UDP so SID is always 0
if (msg.proto != Proto.UDP) return log.warn("TCP Broadcast received, something is wrong.")
clients.values.forEach { it.send(msg.copy(src = stubIp)) }
}
Command.DATA_SEND -> {
target ?: return log.warn("Send: Target not found: ${msg.dst}")
if (msg.proto == Proto.TCP && msg.sid !in tcpStreams)
return log.warn("Stream ID not found: ${msg.sid}")
target.send(msg.copy(src = stubIp, dst = target.stubIp))
}
Command.CTL_TCP_CONNECT -> {
target ?: return log.warn("Connect: Target not found: ${msg.dst}")
val sid = msg.sid ?: return log.warn("Connect: Stream ID not found")
if (sid in tcpStreams || sid in pendingStreams)
return log.warn("Stream ID already in use: $sid")
// Add the stream to the pending list
pendingStreams.add(sid)
if (pendingStreams.size > MAX_STREAMS) {
log.warn("Too many pending streams, closing connection")
return socket.close()
}
target.send(msg.copy(src = stubIp, dst = target.stubIp))
}
Command.CTL_TCP_ACCEPT -> {
target ?: return log.warn("Accept: Target not found: ${msg.dst}")
val sid = msg.sid ?: return log.warn("Accept: Stream ID not found")
if (sid !in target.pendingStreams)
return log.warn("Stream ID not found in pending list: $sid")
// Add the stream to the active list
target.pendingStreams.remove(sid)
target.tcpStreams[sid] = stubIp
tcpStreams[sid] = target.stubIp
target.send(msg.copy(src = stubIp, dst = target.stubIp))
}
}
}
fun String.hashToUInt() = md5().let {
((it[0].toUInt() and 0xFFu) shl 24) or
((it[1].toUInt() and 0xFFu) shl 16) or
((it[2].toUInt() and 0xFFu) shl 8) or
(it[3].toUInt() and 0xFFu)
}
fun keychipToStubIp(keychip: String) = keychip.hashToUInt()
// Keychip ID to Socket
val clients = ConcurrentHashMap<UInt, ActiveClient>()
/**
* Service for the party linker for AquaMai
*/
class MaimaiFutari(private val port: Int = 20101) {
val log = logger()
fun start() {
val serverSocket = ServerSocket(port)
log.info("Server started on port $port")
while (true) {
val clientSocket = serverSocket.accept().apply {
soTimeout = SO_TIMEOUT
log.info("[+] Client connected: $remoteSocketAddress")
}
thread { handleClient(clientSocket) }
}
}
fun handleClient(socket: Socket) {
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream()))
var handler: ActiveClient? = null
try {
while (!Thread.interrupted() && !socket.isClosed) {
val input = (reader.readLine() ?: break).trim('\uFEFF')
if (input != "1,3") log.info("${socket.remoteSocketAddress} (${handler?.clientKey}) <<< $input")
val message = Msg.fromString(input)
when (message.cmd) {
// Start: Register the client. Payload is the keychip
Command.CTL_START -> {
val id = message.data as String
val client = ActiveClient(id, socket, reader, writer)
clients[client.stubIp]?.socket?.close()
clients[client.stubIp] = client
handler = clients[client.stubIp]
log.info("[+] Client registered: ${socket.remoteSocketAddress} -> $id")
// Send back the version
handler?.send(ctlMsg(Command.CTL_START, "version=$PROTO_VERSION"))
}
// Handle any other command using the handler
else -> {
(handler ?: throw Exception("Client not registered")).handle(message)
}
}
}
} catch (e: Exception) {
if (e.message != "Connection reset" && e !is SocketTimeoutException)
log.error("Error in client handler", e)
} finally {
// Remove client
handler?.stubIp?.let { clients.remove(it) }
socket.close()
log.info("[-] Client disconnected: ${handler?.clientKey}")
}
}
}
fun main() = MaimaiFutari().start()

View File

@ -1,100 +0,0 @@
@file:Suppress("PropertyName")
package icu.samnyan.aqua.sega.maimai2.worldslink
import ext.*
import kotlinx.serialization.Serializable
object Command {
// Control plane
const val CTL_START = 1u
const val CTL_BIND = 2u
const val CTL_HEARTBEAT = 3u
const val CTL_TCP_CONNECT = 4u // Accept a new multiplexed TCP stream
const val CTL_TCP_ACCEPT = 5u
const val CTL_TCP_ACCEPT_ACK = 6u
const val CTL_TCP_CLOSE = 7u
// Data plane
const val DATA_SEND = 21u
const val DATA_BROADCAST = 22u
}
object Proto {
const val TCP = 6u
const val UDP = 17u
}
data class Msg(
var cmd: UInt,
var proto: UInt? = null,
var sid: UInt? = null,
var src: UInt? = null,
var sPort: UInt? = null,
var dst: UInt? = null,
var dPort: UInt? = null,
var data: String? = null
) {
override fun toString() = ls(
1, cmd, proto, sid, src, sPort, dst, dPort,
null, null, null, null, null, null, null, null, // reserved for future use
data
).joinToString(",") { it?.str ?: "" }.trimEnd(',')
companion object {
val fields = arr(Msg::proto, Msg::sid, Msg::src, Msg::sPort, Msg::dst, Msg::dPort)
fun fromString(str: String): Msg {
val sp = str.split(',')
return Msg(0u).apply {
cmd = sp[1].toUInt()
fields.forEachIndexed { i, f -> f.set(this, sp.getOrNull(i + 2)?.some?.toUIntOrNull()) }
data = sp.drop(16).joinToString(",")
}
}
}
}
@Serializable
data class MechaInfo(
val IsJoin: Bool,
val IpAddress: UInt,
val MusicID: Int,
val Entrys: List<Bool>,
var UserIDs: List<Long>,
val UserNames: List<String>,
val IconIDs: List<Int>,
val FumenDifs: List<Int>,
val Rateing: List<Int>,
val ClassValue: List<Int>,
val MaxClassValue: List<Int>,
val UserType: List<Int>
)
@Serializable
data class RecruitInfo(
val MechaInfo: MechaInfo,
val MusicID: Int,
val GroupID: Int,
val EventModeID: Boolean,
val JoinNumber: Int,
val PartyStance: Int,
val _startTimeTicks: Long,
val _recvTimeTicks: Long
)
@Serializable
data class RecruitRecord(
val RecruitInfo: RecruitInfo,
val Keychip: String,
var Server: RelayServerInfo? = null,
var Time: Long = 0,
)
@Serializable
data class RelayServerInfo(
val name: String,
val addr: String,
val port: Int = 20101,
val official: Bool = true
)

View File

@ -2,8 +2,6 @@ package icu.samnyan.aqua.sega.ongeki.model.userdata;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import icu.samnyan.aqua.sega.util.jackson.UserIdSerializer;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

View File

@ -1,29 +0,0 @@
package icu.samnyan.aqua.sega.util
import java.util.*
import kotlin.text.Charsets.UTF_8
object AllNetBillingDecoder {
/**
* Decode the input byte array from Base64 MIME encoding and decompress the decoded byte array
*/
fun decode(src: ByteArray, base64: Boolean, nowrap: Boolean): Map<String, String> {
// Decode the input byte array from Base64 MIME encoding
val bytes = if (!base64) src else Base64.getMimeDecoder().decode(src)
// Decompress the decoded byte array
val output = ZLib.decompress(bytes, nowrap).toString(UTF_8).trim()
// Split the string by '&' symbol to separate key-value pairs
return output.split("&").associate {
val (key, value) = it.split("=")
key to value
}
}
@JvmStatic
fun decodeAllNet(src: ByteArray) = decode(src, base64 = true, nowrap = false)
@JvmStatic
fun decodeBilling(src: ByteArray) = decode(src, base64 = false, nowrap = true)
}

View File

@ -1,27 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import icu.samnyan.aqua.sega.general.model.Card;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class AccessCodeSerializer extends StdSerializer<Card> {
public AccessCodeSerializer() {
this(null);
}
public AccessCodeSerializer(Class<Card> t) {
super(t);
}
@Override
public void serialize(Card value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(value.getLuid());
}
}

View File

@ -1,55 +1,96 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.json.JsonWriteFeature
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import ext.jsonArray
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
interface IMapper {
fun write(o: Any?): String
open class IMapper(val mapper: ObjectMapper) {
fun write(o: Any): String = mapper.writeValueAsString(o)
fun <T> convert(map: Any, to: Class<T>) = mapper.convertValue(map, to)
fun <T> convert(map: Any, to: TypeReference<T>): T = mapper.convertValue(map, to)
fun <T> read(json: String, to: Class<T>) = mapper.readValue(json, to)
fun <T> read(json: String, to: TypeReference<T>) = mapper.readValue(json, to)
inline fun <reified T> convert(map: Any): T = convert(map, object : TypeReference<T>() {})
inline fun <reified T> read(json: String): T = read(json, object : TypeReference<T>() {})
}
val BASIC_MAPPER = jacksonObjectMapper().apply {
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
registerModule(SimpleModule().apply {
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
})
}
@Component
class BasicMapper: IMapper {
companion object {
val BASIC_MAPPER = jacksonObjectMapper().apply {
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
registerModule(SimpleModule().apply {
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
})
}
class BasicMapper: IMapper(BASIC_MAPPER)
val BOOLEAN_SERIALIZER = object : StdSerializer<Boolean>(Boolean::class.java) {
override fun serialize(v: Boolean, gen: JsonGenerator, p: SerializerProvider) {
gen.writeString(v.toString())
}
override fun write(o: Any?) =
BASIC_MAPPER.writeValueAsString(o)
fun <T> read(jsonStr: String?, toClass: Class<T>?) =
BASIC_MAPPER.readValue(jsonStr, toClass)
fun <T> read(jsonStr: String?, toValueTypeRef: TypeReference<T>?) =
BASIC_MAPPER.readValue(jsonStr, toValueTypeRef)
fun <T> convert(map: Any?, toClass: Class<T>?) =
BASIC_MAPPER.convertValue(map, toClass)
fun <T> convert(map: Any?, toValueTypeRef: TypeReference<T>?) =
BASIC_MAPPER.convertValue(map, toValueTypeRef)
fun toMap(obj: Any?): LinkedHashMap<String, Any?> =
BASIC_MAPPER.convertValue(obj, object : TypeReference<LinkedHashMap<String, Any?>>() {})
}
var STRING_MAPPER = jacksonObjectMapper().apply {
enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS.mappedFeature())
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
registerModule(SimpleModule().apply {
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
addSerializer(Boolean::class.javaObjectType, BOOLEAN_SERIALIZER)
addSerializer(Boolean::class.javaPrimitiveType, BOOLEAN_SERIALIZER)
})
}
@Component
class StringMapper: IMapper(STRING_MAPPER)
// Testing code
private class A(
var cat: String = ""
)
fun main(args: Array<String>) {
val json = """{"cat":"meow"}"""
val a = BasicMapper().read(json, A::class.java)
println(a.cat)
val lst = """[{"cat":"meow"}, {"cat":"meow"}]"""
val b = BasicMapper().convert(lst.jsonArray(), object : TypeReference<List<A>>() {})
println(b[0].cat)
println(b.size)
val c = BasicMapper().convert<List<A>>(lst.jsonArray())
println(c[0].cat)
}

View File

@ -1,19 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class BooleanNumberDeserializer extends JsonDeserializer<Boolean> {
@Override
public Boolean deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
return !"0".equals(p.getText());
}
}

View File

@ -1,26 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class BooleanNumberSerializer extends StdSerializer<Boolean> {
public BooleanNumberSerializer() {
this(null);
}
public BooleanNumberSerializer(Class<Boolean> t) {
super(t);
}
@Override
public void serialize(Boolean value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeNumber(value ? 1 : 0);
}
}

View File

@ -1,23 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import ext.int
class BooleanToIntegerDeserializer : JsonDeserializer<Int>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Int {
return when (p.currentToken) {
JsonToken.VALUE_STRING -> when (val str = p.valueAsString.lowercase()) {
"true" -> 1
"false" -> 0
else -> str.int
}
JsonToken.VALUE_NUMBER_INT -> p.intValue
JsonToken.VALUE_TRUE -> 1
JsonToken.VALUE_FALSE -> 0
else -> throw UnsupportedOperationException("Cannot deserialize to boolean int")
}
}
}

View File

@ -1,30 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class ByteBufSerializer extends StdSerializer<ByteBuf> {
public ByteBufSerializer() {
this(null);
}
public ByteBufSerializer(Class<ByteBuf> t) {
super(t);
}
@Override
public void serialize(ByteBuf value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(ByteBufUtil.hexDump(value));
}
}

View File

@ -0,0 +1,47 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import ext.int
import icu.samnyan.aqua.sega.general.model.Card
import java.time.ZonedDateTime
class AccessCodeSerializer @JvmOverloads constructor(t: Class<Card>? = null) : StdSerializer<Card>(t) {
override fun serialize(value: Card, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeString(value.luid)
}
}
class BooleanToIntegerDeserializer : JsonDeserializer<Int>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = when (p.currentToken) {
JsonToken.VALUE_STRING -> when (val str = p.valueAsString.lowercase()) {
"true" -> 1
"false" -> 0
else -> str.int
}
JsonToken.VALUE_NUMBER_INT -> p.intValue
JsonToken.VALUE_TRUE -> 1
JsonToken.VALUE_FALSE -> 0
else -> throw UnsupportedOperationException("Cannot deserialize to boolean int")
}
}
class BooleanNumberSerializer @JvmOverloads constructor(t: Class<Boolean>? = null) : StdSerializer<Boolean>(t) {
override fun serialize(value: Boolean, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeNumber(if (value) 1 else 0)
}
}
class BooleanNumberDeserializer : JsonDeserializer<Boolean>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = "0" != p.text
}
class ZonedDateTimeDeserializer : JsonDeserializer<ZonedDateTime>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = ZonedDateTime.parse(p.text)
}

View File

@ -1,51 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.json.JsonWriteFeature
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Component
class StringMapper: IMapper {
override fun write(o: Any?) = STRING_MAPPER.writeValueAsString(o)
fun <T> convert(map: Any?, toClass: Class<T>?) = STRING_MAPPER.convertValue(map, toClass)
final inline fun <reified T> convert(map: Any?) = convert(map, T::class.java)
fun toMap(obj: Any?) = STRING_MAPPER.convertValue(obj, object : TypeReference<LinkedHashMap<String, Any>>() {})
companion object {
val BOOLEAN_SERIALIZER = object : StdSerializer<Boolean>(Boolean::class.java) {
override fun serialize(v: Boolean, gen: JsonGenerator, p: SerializerProvider) {
gen.writeString(v.toString())
}
}
var STRING_MAPPER = jacksonObjectMapper().apply {
enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS.mappedFeature())
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
registerModule(SimpleModule().apply {
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
addSerializer(Boolean::class.javaObjectType, BOOLEAN_SERIALIZER)
addSerializer(Boolean::class.javaPrimitiveType, BOOLEAN_SERIALIZER)
})
}
}
}

View File

@ -1,27 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import icu.samnyan.aqua.sega.ongeki.model.userdata.UserData;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class UserIdSerializer extends StdSerializer<UserData> {
public UserIdSerializer() {
this(null);
}
public UserIdSerializer(Class<UserData> t) {
super(t);
}
@Override
public void serialize(UserData value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeNumber(value.getCard().getExtId());
}
}

View File

@ -1,19 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
import java.time.ZonedDateTime;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class ZonedDateTimeDeserializer extends JsonDeserializer<ZonedDateTime> {
@Override
public ZonedDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
return ZonedDateTime.parse(p.getText());
}
}