merge upstream
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
2025-09-12 16:46:24 +08:00
53 changed files with 1939 additions and 331 deletions

View File

@@ -79,6 +79,8 @@
<Router {url}> <Router {url}>
<Route path="/" component={Welcome} /> <Route path="/" component={Welcome} />
<Route path="/verify" component={Welcome} /> <!-- For email verification only, backwards compatibility with AquaNet2 in the future -->
<Route path="/reset-password" component={Welcome} />
<Route path="/home" component={Home} /> <Route path="/home" component={Home} />
<Route path="/ranking" component={Ranking} /> <Route path="/ranking" component={Ranking} />
<Route path="/ranking/:game" component={Ranking} /> <Route path="/ranking/:game" component={Ranking} />

View File

@@ -0,0 +1,32 @@
<script lang="ts">
export let username: string;
export let email: string;
let shouldShow = navigator.language.startsWith('zh');
// 会导致瞬间出现,但是不知道为什么 svelte 的 transition 动画不工作
// if (!shouldShow) {
// fetch('https://47.122.72.135/ip/isChina')
// .then(it => it.json())
// .then(it => shouldShow = it)
// .catch(() => shouldShow = false);
// }
const jump = () => {
const params = new URLSearchParams();
if (username) params.set('username', username);
if (email) params.set('email', email);
location.href = `https://portal.mumur.net/register?${params.toString()}`;
}
</script>
{#if shouldShow}
<div class="cursor-pointer" on:click={jump}>
<h2>MuNET 了解一下!</h2>
<div>
<p>MuNET 是 AquaDX 的继任者,提供更适合中国用户的服务器和更好的游戏体验。</p>
<p>如果你还没有游戏数据,建议在 MuNET 上创建账号并开始游戏。点击立即前往</p>
</div>
</div>
{/if}

View File

@@ -70,7 +70,7 @@
if (ubKey == 'namePlateId') ubKey = 'nameplateId' if (ubKey == 'namePlateId') ubKey = 'nameplateId'
if (ubKey == 'systemVoiceId') ubKey = 'voiceId' if (ubKey == 'systemVoiceId') ubKey = 'voiceId'
return [{ iKey, ubKey: ubKey as keyof UserBox, return [{ iKey, ubKey: ubKey as keyof UserBox,
items: profile.items.filter(x => x.itemKind === iKind) items: profile.items.filter(x => x.itemKind === iKind || (iKey == "trophy" && x.itemKind == 3))
}] }]
} }
@@ -106,6 +106,133 @@
.finally(() => submitting = "") .finally(() => submitting = "")
} }
async function exportBatchManual() {
submitting = "batchExport"
const DIFFICULTY_MAP: Record<number, string> = {
0: "BASIC",
1: "ADVANCED",
2: "EXPERT",
3: "MASTER",
4: "ULTIMA"
} as const // WORLD'S END scores not supported by Tachi
const DAN_MAP: Record<number, string> = {
1: "DAN_I",
2: "DAN_II",
3: "DAN_III",
4: "DAN_IV",
5: "DAN_V",
6: "DAN_INFINITE"
} as const
const SKILL_IDS: Record<number, string> = {
100009: 'CATASTROPHY',
102009: 'CATASTROPHY',
103007: 'CATASTROPHY',
100008: 'ABSOLUTE',
101008: 'ABSOLUTE',
102008: 'ABSOLUTE',
103006: 'ABSOLUTE',
100007: 'BRAVE',
101007: 'BRAVE',
102007: 'BRAVE',
103005: 'BRAVE',
100005: 'HARD',
100006: 'HARD',
101004: 'HARD',
101005: 'HARD',
101006: 'HARD',
102004: 'HARD',
102005: 'HARD',
102006: 'HARD',
103002: 'HARD',
103003: 'HARD',
103004: 'HARD'
} as const
// Shamelessly stolen from https://github.com/beer-psi/saekawa/commit/b3bee13e126df2f4e2a449bdf971debb8c95ba40, needs to be updated every major version :(
let data: any
let output: any = {
"meta": {
"game": "chunithm",
"playtype": "Single",
"service": "AquaDX-Manual"
},
"scores": [],
"classes": {}
}
try {
data = await GAME.export('chu3')
}
catch (e) {
error = e.message
submitting = ""
return
}
if (data && "userPlaylogList" in data) {
for (let score of data.userPlaylogList) {
let clearLamp = null
let noteLamp = null
if (score.level in DIFFICULTY_MAP) {
if (score.isClear) {
clearLamp = score.skillId in SKILL_IDS ? SKILL_IDS[score.skillId] : "CLEAR"
}
else {
clearLamp = "FAILED"
}
if (score.score === 1010000) {
noteLamp = "ALL JUSTICE CRITICAL"
}
else if (score.isAllJustice) {
noteLamp = "ALL JUSTICE"
}
else if (score.isFullCombo) {
noteLamp = "FULL COMBO"
}
else {
noteLamp = "NONE"
}
output.scores.push({
"score": score.score,
"clearLamp": clearLamp,
"noteLamp": noteLamp,
"judgements": {
"jcrit": score.judgeHeaven + score.judgeCritical,
"justice": score.judgeJustice,
"attack": score.judgeAttack,
"miss": score.judgeGuilty
},
"matchType": "inGameID",
"identifier": score.musicId.toString(),
"difficulty": DIFFICULTY_MAP[score.level],
"timeAchieved": score.sortNumber * 1000,
"optional": {
"maxCombo": score.maxCombo
}
})
}
}
}
if (data.userData.classEmblemMedal in DAN_MAP) {
output.classes["dan"] = DAN_MAP[data.userData.classEmblemMedal]
}
if (data.userData.classEmblemBase in DAN_MAP) {
output.classes["emblem"] = DAN_MAP[data.userData.classEmblemBase]
}
download(JSON.stringify(output), `AquaDX_chu3_BatchManualExport_${userbox.userName}.json`)
submitting = ""
}
function download(data: string, filename: string) { function download(data: string, filename: string) {
const blob = new Blob([data]); const blob = new Blob([data]);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -301,6 +428,10 @@
<Icon icon="bxs:file-export"/> <Icon icon="bxs:file-export"/>
{t('settings.export')} {t('settings.export')}
</button> </button>
<button class="exportBatchManualButton" on:click={exportBatchManual}>
<Icon icon="bxs:file-export"/>
{t('settings.batchManualExport')}
</button>
</div> </div>
{/if} {/if}

View File

@@ -4,6 +4,7 @@
import GameSettingFields from "./GameSettingFields.svelte"; import GameSettingFields from "./GameSettingFields.svelte";
import { t, ts } from "../../libs/i18n"; import { t, ts } from "../../libs/i18n";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte"; import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
import RegionSelector from "./RegionSelector.svelte";
const rounding = useLocalStorage("rounding", true); const rounding = useLocalStorage("rounding", true);
</script> </script>
@@ -22,6 +23,11 @@
</label> </label>
</div> </div>
</div> </div>
<div class="divider"></div>
<blockquote>
{ts("settings.regionNotice")}
</blockquote>
<RegionSelector/>
</div> </div>
<style lang="sass"> <style lang="sass">
@@ -44,19 +50,10 @@
.desc .desc
opacity: 0.6 opacity: 0.6
.field .divider
display: flex width: 100%
flex-direction: column height: 0.5px
background: white
label opacity: 0.2
max-width: max-content margin: 0.4rem 0
> div:not(.bool)
display: flex
align-items: center
gap: 1rem
margin-top: 0.5rem
> input
flex: 1
</style> </style>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { USER} from "../../libs/sdk";
import { ts } from "../../libs/i18n";
import StatusOverlays from "../StatusOverlays.svelte";
let regionId = $state(0);
let submitting = ""
let error: string;
const prefectures = ["None","Aichi","Aomori","Akita","Ishikawa","Ibaraki","Iwate","Ehime","Oita","Osaka","Okayama","Okinawa","Kagawa","Kagoshima","Kanagawa","Gifu","Kyoto","Kumamoto","Gunma","Kochi","Saitama","Saga","Shiga","Shizuoka","Shimane","Chiba","Tokyo","Tokushima","Tochigi","Tottori","Toyama","Nagasaki","Nagano","Nara","Niigata","Hyogo","Hiroshima","Fukui","Fukuoka","Fukushima","Hokkaido","Mie","Miyagi","Miyazaki","Yamagata","Yamaguchi","Yamanashi","Wakayama"]
USER.me().then(user => {
const parsedRegion = parseInt(user.region);
if (!isNaN(parsedRegion) && parsedRegion > 0) {
regionId = parsedRegion - 1;
} else {
regionId = 0;
}
})
async function saveNewRegion() {
if (submitting) return false
submitting = "region"
await USER.changeRegion(regionId+1).catch(e => error = e.message).finally(() => submitting = "")
return true
}
</script>
<div class="fields">
<label for="rounding">
<span class="name">{ts(`settings.regionSelector.title`)}</span>
<span class="desc">{ts(`settings.regionSelector.desc`)}</span>
</label>
<select bind:value={regionId} on:change={saveNewRegion}>
<option value={0} disabled selected>{ts("settings.regionSelector.select")}</option>
{#each prefectures.slice(1) as prefecture, index}
<option value={index}>{prefecture}</option>
{/each}
</select>
</div>
<StatusOverlays {error} loading={!!submitting}/>
<style lang="sass">
@use "../../vars"
.fields
display: flex
flex-direction: column
gap: 12px
label
display: flex
flex-direction: column
.desc
opacity: 0.6
</style>

View File

@@ -19,6 +19,7 @@ export interface AquaNetUser {
email: string email: string
displayName: string displayName: string
country: string country: string
region:string
lastLogin: number lastLogin: number
regTime: number regTime: number
profileLocation: string profileLocation: string

View File

@@ -34,21 +34,31 @@ export const EN_REF_Welcome = {
'back': 'Back', 'back': 'Back',
'email': 'Email', 'email': 'Email',
'password': 'Password', 'password': 'Password',
'new-password': 'New password',
'username': 'Username', 'username': 'Username',
'welcome.btn-login': 'Log in', 'welcome.btn-login': 'Log in',
'welcome.btn-signup': 'Sign up', 'welcome.btn-signup': 'Sign up',
'welcome.email-password-missing': 'Email and password are required', 'welcome.btn-reset-password': 'Forgot password?',
'welcome.btn-submit-reset-password': 'Send reset link',
'welcome.btn-submit-new-password': 'Change password',
'welcome.email-missing': 'Email is required',
'welcome.password-missing': 'Password is required',
'welcome.username-missing': 'Username/email is required', 'welcome.username-missing': 'Username/email is required',
'welcome.email-password-missing': 'Email and password are required',
'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...', 'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...',
'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.', 'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.',
'welcome.turnstile-timeout': 'Network verification timed out. Please try again.', 'welcome.turnstile-timeout': 'Network verification timed out. Please try again.',
'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!', 'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!',
'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!', 'welcome.reset-password-sent': 'A password reset email has been sent to ${email}. Please check your inbox!',
'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!', 'welcome.verify-state-0': 'You haven\'t verified your email. A verification email has been sent to your inbox just now. Please check your inbox!',
'welcome.verify-state-1': 'You haven\'t verified your email. You have requested too many emails, please try again later.',
'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!', 'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!',
'welcome.reset-state-0': 'A reset email has been sent to your inbox just now. Please check your inbox!',
'welcome.reset-state-1': 'Too many emails have been sent. Another will not be sent.',
'welcome.verifying': 'Verifying your email... please wait.', 'welcome.verifying': 'Verifying your email... please wait.',
'welcome.verified': 'Your email has been verified! You can now log in now.', 'welcome.verified': 'Your email has been verified! You can now log in now.',
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.', 'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
'welcome.password-reset-done': 'Your password has been updated! Please log back in.',
} }
export const EN_REF_LEADERBOARD = { export const EN_REF_LEADERBOARD = {
@@ -183,8 +193,13 @@ export const EN_REF_SETTINGS = {
'settings.profile.logout': 'Log out', 'settings.profile.logout': 'Log out',
'settings.profile.unchanged': 'Unchanged', 'settings.profile.unchanged': 'Unchanged',
'settings.export': 'Export Player Data', 'settings.export': 'Export Player Data',
'settings.batchManualExport': "Export in Batch Manual (for Tachi)",
'settings.cabNotice': "Note: These settings will only affect your own cab/setup. If you're playing on someone else's setup, please contact them to change these settings.", 'settings.cabNotice': "Note: These settings will only affect your own cab/setup. If you're playing on someone else's setup, please contact them to change these settings.",
'settings.gameNotice': "These only apply to Mai and Wacca." 'settings.gameNotice': "These only apply to Mai and Wacca.",
'settings.regionNotice': "These only apply to Mai, Ongeki and Chuni.",
'settings.regionSelector.title': "Prefecture Selector",
'settings.regionSelector.desc': "Select the region where you want the game to think you are playing",
'settings.regionSelector.select': "Select Prefecture",
} }
export const EN_REF_USERBOX = { export const EN_REF_USERBOX = {

View File

@@ -46,21 +46,31 @@ const zhWelcome: typeof EN_REF_Welcome = {
'back': '返回', 'back': '返回',
'email': '邮箱', 'email': '邮箱',
'password': '密码', 'password': '密码',
'new-password': '新密码',
'username': '用户名', 'username': '用户名',
'welcome.btn-login': '登录', 'welcome.btn-login': '登录',
'welcome.btn-signup': '注册', 'welcome.btn-signup': '注册',
'welcome.email-password-missing': '邮箱和密码必须填哦', 'welcome.btn-reset-password': '忘记密码?',
'welcome.btn-submit-reset-password': '发送重置链接',
'welcome.btn-submit-new-password': '修改密码',
'welcome.email-missing': '邮箱必须填哦',
'welcome.password-missing': '密码必须填哦',
'welcome.username-missing': '用户名/邮箱必须填哦', 'welcome.username-missing': '用户名/邮箱必须填哦',
'welcome.email-password-missing': '邮箱和密码必须填哦',
'welcome.waiting-turnstile': '正在验证网络环境…', 'welcome.waiting-turnstile': '正在验证网络环境…',
'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试', 'welcome.turnstile-error': '验证网络环境出错了,请关闭 VPN 后重试',
'welcome.turnstile-timeout': '验证网络环境超时了,请重试', 'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱', 'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
'welcome.reset-password-sent': '重置邮件已发送至 ${email},请翻翻收件箱',
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱', 'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱', 'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的 24 小时内已经发送了 3 封验证邮件,所以我们不会再发送了,请翻翻收件箱',
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱', 'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
'welcome.reset-state-0': '重置邮件刚刚发送到你的邮箱啦,请翻翻收件箱!',
'welcome.reset-state-1': '邮件发送次数过多,暂时不会再发送新的重置邮件了',
'welcome.verifying': '正在验证邮箱…请稍等', 'welcome.verifying': '正在验证邮箱…请稍等',
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了', 'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
'welcome.verification-failed': '验证失败:${message}。请重试', 'welcome.verification-failed': '验证失败:${message}。请重试',
'welcome.password-reset-done': '您的密码已更新!请重新登录',
} }
const zhLeaderboard: typeof EN_REF_LEADERBOARD = { const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
@@ -195,8 +205,17 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.profile.logout': '登出', 'settings.profile.logout': '登出',
'settings.profile.unchanged': '未更改', 'settings.profile.unchanged': '未更改',
'settings.export': '导出玩家数据', 'settings.export': '导出玩家数据',
'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi",
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置', 'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
'settings.gameNotice': "这些设置仅对舞萌和华卡生效。", 'settings.gameNotice': "这些设置仅对舞萌和华卡生效。",
// AI
'settings.regionNotice': "这些设置仅适用于舞萌、音击和中二。",
// AI
'settings.regionSelector.title': "地区选择器",
// AI
'settings.regionSelector.desc': "选择游戏中显示的地区",
// AI
'settings.regionSelector.select': "选择地区",
} }
export const zhUserbox: typeof EN_REF_USERBOX = { export const zhUserbox: typeof EN_REF_USERBOX = {

View File

@@ -163,12 +163,22 @@ async function login(user: { email: string, password: string, turnstile: string
localStorage.setItem('token', data.token) localStorage.setItem('token', data.token)
} }
async function resetPassword(user: { email: string, turnstile: string }) {
return await post('/api/v2/user/reset-password', user)
}
async function changePassword(user: { token: string, password: string }) {
return await post('/api/v2/user/change-password', user)
}
const isLoggedIn = () => !!localStorage.getItem('token') const isLoggedIn = () => !!localStorage.getItem('token')
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/') const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
export const USER = { export const USER = {
register, register,
login, login,
resetPassword,
changePassword,
confirmEmail: (token: string) => confirmEmail: (token: string) =>
post('/api/v2/user/confirm-email', { token }), post('/api/v2/user/confirm-email', { token }),
me: (): Promise<AquaNetUser> => { me: (): Promise<AquaNetUser> => {
@@ -186,6 +196,8 @@ export const USER = {
}, },
isLoggedIn, isLoggedIn,
ensureLoggedIn, ensureLoggedIn,
changeRegion: (regionId: number) =>
post('/api/v2/user/change-region', { regionId }),
} }
export const USERBOX = { export const USERBOX = {
@@ -254,5 +266,14 @@ export const TRANSFER = {
post('/api/v2/transfer/push', {}, { json: { client: d, data } }), post('/api/v2/transfer/push', {}, { json: { client: d, data } }),
} }
export const FEDY = {
status: (): Promise<{ linkedAt: number }> =>
post('/api/v2/fedy/status'),
link: (nonce: string): Promise<{ linkedAt: number }> =>
post('/api/v2/fedy/link', { nonce }),
unlink: () =>
post('/api/v2/fedy/unlink'),
}
// @ts-ignore // @ts-ignore
window.sdk = { USER, USERBOX, CARD, GAME, DATA, SETTING, TRANSFER } window.sdk = { USER, USERBOX, CARD, GAME, DATA, SETTING, TRANSFER, FEDY }

View File

@@ -10,6 +10,7 @@
import { t } from "../libs/i18n"; import { t } from "../libs/i18n";
import ImportDataAction from "./Home/ImportDataAction.svelte"; import ImportDataAction from "./Home/ImportDataAction.svelte";
import Communities from "./Home/Communities.svelte"; import Communities from "./Home/Communities.svelte";
import MigrateAction from "./Home/MigrateAction.svelte";
USER.ensureLoggedIn(); USER.ensureLoggedIn();
@@ -58,6 +59,9 @@
</ActionCard> </ActionCard>
<ImportDataAction/> <ImportDataAction/>
{#if me}
<MigrateAction username={me.username}/>
{/if}
</div> </div>
{:else if tab === 1} {:else if tab === 1}
<div out:fade={FADE_OUT} in:fade={FADE_IN}> <div out:fade={FADE_OUT} in:fade={FADE_IN}>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { fade } from "svelte/transition"
import { t } from "../../libs/i18n";
import ActionCard from "../../components/ActionCard.svelte";
import { CARD, GAME, USER } from "../../libs/sdk";
export let username: string;
let shouldShow = navigator.language.startsWith('zh');
let showWarning = false;
let isCardBindIssue = false;
if (!shouldShow) {
fetch('https://47.122.72.135/ip/isChina')
.then(it => it.json())
.then(it => shouldShow = it)
.catch(() => shouldShow = false);
}
CARD.userGames(username).then(games => {
if (!Object.values(games).some(it => it)) {
isCardBindIssue = true;
}
})
const handleClick = () => {
if (isCardBindIssue) {
showWarning = true;
return
}
jump()
}
const jump = () => {
const token = localStorage.getItem('token')
location.href = `https://portal.mumur.net/migrateFromAquaDx/${token}`
}
</script>
{#if shouldShow}
<ActionCard color="190, 149, 255" icon="system-uicons:jump-up" on:click={handleClick}>
<h3>迁移到 MuNET</h3>
<span>更适合中国宝宝体质的服务器AquaDX 的继任者。点击查看详情</span>
</ActionCard>
{/if}
{#if showWarning}
<div class="overlay" transition:fade>
<div>
<h2>提示</h2>
<p>看起来你在 AquaDX 还没有游戏数据,也许是因为没有绑卡或者绑定的卡不是在游戏中点击“查看卡号”获取的…</p>
<p>现在迁移的话,大概会导致你的游戏数据无法被正确的迁移。建议你先去检查一下吧</p>
<div class="buttons">
<button on:click={() => showWarning = false}>{t('action.cancel')}</button>
<button on:click={jump}>继续</button>
</div>
</div>
</div>
{/if}
<style lang="sass">
h3
font-size: 1.3rem
margin: 0
.buttons
display: grid
grid-template-columns: 1fr 1fr
gap: 1rem
</style>

View File

@@ -84,7 +84,7 @@
}).catch(err => error = err.message).finally(() => loading = false) }).catch(err => error = err.message).finally(() => loading = false)
} }
$: isBlacklist = !!blacklist.filter(x => src.dns.includes(x)) $: isBlacklist = blacklist.filter(x => src.dns.includes(x)).length > 0
</script> </script>
<StatusOverlays {loading} /> <StatusOverlays {loading} />

View File

@@ -5,6 +5,7 @@
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { USER } from "../libs/sdk"; import { USER } from "../libs/sdk";
import { t } from "../libs/i18n" import { t } from "../libs/i18n"
import MunetRegisterBanner from "../components/MunetRegisterBanner.svelte";
let params = new URLSearchParams(window.location.search) let params = new URLSearchParams(window.location.search)
@@ -20,28 +21,33 @@
let error = "" let error = ""
let verifyMsg = "" let verifyMsg = ""
let token = ""
if (USER.isLoggedIn()) { if (USER.isLoggedIn()) {
window.location.href = "/home" window.location.href = "/home"
} }
if (params.get('code')) {
token = params.get('code')!
if (location.pathname === '/verify') {
state = 'verify'
verifyMsg = t("welcome.verifying")
submitting = true
if (params.get('confirm-email')) { // Send request to server
state = 'verify' USER.confirmEmail(token)
verifyMsg = t("welcome.verifying") .then(() => {
submitting = true verifyMsg = t('welcome.verified')
submitting = false
// Send request to server
USER.confirmEmail(params.get('confirm-email')!)
.then(() => {
verifyMsg = t('welcome.verified')
submitting = false
// Clear the query param // Clear the query param
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
}) })
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message })) .catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
}
else if (location.pathname === '/reset-password') {
state = 'reset'
}
} }
async function submit(): Promise<any> { async function submit(): Promise<any> {
submitting = true submitting = true
@@ -94,14 +100,80 @@
state = 'verify' state = 'verify'
verifyMsg = t("welcome.verify-state-2") verifyMsg = t("welcome.verify-state-2")
} }
else if (e.message === 'Login not allowed: Card has been migrated to Minato.') {
location.href = `https://portal.mumur.net/login?username=${encodeURIComponent(email)}`
}
else {
error = e.message
submitting = false // unnecessary? see line 113, same for both reset functions
turnstileReset()
}
})
}
submitting = false
}
async function resetPassword(): Promise<any> {
submitting = true;
if (email === "") {
error = t("welcome.email-missing")
return submitting = false
}
if (TURNSTILE_SITE_KEY && turnstile === "") {
// Sleep for 100ms to allow Turnstile to finish
error = t("welcome.waiting-turnstile")
return setTimeout(resetPassword, 100)
}
// Send request to server
await USER.resetPassword({ email, turnstile })
.then(() => {
// Show email sent message, reusing email verify page
state = 'verify'
verifyMsg = t("welcome.reset-password-sent", { email })
})
.catch(e => {
if (e.message === "Reset request rejected - STATE_0") {
state = 'verify'
verifyMsg = t("welcome.reset-state-0")
}
else if (e.message === "Reset request rejected - STATE_1") {
state = 'verify'
verifyMsg = t("welcome.reset-state-1")
}
else { else {
error = e.message error = e.message
submitting = false submitting = false
turnstileReset() turnstileReset()
} }
}) })
submitting = false
}
async function changePassword(): Promise<any> {
submitting = true
if (password === "") {
error = t("welcome.password-missing")
return submitting = false
} }
// Send request to server
await USER.changePassword({ token, password })
.then(() => {
state = 'verify'
verifyMsg = t("welcome.password-reset-done")
})
.catch(e => {
error = e.message
submitting = false
turnstileReset()
})
submitting = false submitting = false
} }
@@ -120,11 +192,13 @@
{#if error} {#if error}
<span class="error">{error}</span> <span class="error">{error}</span>
{/if} {/if}
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'} {#if error != t("welcome.waiting-turnstile")}
role="button" tabindex="0" class="clickable"> <div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
<Icon icon="line-md:chevron-small-left" /> role="button" tabindex="0" class="clickable">
<span>{t('back')}</span> <Icon icon="line-md:chevron-small-left" />
</div> <span>{t('back')}</span>
</div>
{/if}
{#if isSignup} {#if isSignup}
<input type="text" placeholder={t('username')} bind:value={username}> <input type="text" placeholder={t('username')} bind:value={username}>
{/if} {/if}
@@ -137,6 +211,9 @@
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')} {isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
{/if} {/if}
</button> </button>
{#if state === "login" && !submitting}
<button on:click={() => state = 'submitreset'}>{t('welcome.btn-reset-password')}</button>
{/if}
{#if TURNSTILE_SITE_KEY} {#if TURNSTILE_SITE_KEY}
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset} <Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)} on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
@@ -144,6 +221,37 @@
on:turnstile-expired={_ => window.location.reload()} on:turnstile-expired={_ => window.location.reload()}
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} /> on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
{/if} {/if}
{#if isSignup}
<MunetRegisterBanner username={username} email={email}/>
{/if}
</div>
{:else if state === "submitreset"}
<div class="login-form" transition:slide>
{#if error}
<span class="error">{error}</span>
{/if}
{#if error != t("welcome.waiting-turnstile")}
<div on:click={() => state = 'login'} on:keypress={() => state = 'login'}
role="button" tabindex="0" class="clickable">
<Icon icon="line-md:chevron-small-left" />
<span>{t('back')}</span>
</div>
{/if}
<input type="email" placeholder={t('email')} bind:value={email}>
<button on:click={resetPassword}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('welcome.btn-submit-reset-password')}
{/if}
</button>
{#if TURNSTILE_SITE_KEY}
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
on:turnstile-expired={_ => window.location.reload()}
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
{/if}
</div> </div>
{:else if state === "verify"} {:else if state === "verify"}
<div class="login-form" transition:slide> <div class="login-form" transition:slide>
@@ -152,6 +260,20 @@
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button> <button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
{/if} {/if}
</div> </div>
{:else if state === "reset"}
{#if error}
<span class="error">{error}</span>
{/if}
<div class="login-form" transition:slide>
<input type="password" placeholder={t('new-password')} bind:value={password}>
<button on:click={changePassword}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('welcome.btn-submit-new-password')}
{/if}
</button>
</div>
{/if} {/if}
</div> </div>

View File

@@ -171,3 +171,21 @@ sourceSets {
java.srcDir("${layout.buildDirectory.get()}/generated/source/kapt/main") java.srcDir("${layout.buildDirectory.get()}/generated/source/kapt/main")
} }
} }
val copyDependencies by tasks.registering(Copy::class) {
from(configurations.runtimeClasspath)
into("${layout.buildDirectory.get()}/libs/lib")
}
val packageThin by tasks.registering(Jar::class) {
group = "build"
from(sourceSets.main.get().output)
manifest {
attributes(
"Main-Class" to "icu.samnyan.aqua.EntryKt",
"Class-Path" to configurations.runtimeClasspath.get().files.joinToString(" ") { "lib/${it.name}" }
)
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
dependsOn(copyDependencies)
}

View File

@@ -131,6 +131,11 @@ server.error.whitelabel.enabled=false
aqua-net.frontier.enabled=false aqua-net.frontier.enabled=false
aqua-net.frontier.ftk=0x00 aqua-net.frontier.ftk=0x00
## Fedy Settings
aqua-net.fedy.enabled=false
aqua-net.fedy.key=maigo
aqua-net.fedy.remote=http://localhost:2528/api/fedy
## APIs for bot management ## APIs for bot management
aqua-net.bot.enabled=true aqua-net.bot.enabled=true
aqua-net.bot.secret=hunter2 aqua-net.bot.secret=hunter2

View File

@@ -59,7 +59,7 @@ Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegist
* token: String * token: String
* **Returns**: User information * **Returns**: User information
**/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation **/user/login** : Login with email/username and password. This will also check if the email is verified and send another confirmation.
* email: String * email: String
* password: String * password: String
@@ -74,6 +74,18 @@ Located at: [icu.samnyan.aqua.net.UserRegistrar](icu/samnyan/aqua/net/UserRegist
* turnstile: String * turnstile: String
* **Returns**: Success message * **Returns**: Success message
**/user/reset-password** : Send the user a reset password email. This will also check if the email is verified or if many requests were sent recently.
* email: String
* turnstile: String
* **Returns** Success message
**/user/change-password** : Reset a user's password with a token sent through email to the user.
* token: String
* password: String
* **Returns** Success message
**/user/setting** : Validate and set a user setting field. **/user/setting** : Validate and set a user setting field.
* token: String * token: String

View File

@@ -8,6 +8,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonNamingStrategy
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
// Jackson // Jackson
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null") val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
@@ -21,7 +23,13 @@ val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, obj
}) })
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<java.time.LocalDateTime>() { val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<java.time.LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = override fun deserialize(parser: JsonParser, context: DeserializationContext) =
parser.text.asDateTime() ?: (400 - "Invalid date time value ${parser.text}") // First try standard formats via asDateTime() method
parser.text.asDateTime() ?: try {
// Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
} catch (e: Exception) {
400 - "Invalid date time value ${parser.text}"
}
}) })
val JACKSON = jacksonObjectMapper().apply { val JACKSON = jacksonObjectMapper().apply {
setSerializationInclusion(JsonInclude.Include.NON_NULL) setSerializationInclusion(JsonInclude.Include.NON_NULL)

View File

@@ -0,0 +1,204 @@
package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.sega.general.service.CardService
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.RestController
import java.security.MessageDigest
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaNetUserFedyRepo
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.net.components.JWT
import icu.samnyan.aqua.net.db.AquaNetUserFedy
import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.games.ImportController
import icu.samnyan.aqua.net.games.mai2.Mai2Import
import icu.samnyan.aqua.net.games.ExportOptions
import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPlaylogHandler as Mai2UploadUserPlaylogHandler
import icu.samnyan.aqua.sega.maimai2.handler.UpsertUserAllHandler as Mai2UpsertUserAllHandler
import icu.samnyan.aqua.net.utils.ApiException
import java.util.Arrays
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.net.games.GenericUserDataRepo
import icu.samnyan.aqua.net.games.IUserData
import java.util.concurrent.CompletableFuture
@Configuration
@ConfigurationProperties(prefix = "aqua-net.fedy")
class FedyProps {
var enabled: Boolean = false
var key: String = ""
var remote: String = ""
}
enum class FedyEvent {
Linked,
Unlinked,
Upserted,
Imported,
}
@RestController
@API("/api/v2/fedy")
class Fedy(
val jwt: JWT,
val userRepo: AquaNetUserRepo,
val userFedyRepo: AquaNetUserFedyRepo,
val mai2Import: Mai2Import,
val mai2UserDataRepo: Mai2UserDataRepo,
val mai2UploadUserPlaylog: Mai2UploadUserPlaylogHandler,
val mai2UpsertUserAll: Mai2UpsertUserAllHandler,
val props: FedyProps,
val transactionManager: PlatformTransactionManager
) {
val transaction by lazy { TransactionTemplate(transactionManager) }
private fun Str.checkKey() {
if (!props.enabled) 403 - "Fedy is disabled"
if (!MessageDigest.isEqual(this.toByteArray(), props.key.toByteArray())) 403 - "Invalid Key"
}
@API("/status")
fun handleStatus(@RP token: Str): Any {
val user = jwt.auth(token)
val userFedy = userFedyRepo.findByAquaNetUserAuId(user.auId)
return mapOf("linkedAt" to (userFedy?.createdAt?.toEpochMilli() ?: 0))
}
@API("/link")
fun handleLink(@RP token: Str, @RP nonce: Str): Any {
val user = jwt.auth(token)
if (userFedyRepo.findByAquaNetUserAuId(user.auId) != null) 412 - "User already linked"
val userFedy = AquaNetUserFedy(aquaNetUser = user)
userFedyRepo.save(userFedy)
notify(FedyEvent.Linked, mapOf("auId" to user.auId, "nonce" to nonce))
return mapOf("linkedAt" to userFedy.createdAt.toEpochMilli())
}
@API("/unlink")
fun handleUnlink(@RP token: Str): Any {
val user = jwt.auth(token)
val userFedy = userFedyRepo.findByAquaNetUserAuId(user.auId) ?: 412 - "User not linked"
userFedyRepo.delete(userFedy)
notify(FedyEvent.Unlinked, mapOf("auId" to user.auId))
return SUCCESS
}
private fun ensureUser(auId: Long): AquaNetUser {
val userFedy = userFedyRepo.findByAquaNetUserAuId(auId) ?: 404 - "User not linked"
val user = userRepo.findByAuId(auId) ?: 404 - "User not found"
return user
}
data class UnlinkByRemoteReq(val auId: Long)
@API("/unlink-by-remote")
fun handleUnlinkByRemote(@RH(KEY_HEADER) key: Str, @RB req: UnlinkByRemoteReq): Any {
key.checkKey()
val user = ensureUser(req.auId)
userFedyRepo.deleteByAquaNetUserAuId(user.auId)
// No need to notify remote, because initiated by remote
return SUCCESS
}
data class PullReq(val auId: Long, val game: Str, val exportOptions: ExportOptions)
@API("/pull")
fun handlePull(@RH(KEY_HEADER) key: Str, @RB req: PullReq): Any {
key.checkKey()
val user = ensureUser(req.auId)
fun catched(block: () -> Any) =
try { mapOf("result" to block()) }
catch (e: ApiException) { mapOf("error" to mapOf("code" to e.code, "message" to e.message.toString())) }
return when (req.game) {
"mai2" -> catched { mai2Import.export(user, req.exportOptions) }
else -> 406 - "Unsupported game"
}
}
data class PushReq(val auId: Long, val game: Str, val data: JDict, val removeOldData: Bool)
@Suppress("UNCHECKED_CAST")
@API("/push")
fun handlePush(@RH(KEY_HEADER) key: Str, @RB req: PushReq): Any {
key.checkKey()
val user = ensureUser(req.auId)
val extId = user.ghostCard.extId
fun<UserData : IUserData, UserRepo : GenericUserDataRepo<UserData>> removeOldData(repo: UserRepo) {
val oldData = repo.findByCard_ExtId(extId)
if (oldData.isPresent) {
log.info("Fedy: Deleting old data for $extId (${req.game})")
repo.delete(oldData.get());
repo.flush()
}
}
transaction.execute { when (req.game) {
"mai2" -> {
if (req.removeOldData) { removeOldData(mai2UserDataRepo) }
val userAll = req.data["upsertUserAll"] as JDict // UserAll first, prevent using backlog
mai2UpsertUserAll.handle(mapOf("userId" to extId, "upsertUserAll" to userAll))
val playlogs = req.data["userPlaylogList"] as List<JDict>
playlogs.forEach { mai2UploadUserPlaylog.handle(mapOf("userId" to extId, "userPlaylog" to it)) }
}
else -> 406 - "Unsupported game"
} }
return SUCCESS
}
fun onUpserted(game: Str, maybeExtId: Any?) = maybeNotifyAsync(FedyEvent.Upserted, game, maybeExtId)
fun onImported(game: Str, maybeExtId: Any?) = maybeNotifyAsync(FedyEvent.Imported, game, maybeExtId)
private fun maybeNotifyAsync(event: FedyEvent, game: Str, maybeExtId: Any?) = if (!props.enabled) {} else CompletableFuture.runAsync { try {
val extId = maybeExtId?.long ?: return@runAsync
val user = userRepo.findByGhostCardExtId(extId) ?: return@runAsync
val userFedy = userFedyRepo.findByAquaNetUserAuId(user.auId) ?: return@runAsync
notify(event, mapOf("auId" to user.auId, "game" to game))
} catch (e: Exception) {
log.error("Error handling Fedy on maybeNotifyAsync($event, $game, $maybeExtId)", e)
} }
private fun notify(event: FedyEvent, body: Any?) {
val MAX_RETRY = 3
val body = body?.toJson() ?: "{}"
var retry = 0
var shouldRetry = true
while (retry < MAX_RETRY) {
try {
val response = "${props.remote.trimEnd('/')}/notify/${event.name}".request()
.header("Content-Type" to "application/json")
.header(KEY_HEADER to props.key)
.post(body)
val statusCodeStr = response.statusCode().toString()
val hasError = !statusCodeStr.startsWith("2")
// Check for non-transient errors
if (hasError) {
if (!statusCodeStr.startsWith("5")) { shouldRetry = false }
throw Exception("Failed to notify Fedy event $event with body $body, status code $statusCodeStr")
}
return
} catch (e: Exception) {
retry++
if (retry >= MAX_RETRY || !shouldRetry) throw e
log.error("Error notifying Fedy event $event with body $body, retrying ($retry/$MAX_RETRY)", e)
}
}
}
companion object
{
const val KEY_HEADER = "X-Fedy-Key"
val log = logger()
fun getGameName(gameId: Str) = when (gameId) {
"SDEZ" -> "mai2"
else -> null // Not supported
}
}
}

View File

@@ -29,10 +29,12 @@ class UserRegistrar(
val geoIP: GeoIP, val geoIP: GeoIP,
val jwt: JWT, val jwt: JWT,
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo,
val cardRepo: CardRepository, val cardRepo: CardRepository,
val cardService: CardService, val cardService: CardService,
val validator: AquaUserServices, val validator: AquaUserServices,
val emailProps: EmailProperties, val emailProps: EmailProperties,
val sessionRepo: SessionTokenRepo,
final val paths: PathProps final val paths: PathProps
) { ) {
val portraitPath = paths.aquaNetPortrait.path() val portraitPath = paths.aquaNetPortrait.path()
@@ -144,6 +146,73 @@ class UserRegistrar(
return mapOf("token" to token) return mapOf("token" to token)
} }
@API("/reset-password")
@Doc("Reset password with a token sent through email to the user, if it exists.", "Success message")
suspend fun resetPassword(
@RP email: Str, @RP turnstile: Str,
request: HttpServletRequest
) : Any {
// Check captcha
val ip = geoIP.getIP(request)
log.info("Net: /user/reset-password from $ip : $email")
if (!turnstileService.validate(turnstile, ip)) 400 - "Invalid captcha"
// Check if user exists, treat as email / username
val user = async { userRepo.findByEmailIgnoreCase(email) ?: userRepo.findByUsernameIgnoreCase(email) }
?: return SUCCESS // obviously dont tell them if the email exists or not
// Check if email is verified
if (!user.emailConfirmed && emailProps.enable) 400 - "Email not verified"
val resets = async { resetPasswordRepo.findByAquaNetUserAuId(user.auId) }
val lastReset = resets.maxByOrNull { it.createdAt }
if (lastReset?.createdAt?.plusSeconds(60)?.isAfter(Instant.now()) == true) {
400 - "Reset request rejected - STATE_0"
}
// Check if we have sent more than 3 confirmation emails in the last 24 hours
if (resets.count { it.createdAt.plusSeconds(60 * 60 * 24).isAfter(Instant.now()) } > 3) {
400 - "Reset request rejected - STATE_1"
}
// Send a password reset email
emailService.sendPasswordReset(user)
return SUCCESS
}
@API("/change-password")
@Doc("Change a user's password given a reset code", "Success message")
suspend fun changePassword(
@RP token: Str, @RP password: Str,
request: HttpServletRequest
) : Any {
// Find the reset token
val reset = async { resetPasswordRepo.findByToken(token) }
// Check if the token is valid
if (reset == null) 400 - "Invalid token"
// Check if the token is expired
if (reset.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired"
// Change the password
async { userRepo.save(reset.aquaNetUser.apply { pwHash = validator.checkPwHash(password) }) }
// Remove the token from the list
resetPasswordRepo.delete(reset)
// Clear all sessions
sessionRepo.deleteAll(
sessionRepo.findByAquaNetUserAuId(reset.aquaNetUser.auId)
)
return SUCCESS
}
@API("/confirm-email") @API("/confirm-email")
@Doc("Confirm email address with a token sent through email to the user.", "Success message") @Doc("Confirm email address with a token sent through email to the user.", "Success message")
suspend fun confirmEmail(@RP token: Str): Any { suspend fun confirmEmail(@RP token: Str): Any {
@@ -185,6 +254,12 @@ class UserRegistrar(
// Save the user // Save the user
userRepo.save(u) userRepo.save(u)
// Clear all tokens if changing password
if (key == "pwHash")
sessionRepo.deleteAll(
sessionRepo.findByAquaNetUserAuId(u.auId)
)
} }
SUCCESS SUCCESS
@@ -227,4 +302,17 @@ class UserRegistrar(
SUCCESS SUCCESS
} }
@API("/change-region")
@Doc("Change the region of the user.", "Success message")
suspend fun changeRegion(@RP token: Str, @RP regionId: Str) = jwt.auth(token) { u ->
// Check if the region is valid (between 1 and 47)
val r = regionId.toIntOrNull() ?: (400 - "Invalid region")
if (r !in 1..47) 400 - "Invalid region"
async {
userRepo.save(u.apply { region = r.toString() })
}
SUCCESS
}
} }

View File

@@ -6,6 +6,8 @@ import ext.logger
import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.EmailConfirmation import icu.samnyan.aqua.net.db.EmailConfirmation
import icu.samnyan.aqua.net.db.EmailConfirmationRepo import icu.samnyan.aqua.net.db.EmailConfirmationRepo
import icu.samnyan.aqua.net.db.ResetPassword
import icu.samnyan.aqua.net.db.ResetPasswordRepo
import org.simplejavamail.api.mailer.Mailer import org.simplejavamail.api.mailer.Mailer
import org.simplejavamail.email.EmailBuilder import org.simplejavamail.email.EmailBuilder
import org.simplejavamail.springsupport.SimpleJavaMailSpringSupport import org.simplejavamail.springsupport.SimpleJavaMailSpringSupport
@@ -38,10 +40,13 @@ class EmailService(
val mailer: Mailer, val mailer: Mailer,
val props: EmailProperties, val props: EmailProperties,
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo,
) { ) {
val log = logger() val log = logger()
val confirmTemplate: Str = this::class.java.getResource("/email/confirm.html")?.readText() val confirmTemplate: Str = this::class.java.getResource("/email/confirm.html")?.readText()
?: throw Exception("Email Template Not Found") ?: throw Exception("Email Confirm Template Not Found")
val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText()
?: throw Exception("Password Reset Template Not Found")
@Async @Async
@EventListener(ApplicationStartedEvent::class) @EventListener(ApplicationStartedEvent::class)
@@ -69,15 +74,38 @@ class EmailService(
confirmationRepo.save(confirmation) confirmationRepo.save(confirmation)
// Send email // Send email
log.info("Sending confirmation email to ${user.email}") log.info("Sending verification email to ${user.email}")
mailer.sendMail(EmailBuilder.startingBlank() mailer.sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr) .from(props.senderName, props.senderAddr)
.to(user.computedName, user.email) .to(user.computedName, user.email)
.withSubject("Confirm Your Email Address for AquaNet") .withSubject("Verify Your Email Address for AquaNet")
.withHTMLText(confirmTemplate .withHTMLText(confirmTemplate
.replace("{{name}}", user.computedName) .replace("{{name}}", user.computedName)
.replace("{{url}}", "https://${props.webHost}?confirm-email=$token")) .replace("{{url}}", "https://${props.webHost}/verify?code=$token"))
.buildEmail()).thenRun { log.info("Confirmation email sent to ${user.email}") } .buildEmail()).thenRun { log.info("Verification email sent to ${user.email}") }
}
/**
* Send a reset password email to the user
*/
fun sendPasswordReset (user: AquaNetUser) {
if (!props.enable) return
// Generate token (UUID4)
val token = UUID.randomUUID().toString()
val reset = ResetPassword(token = token, aquaNetUser = user, createdAt = Date().toInstant())
resetPasswordRepo.save(reset)
// Send email
log.info("Sending reset password email to ${user.email}")
mailer.sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr)
.to(user.computedName, user.email)
.withSubject("Reset Your Password for AquaNet")
.withHTMLText(resetTemplate
.replace("{{name}}", user.computedName)
.replace("{{url}}", "https://${props.webHost}/reset-password?code=$token"))
.buildEmail()).thenRun { log.info("Reset password email sent to ${user.email}") }
} }
fun testEmail(addr: Str, name: Str) { fun testEmail(addr: Str, name: Str) {

View File

@@ -4,14 +4,19 @@ import ext.Str
import ext.minus import ext.minus
import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaNetUserRepo import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.SessionToken
import icu.samnyan.aqua.net.db.SessionTokenRepo
import icu.samnyan.aqua.net.db.getTokenExpiry
import io.jsonwebtoken.JwtParser import io.jsonwebtoken.JwtParser
import io.jsonwebtoken.Jwts import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import jakarta.transaction.Transactional
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey
@@ -24,7 +29,8 @@ class JWTProperties {
@Service @Service
class JWT( class JWT(
val props: JWTProperties, val props: JWTProperties,
val userRepo: AquaNetUserRepo val userRepo: AquaNetUserRepo,
val sessionRepo: SessionTokenRepo
) { ) {
val log = LoggerFactory.getLogger(JWT::class.java)!! val log = LoggerFactory.getLogger(JWT::class.java)!!
lateinit var key: SecretKey lateinit var key: SecretKey
@@ -55,20 +61,53 @@ class JWT(
log.info("JWT Service Enabled") log.info("JWT Service Enabled")
} }
@Transactional
fun gen(user: AquaNetUser): Str {
val activeTokens = sessionRepo.findByAquaNetUserAuId(user.auId)
.sortedByDescending { it.expiry }.drop(9) // the cap is 10, but we append a new token after the fact
if (activeTokens.isNotEmpty()) {
sessionRepo.deleteAll(activeTokens)
}
val token = SessionToken().apply {
aquaNetUser = user
}
sessionRepo.save(token)
fun gen(user: AquaNetUser): Str = Jwts.builder().header() return Jwts.builder().header()
.keyId("aqua-net") .keyId("aqua-net")
.and() .and()
.subject(user.auId.toString()) .subject(token.token)
.issuedAt(Date()) .issuedAt(Date())
.signWith(key) .signWith(key)
.compact() .compact()
}
fun parse(token: Str): AquaNetUser? = try { @Transactional
userRepo.findByAuId(parser.parseSignedClaims(token).payload.subject.toLong()) fun parse(token: Str): AquaNetUser? {
} catch (e: Exception) { try {
log.debug("Failed to parse JWT", e) val uuid = parser.parseSignedClaims(token).payload.subject.toString()
null val token = sessionRepo.findByToken(uuid)
if (token != null) {
val toBeRemoved = sessionRepo.findByAquaNetUserAuId(token.aquaNetUser.auId)
.filter { it.expiry < Instant.now() }
if (toBeRemoved.isNotEmpty())
sessionRepo.deleteAll(toBeRemoved)
if (token.expiry < Instant.now()) {
sessionRepo.delete(token)
return null
}
sessionRepo.save(token.apply{
expiry = getTokenExpiry()
})
}
return token?.aquaNetUser
} catch (e: Exception) {
log.debug("Failed to parse JWT", e)
return null
}
} }
fun auth(token: Str) = parse(token) ?: (400 - "Invalid token") fun auth(token: Str) = parse(token) ?: (400 - "Invalid token")

View File

@@ -0,0 +1,33 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@Entity
@Table(name = "aqua_net_email_reset_password")
class ResetPassword(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0,
@Column(nullable = false)
var token: String = "",
// Token creation time
@Column(nullable = false)
var createdAt: Instant = Instant.now(),
// Linking to the AquaNetUser
@ManyToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@Repository
interface ResetPasswordRepo : JpaRepository<ResetPassword, Long> {
fun findByToken(token: String): ResetPassword?
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
}

View File

@@ -0,0 +1,33 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
import java.util.UUID
fun getTokenExpiry() = Instant.now().plusSeconds(7 * 86400)
@Entity
@Table(name = "aqua_net_session")
class SessionToken(
@Id
@Column(nullable = false)
var token: String = UUID.randomUUID().toString(),
// Token creation time
@Column(nullable = false)
var expiry: Instant = getTokenExpiry(),
// Linking to the AquaNetUser
@ManyToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@Repository
interface SessionTokenRepo : JpaRepository<SessionToken, String> {
fun findByToken(token: String): SessionToken?
fun findByAquaNetUserAuId(auId: Long): List<SessionToken>
}

View File

@@ -43,6 +43,10 @@ class AquaNetUser(
@Column(length = 3) @Column(length = 3)
var country: String = "", var country: String = "",
// Region code at most 2 characters
@Column(length = 2)
var region: String = "",
// Last login time // Last login time
var lastLogin: Long = 0L, var lastLogin: Long = 0L,
@@ -98,6 +102,7 @@ interface AquaNetUserRepo : JpaRepository<AquaNetUser, Long> {
fun findByEmailIgnoreCase(email: String): AquaNetUser? fun findByEmailIgnoreCase(email: String): AquaNetUser?
fun findByUsernameIgnoreCase(username: String): AquaNetUser? fun findByUsernameIgnoreCase(username: String): AquaNetUser?
fun findByKeychip(keychip: String): AquaNetUser? fun findByKeychip(keychip: String): AquaNetUser?
fun findByGhostCardExtId(extId: Long): AquaNetUser?
} }
data class SettingField( data class SettingField(

View File

@@ -0,0 +1,29 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@Entity
@Table(name = "aqua_net_user_fedy")
class AquaNetUserFedy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0,
@Column(nullable = false)
var createdAt: Instant = Instant.now(),
// Linking to the AquaNetUser
@OneToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser,
) : Serializable
@Repository
interface AquaNetUserFedyRepo : JpaRepository<AquaNetUserFedy, Long> {
fun findByAquaNetUserAuId(auId: Long): AquaNetUserFedy?
fun deleteByAquaNetUserAuId(auId: Long): Unit
}

View File

@@ -92,7 +92,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
AVG(p.achievement) / 10000.0 AS acc, AVG(p.achievement) / 10000.0 AS acc,
SUM(p.is_full_combo) AS fc, SUM(p.is_full_combo) AS fc,
SUM(p.is_all_perfect) AS ap, SUM(p.is_all_perfect) AS ap,
c.ranking_banned or a.opt_out_of_leaderboard AS hide, c.ranking_banned or a.opt_out_of_leaderboard or c.status = 12 AS hide,
a.username a.username
FROM ${tableName}_user_playlog_view p FROM ${tableName}_user_playlog_view p
JOIN ${tableName}_user_data_view u ON p.user_id = u.id JOIN ${tableName}_user_data_view u ON p.user_id = u.id

View File

@@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaUserServices import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.Fedy
import icu.samnyan.aqua.net.utils.AquaNetProps import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.net.utils.SUCCESS
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -15,6 +16,11 @@ import java.util.*
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.writeText import kotlin.io.path.writeText
import kotlin.reflect.KClass import kotlin.reflect.KClass
import org.springframework.context.annotation.Lazy
data class ExportOptions(
val playlogAfter: String? = null
)
// Import class with renaming // Import class with renaming
data class ImportClass<T : Any>( data class ImportClass<T : Any>(
@@ -54,6 +60,8 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
val exportFields: Map<String, Var<ExportModel, Any>>, val exportFields: Map<String, Var<ExportModel, Any>>,
val exportRepos: Map<Var<ExportModel, Any>, IUserRepo<UserModel, *>>, val exportRepos: Map<Var<ExportModel, Any>, IUserRepo<UserModel, *>>,
val artemisRenames: Map<String, ImportClass<*>>, val artemisRenames: Map<String, ImportClass<*>>,
val customExporters: Map<Var<ExportModel, Any>, (UserModel, ExportOptions) -> Any?> = emptyMap(),
val customImporters: Map<Var<ExportModel, Any>, (ExportModel, UserModel) -> Unit> = emptyMap()
) { ) {
abstract fun createEmpty(): ExportModel abstract fun createEmpty(): ExportModel
abstract val userDataRepo: GenericUserDataRepo<UserModel> abstract val userDataRepo: GenericUserDataRepo<UserModel>
@@ -62,6 +70,7 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
@Autowired lateinit var netProps: AquaNetProps @Autowired lateinit var netProps: AquaNetProps
@Autowired lateinit var transManager: PlatformTransactionManager @Autowired lateinit var transManager: PlatformTransactionManager
val trans by lazy { TransactionTemplate(transManager) } val trans by lazy { TransactionTemplate(transManager) }
@Autowired @Lazy lateinit var fedy: Fedy
init { init {
artemisRenames.values.forEach { artemisRenames.values.forEach {
@@ -72,13 +81,18 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
val listRepos = exportRepos.filter { it.key returns List::class } val listRepos = exportRepos.filter { it.key returns List::class }
val singleRepos = exportRepos.filter { !(it.key returns List::class) } val singleRepos = exportRepos.filter { !(it.key returns List::class) }
fun export(u: AquaNetUser) = createEmpty().apply { fun export(u: AquaNetUser): ExportModel = export(u, ExportOptions())
fun export(u: AquaNetUser, options: ExportOptions) = createEmpty().apply {
gameId = game gameId = game
userData = userDataRepo.findByCard(u.ghostCard) ?: (404 - "User not found") userData = userDataRepo.findByCard(u.ghostCard) ?: (404 - "User not found")
exportRepos.forEach { (f, u) -> exportRepos.forEach { (f, u) ->
if (f returns List::class) f.set(this, u.findByUser(userData)) if (f returns List::class) f.set(this, u.findByUser(userData))
else u.findSingleByUser(userData)()?.let { f.set(this, it) } else u.findSingleByUser(userData)()?.let { f.set(this, it) }
} }
customExporters.forEach { (f, exporter) ->
exporter(userData, options)?.let { f.set(this, it) }
}
} }
@API("export") @API("export")
@@ -95,6 +109,7 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull() val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull()
val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull() val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull()
var repoFieldMap = exportRepos.toList().associate { (f, r) -> r to f }
// Validate new user data // Validate new user data
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore) // Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
@@ -126,8 +141,14 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
// Save new data // Save new data
singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) } singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) }
lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) } lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) }
// Handle custom importers
customImporters.forEach { (field, importer) ->
importer(export, nu)
}
} }
Fedy.getGameName(game)?.let { fedy.onImported(it, u.ghostCard.extId) }
SUCCESS SUCCESS
} }

View File

@@ -3,11 +3,13 @@ package icu.samnyan.aqua.net.games.mai2
import ext.API import ext.API
import ext.returns import ext.returns
import ext.vars import ext.vars
import icu.samnyan.aqua.net.games.ExportOptions
import icu.samnyan.aqua.net.games.IExportClass import icu.samnyan.aqua.net.games.IExportClass
import icu.samnyan.aqua.net.games.ImportClass import icu.samnyan.aqua.net.games.ImportClass
import icu.samnyan.aqua.net.games.ImportController import icu.samnyan.aqua.net.games.ImportController
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserLinked import icu.samnyan.aqua.sega.maimai2.model.Mai2UserLinked
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserFavoriteItem
import icu.samnyan.aqua.sega.maimai2.model.userdata.* import icu.samnyan.aqua.sega.maimai2.model.userdata.*
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.declaredMembers import kotlin.reflect.full.declaredMembers
@@ -22,11 +24,16 @@ class Mai2Import(
it.name.replace("List", "").lowercase() it.name.replace("List", "").lowercase()
}, },
exportRepos = Maimai2DataExport::class.vars() exportRepos = Maimai2DataExport::class.vars()
.filter { f -> f.name !in setOf("gameId", "userData") } .filter { f -> f.name !in setOf("gameId", "userData", "userPlaylogList", "userFavoriteMusicList") }
.associateWith { Mai2Repos::class.declaredMembers .associateWith { field ->
.filter { f -> f returns Mai2UserLinked::class } val repoName = when (field.name) {
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") } "userKaleidxScopeList" -> "userKaleidx"
?.call(repos) as Mai2UserLinked<*>? ?: error("No matching field found for ${it.name}") else -> field.name.replace("List", "")
}
Mai2Repos::class.declaredMembers
.filter { f -> f returns Mai2UserLinked::class }
.firstOrNull { f -> f.name == repoName }
?.call(repos) as Mai2UserLinked<*>? ?: error("No matching field found for ${field.name}")
}, },
artemisRenames = mapOf( artemisRenames = mapOf(
"mai2_item_character" to ImportClass(Mai2UserCharacter::class), "mai2_item_character" to ImportClass(Mai2UserCharacter::class),
@@ -44,34 +51,71 @@ class Mai2Import(
"mai2_profile_option" to ImportClass(Mai2UserOption::class, mapOf("version" to null)), "mai2_profile_option" to ImportClass(Mai2UserOption::class, mapOf("version" to null)),
"mai2_score_best" to ImportClass(Mai2UserMusicDetail::class), "mai2_score_best" to ImportClass(Mai2UserMusicDetail::class),
"mai2_score_course" to ImportClass(Mai2UserCourse::class), "mai2_score_course" to ImportClass(Mai2UserCourse::class),
) ),
customExporters = mapOf(
Maimai2DataExport::userPlaylogList to { user: Mai2UserDetail, options: ExportOptions ->
if (options.playlogAfter != null) {
repos.userPlaylog.findByUserAndUserPlayDateAfter(user, options.playlogAfter)
} else {
repos.userPlaylog.findByUser(user)
}
},
Maimai2DataExport::userFavoriteMusicList to { user: Mai2UserDetail, _: ExportOptions ->
repos.userGeneralData.findByUserAndPropertyKey(user, "favorite_music").orElse(null)
?.propertyValue
?.takeIf { it.isNotEmpty() }
?.split(",")
?.mapIndexed { index, id -> Mai2UserFavoriteItem().apply { orderId = index; this.id = id.toInt() } }
?: emptyList()
}
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Mai2UserDetail, ExportOptions) -> Any?>,
customImporters = mapOf(
Maimai2DataExport::userPlaylogList to { export: Maimai2DataExport, user: Mai2UserDetail ->
repos.userPlaylog.saveAll(export.userPlaylogList.map { it.apply { it.user = user } })
},
Maimai2DataExport::userFavoriteMusicList to { export: Maimai2DataExport, user: Mai2UserDetail ->
val favoriteMusicList = export.userFavoriteMusicList
if (favoriteMusicList.isNotEmpty()) {
val key = "favorite_music"
// This field always imports as incremental, since the userGeneralData field (for backwards compatibility) is processed before this
val data = repos.userGeneralData.findByUserAndPropertyKey(user, key).orElse(null)
?: Mai2UserGeneralData().apply { this.user = user; propertyKey = key }
repos.userGeneralData.save(data.apply {
propertyValue = favoriteMusicList.sortedBy { it.orderId }.map { it.id }.joinToString(",")
})
}
}
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Maimai2DataExport, Mai2UserDetail) -> Unit>
) { ) {
override fun createEmpty() = Maimai2DataExport() override fun createEmpty() = Maimai2DataExport()
override val userDataRepo = repos.userData override val userDataRepo = repos.userData
} }
data class Maimai2DataExport( data class Maimai2DataExport(
override var userData: Mai2UserDetail, override var userData: Mai2UserDetail = Mai2UserDetail(),
var userExtend: Mai2UserExtend, var userExtend: Mai2UserExtend = Mai2UserExtend(),
var userOption: Mai2UserOption, var userOption: Mai2UserOption = Mai2UserOption(),
var userUdemae: Mai2UserUdemae, var userUdemae: Mai2UserUdemae = Mai2UserUdemae(),
var mapEncountNpcList: List<Mai2MapEncountNpc>, var mapEncountNpcList: List<Mai2MapEncountNpc> = mutableListOf(),
var userActList: List<Mai2UserAct>, var userActList: List<Mai2UserAct> = mutableListOf(),
var userCharacterList: List<Mai2UserCharacter>, var userCharacterList: List<Mai2UserCharacter> = mutableListOf(),
var userChargeList: List<Mai2UserCharge>, var userChargeList: List<Mai2UserCharge> = mutableListOf(),
var userCourseList: List<Mai2UserCourse>, var userCourseList: List<Mai2UserCourse> = mutableListOf(),
var userFavoriteList: List<Mai2UserFavorite>, var userFavoriteList: List<Mai2UserFavorite> = mutableListOf(),
var userFriendSeasonRankingList: List<Mai2UserFriendSeasonRanking>, var userFriendSeasonRankingList: List<Mai2UserFriendSeasonRanking> = mutableListOf(),
var userGeneralDataList: List<Mai2UserGeneralData>, var userGeneralDataList: List<Mai2UserGeneralData> = mutableListOf(),
var userItemList: List<Mai2UserItem>, var userItemList: List<Mai2UserItem> = mutableListOf(),
var userLoginBonusList: List<Mai2UserLoginBonus>, var userLoginBonusList: List<Mai2UserLoginBonus> = mutableListOf(),
var userMapList: List<Mai2UserMap>, var userMapList: List<Mai2UserMap> = mutableListOf(),
var userMusicDetailList: List<Mai2UserMusicDetail>, var userMusicDetailList: List<Mai2UserMusicDetail> = mutableListOf(),
var userPlaylogList: List<Mai2UserPlaylog>, var userIntimateList: List<Mai2UserIntimate> = mutableListOf(),
var userFavoriteMusicList: List<Mai2UserFavoriteItem> = mutableListOf(),
var userKaleidxScopeList: List<Mai2UserKaleidx> = mutableListOf(),
var userPlaylogList: List<Mai2UserPlaylog> = mutableListOf(),
// Not supported yet:
// var userWeeklyData
// var userMissionDataList
// var userShopStockList
// var userTradeItemList
override var gameId: String = "SDEZ", override var gameId: String = "SDEZ",
): IExportClass<Mai2UserDetail> { ): IExportClass<Mai2UserDetail>
constructor() : this(Mai2UserDetail(), Mai2UserExtend(), Mai2UserOption(), Mai2UserUdemae(),
mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(),
mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(),
mutableListOf())
}

View File

@@ -103,6 +103,7 @@ class AllNet(
// encode UTF-8, format_ver 3, hops 1 token 2010451813 // encode UTF-8, format_ver 3, hops 1 token 2010451813
val reqMap = decodeAllNet(dataStream.readAllBytes()) val reqMap = decodeAllNet(dataStream.readAllBytes())
val serial = reqMap["serial"] ?: "" val serial = reqMap["serial"] ?: ""
var region = props.map.mut["region0"] ?: "1"
logger.info("AllNet /PowerOn : $reqMap") logger.info("AllNet /PowerOn : $reqMap")
var session: String? = null var session: String? = null
@@ -114,6 +115,10 @@ class AllNet(
if (u != null) { if (u != null) {
// Create a new session for the user // Create a new session for the user
logger.info("> Keychip authenticated: ${u.auId} ${u.computedName}") logger.info("> Keychip authenticated: ${u.auId} ${u.computedName}")
// If the user defined its own region apply it
if (u.region.isNotBlank()) {
region = u.region
}
session = keychipSessionService.new(u, reqMap["game_id"] ?: "").token session = keychipSessionService.new(u, reqMap["game_id"] ?: "").token
} }
@@ -140,6 +145,7 @@ class AllNet(
val resp = props.map.mut + mapOf( val resp = props.map.mut + mapOf(
"uri" to switchUri(here, localPort, gameId, ver, session), "uri" to switchUri(here, localPort, gameId, ver, session),
"host" to props.host.ifBlank { here }, "host" to props.host.ifBlank { here },
"region0" to region
) )
// Different responses for different versions // Different responses for different versions

View File

@@ -83,7 +83,6 @@ fun ChusanController.chusanInit() {
"GetUserCtoCPlay" { """{"userId":"${data["userId"]}","orderBy":"0","count":"0","userCtoCPlayList":[]}""" } "GetUserCtoCPlay" { """{"userId":"${data["userId"]}","orderBy":"0","count":"0","userCtoCPlayList":[]}""" }
"GetUserRivalMusic" { """{"userId":"${data["userId"]}","rivalId":"0","length":"0","nextIndex":"0","userRivalMusicList":[]}""" } "GetUserRivalMusic" { """{"userId":"${data["userId"]}","rivalId":"0","length":"0","nextIndex":"0","userRivalMusicList":[]}""" }
"GetUserRivalData" { """{"userId":"${data["userId"]}","length":"0","userRivalData":[]}""" } "GetUserRivalData" { """{"userId":"${data["userId"]}","length":"0","userRivalData":[]}""" }
"GetUserRegion" { """{"userId":"${data["userId"]}","length":"0","userRegionList":[]}""" }
"GetUserPrintedCard" { """{"userId":"${data["userId"]}","length":0,"nextIndex":-1,"userPrintedCardList":[]}""" } "GetUserPrintedCard" { """{"userId":"${data["userId"]}","length":0,"nextIndex":-1,"userPrintedCardList":[]}""" }
// Net battle data // Net battle data
@@ -237,7 +236,7 @@ fun ChusanController.chusanInit() {
) + userDict ) + userDict
if (user.card?.status == CardStatus.MIGRATED_TO_MINATO) { if (user.card?.status == CardStatus.MIGRATED_TO_MINATO) {
res["userName"] = "Migrated" res["userName"] = "JiaQQqun / CardMigrated"
res["rating"] = 0 res["rating"] = 0
res["playerLevel"] = 0 res["playerLevel"] = 0
} }
@@ -288,6 +287,12 @@ fun ChusanController.chusanInit() {
) )
} }
"GetUserRegion" {
db.userRegions.findByUser_Card_ExtId(uid)
.map { mapOf("regionId" to it.regionId, "playCount" to it.playCount) }
.let { mapOf("userId" to uid, "userRegionList" to it) }
}
// Game settings // Game settings
"GetGameSetting" { "GetGameSetting" {
val version = data["version"].toString() val version = data["version"].toString()

View File

@@ -29,6 +29,17 @@ fun ChusanController.upsertApiInit() {
userNameEx = "" userNameEx = ""
}.also { db.userData.saveAndFlush(it) } }.also { db.userData.saveAndFlush(it) }
// Only save if it is a valid region and the user has played at least a song
req.userPlaylogList?.firstOrNull()?.regionId?.let { rid ->
val region = db.userRegions.findByUserAndRegionId(u, rid)?.apply {
playCount += 1
} ?: UserRegions().apply {
user = u
regionId = rid
}
db.userRegions.save(region)
}
versionHelper[u.lastClientId] = u.lastDataVersion versionHelper[u.lastClientId] = u.lastDataVersion
// Set users // Set users

View File

@@ -174,6 +174,10 @@ interface Chu3GameLoginBonusRepo : JpaRepository<GameLoginBonus, Int> {
fun findByRequiredDays(version: Int, presetId: Int, requiredDays: Int): Optional<GameLoginBonus> fun findByRequiredDays(version: Int, presetId: Int, requiredDays: Int): Optional<GameLoginBonus>
} }
interface Chu3UserRegionsRepo: Chu3UserLinked<UserRegions> {
fun findByUserAndRegionId(user: Chu3UserData, regionId: Int): UserRegions?
}
@Component @Component
class Chu3Repos( class Chu3Repos(
val userLoginBonus: Chu3UserLoginBonusRepo, val userLoginBonus: Chu3UserLoginBonusRepo,
@@ -191,6 +195,7 @@ class Chu3Repos(
val userMap: Chu3UserMapRepo, val userMap: Chu3UserMapRepo,
val userMusicDetail: Chu3UserMusicDetailRepo, val userMusicDetail: Chu3UserMusicDetailRepo,
val userPlaylog: Chu3UserPlaylogRepo, val userPlaylog: Chu3UserPlaylogRepo,
val userRegions: Chu3UserRegionsRepo,
val userCMission: Chu3UserCMissionRepo, val userCMission: Chu3UserCMissionRepo,
val userCMissionProgress: Chu3UserCMissionProgressRepo, val userCMissionProgress: Chu3UserCMissionProgressRepo,
val netBattleLog: Chu3NetBattleLogRepo, val netBattleLog: Chu3NetBattleLogRepo,

View File

@@ -0,0 +1,14 @@
package icu.samnyan.aqua.sega.chusan.model.userdata
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.LocalDate
@Entity(name = "ChusanUserRegions")
@Table(name = "chusan_user_regions", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "region_id"])])
class UserRegions : Chu3UserEntity() {
var regionId = 0
var playCount = 1
var created: String = LocalDate.now().toString()
}

View File

@@ -33,7 +33,10 @@ data class PagedProcessor(val add: JDict?, val fn: PagedHandler, var post: PageP
// A very :3 way of declaring APIs // 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>() val initH = mutableMapOf<String, SpecialHandler>()
infix operator fun String.invoke(fn: SpecialHandler) = initH.set("${this}Api", fn) infix operator fun String.invoke(fn: SpecialHandler) {
if (initH.containsKey("${this}Api")) error("Duplicate API $this found! Someone is not smart 👀")
initH["${this}Api"] = fn
}
infix fun String.static(fn: () -> Any) = serialize(this, fn()).let { resp -> this { resp } } infix fun String.static(fn: () -> Any) = serialize(this, fn()).let { resp -> this { resp } }
// Page Cache: {cache key: (timestamp, full list)} // Page Cache: {cache key: (timestamp, full list)}

View File

@@ -7,9 +7,12 @@ import icu.samnyan.aqua.sega.general.model.CardStatus
import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusic import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusic
import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusicDetail import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx
import icu.samnyan.aqua.sega.maimai2.model.userdata.UserRegions
import java.time.LocalDate import java.time.LocalDate
fun Maimai2ServletController.initApis() { fun Maimai2ServletController.initApis() {
val log = logger()
"GetUserExtend" { mapOf( "GetUserExtend" { mapOf(
"userId" to uid, "userId" to uid,
"userExtend" to (db.userExtend.findSingleByUser_Card_ExtId(uid)() ?: (404 - "User not found")) "userExtend" to (db.userExtend.findSingleByUser_Card_ExtId(uid)() ?: (404 - "User not found"))
@@ -111,7 +114,7 @@ fun Maimai2ServletController.initApis() {
) )
if (d.card?.status == CardStatus.MIGRATED_TO_MINATO) { if (d.card?.status == CardStatus.MIGRATED_TO_MINATO) {
res["userName"] = "Migrated" res["userName"] = "JiaQQqun / CardMigrated"
res["dispRate"] = 1 res["dispRate"] = 1
res["playerRating"] = 66564 res["playerRating"] = 66564
res["totalAwake"] = 7114 res["totalAwake"] = 7114
@@ -134,6 +137,20 @@ fun Maimai2ServletController.initApis() {
res["returnCode"] = 0 res["returnCode"] = 0
} }
// Get regionId from request
val region = data["regionId"] as? Int
// Only save if it is a valid region and the user has played at least a song
if (region != null && region > 0 && d != null) {
val region = db.userRegions.findByUserAndRegionId(d, region)?.apply {
playCount += 1
} ?: UserRegions().apply {
user = d
regionId = region
}
db.userRegions.save(region)
}
res res
} }
@@ -178,13 +195,19 @@ fun Maimai2ServletController.initApis() {
mapOf("userId" to uid, "rivalId" to rivalId, "nextIndex" to 0, "userRivalMusicList" to res.values) mapOf("userId" to uid, "rivalId" to rivalId, "nextIndex" to 0, "userRivalMusicList" to res.values)
} }
"GetUserRegion" {
logger().info("Getting user regions for user $uid")
db.userRegions.findByUser_Card_ExtId(uid)
.map { mapOf("regionId" to it.regionId, "playCount" to it.playCount) }
.let { mapOf("userId" to uid, "length" to it.size, "userRegionList" to it) }
}
"GetUserIntimate".unpaged { "GetUserIntimate".unpaged {
val u = db.userData.findByCardExtId(uid)() ?: (404 - "User not found") val u = db.userData.findByCardExtId(uid)() ?: (404 - "User not found")
db.userIntimate.findByUser(u) db.userIntimate.findByUser(u)
} }
// Empty List Handlers // Empty List Handlers
"GetUserRegion".unpaged { empty }
"GetUserGhost".unpaged { empty } "GetUserGhost".unpaged { empty }
"GetUserFriendBonus" { mapOf("userId" to uid, "returnCode" to 0, "getMiles" to 0) } "GetUserFriendBonus" { mapOf("userId" to uid, "returnCode" to 0, "getMiles" to 0) }
"GetTransferFriend" { mapOf("userId" to uid, "transferFriendList" to empty) } "GetTransferFriend" { mapOf("userId" to uid, "transferFriendList" to empty) }

View File

@@ -14,6 +14,10 @@ import jakarta.servlet.http.HttpServletRequest
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.full.declaredMemberProperties
import icu.samnyan.aqua.net.Fedy
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy
import org.springframework.beans.factory.ObjectProvider
/** /**
* @author samnyan (privateamusement@protonmail.com) * @author samnyan (privateamusement@protonmail.com)
@@ -37,6 +41,8 @@ class Maimai2ServletController(
val net: Maimai2, val net: Maimai2,
): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) { ): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) {
@Autowired @Lazy lateinit var fedy: Fedy
companion object { companion object {
private val log = logger() private val log = logger()
private val empty = listOf<Any>() private val empty = listOf<Any>()
@@ -89,6 +95,7 @@ class Maimai2ServletController(
val ctx = RequestContext(req, data.mut) val ctx = RequestContext(req, data.mut)
serialize(api, handlers[api]!!(ctx) ?: noop).also { serialize(api, handlers[api]!!(ctx) ?: noop).also {
log.info("$token : $api > ${it.truncate(500)}") log.info("$token : $api > ${it.truncate(500)}")
if (api == "UpsertUserAllApi") { fedy.onUpserted("mai2", data["userId"]) }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -57,8 +57,6 @@ interface Mai2UserExtendRepo : Mai2UserLinked<Mai2UserExtend>
interface Mai2UserFavoriteRepo : Mai2UserLinked<Mai2UserFavorite> { interface Mai2UserFavoriteRepo : Mai2UserLinked<Mai2UserFavorite> {
fun findByUserAndItemKind(user: Mai2UserDetail, kind: Int): Optional<Mai2UserFavorite> fun findByUserAndItemKind(user: Mai2UserDetail, kind: Int): Optional<Mai2UserFavorite>
fun findByUserIdAndItemKind(userId: Long, kind: Int): List<Mai2UserFavorite>
fun findByUser_Card_ExtIdAndItemKind(userId: Long, kind: Int): Optional<Mai2UserFavorite> fun findByUser_Card_ExtIdAndItemKind(userId: Long, kind: Int): Optional<Mai2UserFavorite>
} }
@@ -104,6 +102,7 @@ interface Mai2UserPlaylogRepo : GenericPlaylogRepo<Mai2UserPlaylog>, Mai2UserLin
musicId: Int, musicId: Int,
userPlayDate: String userPlayDate: String
): MutableList<Mai2UserPlaylog> ): MutableList<Mai2UserPlaylog>
fun findByUserAndUserPlayDateAfter(user: Mai2UserDetail, userPlayDate: String): List<Mai2UserPlaylog>
} }
interface Mai2UserPrintDetailRepo : JpaRepository<Mai2UserPrintDetail, Long> interface Mai2UserPrintDetailRepo : JpaRepository<Mai2UserPrintDetail, Long>
@@ -126,6 +125,10 @@ interface Mai2GameEventRepo : JpaRepository<Mai2GameEvent, Int> {
interface Mai2GameSellingCardRepo : JpaRepository<Mai2GameSellingCard, Long> interface Mai2GameSellingCardRepo : JpaRepository<Mai2GameSellingCard, Long>
interface Mai2UserRegionsRepo: Mai2UserLinked<UserRegions> {
fun findByUserAndRegionId(user: Mai2UserDetail, regionId: Int): UserRegions?
}
@Component @Component
class Mai2Repos( class Mai2Repos(
val mapEncountNpc: Mai2MapEncountNpcRepo, val mapEncountNpc: Mai2MapEncountNpcRepo,
@@ -151,5 +154,6 @@ class Mai2Repos(
val userIntimate: MAi2UserIntimateRepo, val userIntimate: MAi2UserIntimateRepo,
val gameCharge: Mai2GameChargeRepo, val gameCharge: Mai2GameChargeRepo,
val gameEvent: Mai2GameEventRepo, val gameEvent: Mai2GameEventRepo,
val gameSellingCard: Mai2GameSellingCardRepo val gameSellingCard: Mai2GameSellingCardRepo,
val userRegions: Mai2UserRegionsRepo,
) )

View File

@@ -16,6 +16,12 @@ import lombok.AllArgsConstructor
import lombok.Data import lombok.Data
import lombok.NoArgsConstructor import lombok.NoArgsConstructor
import java.time.LocalDateTime import java.time.LocalDateTime
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import java.time.format.DateTimeFormatter
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.core.JsonGenerator
import java.time.LocalDate
@MappedSuperclass @MappedSuperclass
open class Mai2UserEntity : BaseEntity(), IUserEntity<Mai2UserDetail> { open class Mai2UserEntity : BaseEntity(), IUserEntity<Mai2UserDetail> {
@@ -526,10 +532,14 @@ class Mai2UserKaleidx : Mai2UserEntity() {
var totalDeluxscore = 0 var totalDeluxscore = 0
var bestAchievement = 0 var bestAchievement = 0
var bestDeluxscore = 0 var bestDeluxscore = 0
@JsonSerialize(using = MaimaiDateSerializer::class)
var bestAchievementDate: LocalDateTime? = null var bestAchievementDate: LocalDateTime? = null
@JsonSerialize(using = MaimaiDateSerializer::class)
var bestDeluxscoreDate: LocalDateTime? = null var bestDeluxscoreDate: LocalDateTime? = null
var playCount = 0 var playCount = 0
@JsonSerialize(using = MaimaiDateSerializer::class)
var clearDate: LocalDateTime? = null var clearDate: LocalDateTime? = null
@JsonSerialize(using = MaimaiDateSerializer::class)
var lastPlayDate: LocalDateTime? = null var lastPlayDate: LocalDateTime? = null
var isInfoWatched = false var isInfoWatched = false
} }
@@ -541,3 +551,21 @@ class Mai2UserIntimate : Mai2UserEntity() {
var intimateLevel = 0; var intimateLevel = 0;
var intimateCountRewarded = 0; var intimateCountRewarded = 0;
} }
@Entity(name = "Maimai2UserRegions")
@Table(
name = "maimai2_user_regions",
uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "region_id"])]
)
class UserRegions : Mai2UserEntity() {
var regionId = 0
var playCount = 1
var created: String = LocalDate.now().toString()
}
val MAIMAI_DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0")
class MaimaiDateSerializer : JsonSerializer<LocalDateTime>() {
override fun serialize(v: LocalDateTime, j: JsonGenerator, s: SerializerProvider) {
j.writeString(v.format(MAIMAI_DATETIME))
}
}

View File

@@ -147,6 +147,10 @@ interface OgkUserTrainingRoomRepo : OngekiUserLinked<UserTrainingRoom> {
fun findByUserAndRoomId(user: UserData, roomId: Int): Optional<UserTrainingRoom> fun findByUserAndRoomId(user: UserData, roomId: Int): Optional<UserTrainingRoom>
} }
interface OgkUserRegionsRepo: OngekiUserLinked<UserRegions> {
fun findByUserAndRegionId(user: UserData, regionId: Int): UserRegions?
}
// Re:Fresh // Re:Fresh
interface OgkUserEventMapRepo : OngekiUserLinked<UserEventMap> interface OgkUserEventMapRepo : OngekiUserLinked<UserEventMap>
interface OgkUserSkinRepo : OngekiUserLinked<UserSkin> interface OgkUserSkinRepo : OngekiUserLinked<UserSkin>
@@ -190,6 +194,7 @@ class OngekiUserRepos(
val trainingRoom: OgkUserTrainingRoomRepo, val trainingRoom: OgkUserTrainingRoomRepo,
val eventMap: OgkUserEventMapRepo, val eventMap: OgkUserEventMapRepo,
val skin: OgkUserSkinRepo, val skin: OgkUserSkinRepo,
val regions: OgkUserRegionsRepo,
) )
@Component @Component

View File

@@ -1,11 +1,13 @@
package icu.samnyan.aqua.sega.ongeki package icu.samnyan.aqua.sega.ongeki
import ext.int
import ext.invoke import ext.invoke
import ext.mapApply import ext.mapApply
import ext.minus import ext.minus
import icu.samnyan.aqua.sega.ongeki.model.OngekiUpsertUserAll import icu.samnyan.aqua.sega.ongeki.model.OngekiUpsertUserAll
import icu.samnyan.aqua.sega.ongeki.model.UserData import icu.samnyan.aqua.sega.ongeki.model.UserData
import icu.samnyan.aqua.sega.ongeki.model.UserGeneralData import icu.samnyan.aqua.sega.ongeki.model.UserGeneralData
import icu.samnyan.aqua.sega.ongeki.model.UserRegions
fun OngekiController.initUpsertAll() { fun OngekiController.initUpsertAll() {
@@ -33,6 +35,20 @@ fun OngekiController.initUpsertAll() {
db.data.save(this) db.data.save(this)
} ?: oldUser ?: return@api null } ?: oldUser ?: return@api null
// User region
val region = data["regionId"]?.int ?: 0
// Only save if it is a valid region and the user has played at least a song
if (region > 0 && all.userPlaylogList?.isNotEmpty() == true) {
val region = db.regions.findByUserAndRegionId(u, region)?.apply {
playCount += 1
} ?:UserRegions().apply {
user = u
regionId = region
}
db.regions.save(region)
}
all.run { all.run {
// Set users // Set users
listOfNotNull( listOfNotNull(

View File

@@ -41,7 +41,10 @@ fun OngekiController.initUser() {
"GetUserBpBase".unpaged { empty } "GetUserBpBase".unpaged { empty }
"GetUserRatinglog".unpaged { empty } "GetUserRatinglog".unpaged { empty }
"GetUserRegion".unpaged { empty } "GetUserRegion".unpaged {
db.regions.findByUser_Card_ExtId(uid)
.map { mapOf("regionId" to it.regionId, "playCount" to it.playCount) }
}
"GetUserTradeItem".unpaged { "GetUserTradeItem".unpaged {
val start = parsing { data["startChapterId"]!!.int } val start = parsing { data["startChapterId"]!!.int }
@@ -112,7 +115,29 @@ fun OngekiController.initUser() {
} }
"GetUserPreview" api@ { "GetUserPreview" api@ {
val u = db.data.findByCard_ExtId(uid)() ?: return@api mapOf("userId" to uid, "lastPlayDate" to null) val u = db.data.findByCard_ExtId(uid)() ?: return@api mapOf(
"userId" to uid,
"isLogin" to false,
"lastLoginDate" to "0000-00-00 00:00:00",
"userName" to "",
"reincarnationNum" to 0,
"level" to 0,
"exp" to 0,
"playerRating" to 0,
"lastGameId" to "",
"lastRomVersion" to "",
"lastDataVersion" to "",
"lastPlayDate" to "",
"nameplateId" to 0,
"trophyId" to 0,
"cardId" to 0,
"dispPlayerLv" to 0,
"dispRating" to 0,
"dispBP" to 0,
"headphone" to 0,
"banStatus" to 0,
"isWarningConfirmed" to true
)
val o = db.option.findSingleByUser(u)() val o = db.option.findSingleByUser(u)()
val res = mutableMapOf( val res = mutableMapOf(
@@ -137,7 +162,7 @@ fun OngekiController.initUser() {
) )
if (u.card?.status == CardStatus.MIGRATED_TO_MINATO) { if (u.card?.status == CardStatus.MIGRATED_TO_MINATO) {
res["userName"] = "Migrated" res["userName"] = "JiaQQqun / CardMigrated"
res["level"] = 0 res["level"] = 0
res["exp"] = 0 res["exp"] = 0
res["playerRating"] = 0 res["playerRating"] = 0

View File

@@ -7,6 +7,7 @@ import icu.samnyan.aqua.net.games.*
import icu.samnyan.aqua.sega.general.model.Card import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.util.jackson.AccessCodeSerializer import icu.samnyan.aqua.sega.util.jackson.AccessCodeSerializer
import jakarta.persistence.* import jakarta.persistence.*
import java.time.LocalDate
@MappedSuperclass @MappedSuperclass
class OngekiUserEntity : BaseEntity(), IUserEntity<UserData> { class OngekiUserEntity : BaseEntity(), IUserEntity<UserData> {
@@ -512,3 +513,14 @@ class UserSkin : OngekiUserEntity() {
var cardId2 = 0 var cardId2 = 0
var cardId3 = 0 var cardId3 = 0
} }
@Entity(name = "OngekiUserRegions")
@Table(
name = "ongeki_user_regions",
uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "regionId"])]
)
class UserRegions : OngekiUserEntity() {
var regionId = 0
var playCount = 1
var created: String = LocalDate.now().toString()
}

View File

@@ -225,7 +225,7 @@ fun WaccaServer.init() {
val status = u.lStatus().toMutableList() val status = u.lStatus().toMutableList()
if (u.card?.status == CardStatus.MIGRATED_TO_MINATO) { if (u.card?.status == CardStatus.MIGRATED_TO_MINATO) {
status[1] = "Migrated" status[1] = "JiaQQqun / CardMigrated"
} }
u.run { ls( u.run { ls(

View File

@@ -0,0 +1,9 @@
CREATE TABLE aqua_net_user_fedy
(
id BIGINT AUTO_INCREMENT NOT NULL,
created_at datetime NOT NULL,
au_id BIGINT NOT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_fedy_on_aqua_net_user FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT unq_fedy_on_aqua_net_user UNIQUE (au_id)
);

View File

@@ -0,0 +1,21 @@
INSERT INTO `maimai2_game_selling_card` (`card_id`,`start_date`, `end_date`, `notice_start_date`, `notice_end_date`) VALUES
(5504014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5504016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5503014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5503016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5502014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5502016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5501014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5501016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5500014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5500016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5003014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5003016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5002014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5002016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5001014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5001016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5000014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5000016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5505014, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000'),
(5505016, '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000', '2019-01-01 00:00:00.000000', '2029-01-01 00:00:00.000000');

View File

@@ -0,0 +1,27 @@
INSERT INTO chusan_game_event (id, type, end_date, start_date, enable)
VALUES
(16600,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16601,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16602,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16603,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16604,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16605,8,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16606,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16607,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16608,16,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16609,5,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16610,4,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16611,11,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16612,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16650,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16651,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16652,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16653,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16654,8,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16655,11,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16700,3,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16701,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16702,1,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16703,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16704,2,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16705,5,'2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true);

View File

@@ -0,0 +1,16 @@
INSERT INTO chusan_game_event (id, type, end_date, start_date, enable)
VALUES
(16550, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16551, 3, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16552, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16553, 2, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16554, 8, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16555, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16556, 2, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16557, 8, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16558, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16559, 2, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16560, 8, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16561, 1, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16562, 7, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true),
(16563, 10, '2029-01-01 00:00:00.000000','2019-01-01 00:00:00.000000',true);

View File

@@ -0,0 +1,22 @@
CREATE TABLE aqua_net_session
(
token VARCHAR(36) NOT NULL,
expiry datetime NOT NULL,
au_id BIGINT NULL,
CONSTRAINT pk_session PRIMARY KEY (token)
);
ALTER TABLE aqua_net_session
ADD CONSTRAINT FK_SESSION FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id);
CREATE TABLE aqua_net_email_reset_password
(
id BIGINT AUTO_INCREMENT NOT NULL,
token VARCHAR(255) NOT NULL,
created_at datetime NOT NULL,
au_id BIGINT NULL,
CONSTRAINT pk_email_reset_password PRIMARY KEY (id)
);
ALTER TABLE aqua_net_email_reset_password
ADD CONSTRAINT FK_EMAIL_RESET_PASSWORD_ON_AQUA_USER FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id);

View File

@@ -0,0 +1,38 @@
CREATE TABLE chusan_user_regions
(
id BIGINT AUTO_INCREMENT NOT NULL,
user_id BIGINT NULL,
region_id INT NOT NULL,
play_count INT NOT NULL DEFAULT 1,
created VARCHAR(355),
PRIMARY KEY (id),
CONSTRAINT fk_chusanregions_on_chusan_user_Data FOREIGN KEY (user_id) REFERENCES chusan_user_data (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT unq_chusanregions_on_region_user UNIQUE (user_id, region_id)
);
CREATE TABLE ongeki_user_regions
(
id BIGINT AUTO_INCREMENT NOT NULL,
user_id BIGINT NULL,
region_id INT NOT NULL,
play_count INT NOT NULL DEFAULT 1,
created VARCHAR(355),
PRIMARY KEY (id),
CONSTRAINT fk_ongekiregions_on_aqua_net_user FOREIGN KEY (user_id) REFERENCES aqua_net_user (au_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT unq_ongekiregions_on_region_user UNIQUE (user_id, region_id)
);
CREATE TABLE maimai2_user_regions
(
id BIGINT AUTO_INCREMENT NOT NULL,
user_id BIGINT NULL,
region_id INT NOT NULL,
play_count INT NOT NULL DEFAULT 1,
created VARCHAR(355),
PRIMARY KEY (id),
CONSTRAINT fk_maimai2regions_on_user_Details FOREIGN KEY (user_id) REFERENCES maimai2_user_detail (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT unq_maimai2regions_on_region_user UNIQUE (user_id, region_id)
);
ALTER TABLE aqua_net_user
ADD COLUMN region VARCHAR(2) NOT NULL DEFAULT '1';

View File

@@ -212,7 +212,7 @@
<div style="color:#101112;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:19.2px;"> <div style="color:#101112;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:19.2px;">
<p style="margin: 0; margin-bottom: 16px;">Dear {{name}},</p> <p style="margin: 0; margin-bottom: 16px;">Dear {{name}},</p>
<p style="margin: 0; margin-bottom: 16px;">Thank you for registering with AquaDX! We're excited to have you on board. To complete your registration and verify your email address, please click the link below.</p> <p style="margin: 0; margin-bottom: 16px;">Thank you for registering with AquaDX! We're excited to have you on board. To complete your registration and verify your email address, please click the link below.</p>
<p style="margin: 0;">This link will confirm your email address, and it is valid for 24 hours. If you did not initiate this request, please ignore this email.</p> <p style="margin: 0;">This link will verify your email address, and it is valid for 24 hours. If you did not initiate this request, please ignore this email.</p>
</div> </div>
</td> </td>
</tr> </tr>
@@ -225,7 +225,7 @@
<w:anchorlock/> <w:anchorlock/>
<v:textbox inset="0px,0px,0px,0px"> <v:textbox inset="0px,0px,0px,0px">
<center style="color:#ffffff; font-family:'Trebuchet MS', Tahoma, sans-serif; font-size:16px"> <center style="color:#ffffff; font-family:'Trebuchet MS', Tahoma, sans-serif; font-size:16px">
<![endif]--><a href="{{url}}" style="text-decoration:none;display:inline-block;color:#ffffff;background-color:#646cff;border-radius:8px;width:auto;border-top:0px solid transparent;font-weight:400;border-right:0px solid transparent;border-bottom:0px solid transparent;border-left:0px solid transparent;padding-top:8px;padding-bottom:8px;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;text-align:center;mso-border-alt:none;word-break:keep-all;" target="_blank"><span style="padding-left:16px;padding-right:16px;font-size:16px;display:inline-block;letter-spacing:normal;"><span style="word-break: break-word; line-height: 32px;">Confirm email</span></span></a><!--[if mso]></center></v:textbox></v:roundrect><![endif]--></div> <![endif]--><a href="{{url}}" style="text-decoration:none;display:inline-block;color:#ffffff;background-color:#646cff;border-radius:8px;width:auto;border-top:0px solid transparent;font-weight:400;border-right:0px solid transparent;border-bottom:0px solid transparent;border-left:0px solid transparent;padding-top:8px;padding-bottom:8px;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;text-align:center;mso-border-alt:none;word-break:keep-all;" target="_blank"><span style="padding-left:16px;padding-right:16px;font-size:16px;display:inline-block;letter-spacing:normal;"><span style="word-break: break-word; line-height: 32px;">Verify email</span></span></a><!--[if mso]></center></v:textbox></v:roundrect><![endif]--></div>
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<title></title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/><!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]--><!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100;200;300;400;500;600;700;800;900" rel="stylesheet" type="text/css"/><!--<![endif]-->
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
}
p {
line-height: inherit
}
.desktop_hide,
.desktop_hide table {
mso-hide: all;
display: none;
max-height: 0px;
overflow: hidden;
}
.image_block img+div {
display: none;
}
@media (max-width:700px) {
.desktop_hide table.icons-inner,
.row-3 .column-1 .block-3.button_block .alignment a,
.row-3 .column-1 .block-3.button_block .alignment div {
display: inline-block !important;
}
.icons-inner {
text-align: center;
}
.icons-inner td {
margin: 0 auto;
}
.image_block div.fullWidth {
max-width: 100% !important;
}
.mobile_hide {
display: none;
}
.row-content {
width: 100% !important;
}
.stack .column {
width: 100%;
display: block;
}
.mobile_hide {
min-height: 0;
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0px;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important;
}
.row-1 .column-1 .block-1.icons_block .alignment,
.row-3 .column-1 .block-3.button_block .alignment {
text-align: center !important;
}
.row-3 .column-1 .block-2.paragraph_block td.pad>div {
text-align: left !important;
font-size: 14px !important;
}
.row-3 .column-1 .block-1.heading_block h1 {
text-align: center !important;
font-size: 24px !important;
}
.row-3 .column-1 .block-1.heading_block td.pad {
padding: 15px 0 !important;
}
.row-3 .column-1 .block-4.paragraph_block td.pad>div {
text-align: justify !important;
font-size: 10px !important;
}
.row-3 .column-1 .block-3.button_block a,
.row-3 .column-1 .block-3.button_block div,
.row-3 .column-1 .block-3.button_block span {
font-size: 14px !important;
line-height: 28px !important;
}
.row-3 .column-1 {
padding: 0 24px 48px !important;
}
}
</style>
</head>
<body style="background-color: #f8f6ff; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f6ff; background-image: none; background-position: top left; background-size: auto; background-repeat: no-repeat;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 32px; padding-left: 48px; padding-right: 48px; padding-top: 32px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="vertical-align: middle; color: white; font-family: inherit; font-size: 24px; font-weight: 400; letter-spacing: 6px; text-align: left;">
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="alignment" style="vertical-align: middle; text-align: left;"><!--[if vml]><table align="left" cellpadding="0" cellspacing="0" role="presentation" style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; display: inline-block; margin-right: -4px; padding-left: 0px; padding-right: 0px;"><!--<![endif]-->
<tr>
<td style="vertical-align: middle; text-align: center; padding-top: 0px; padding-bottom: 0px; padding-left: 0px; padding-right: 15px;"><img align="center" class="icon" height="32" src="https://aquadx.net/assets/icons/android-chrome-192x192.png" style="display: block; height: auto; margin: 0 auto; border: 0;" width="32"/></td>
<td style="font-family: 'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; font-size: 24px; font-weight: 400; color: white; vertical-align: middle; letter-spacing: 6px; text-align: left;">AquaDX</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; border-radius: 0; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="width:100%;padding-right:0px;padding-left:0px;">
<div align="center" class="alignment" style="line-height:10px">
<div class="fullWidth" style="max-width: 646px;"><img alt="" src="https://aquadx.net/assets/email/border.png" style="display: block; height: auto; border: 0; width: 100%;" title="An open email illustration" width="646"/></div>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-3" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: white; border-radius: 0; color: #000000; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 48px; padding-left: 48px; padding-right: 48px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="heading_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="padding-bottom:12px;text-align:center;width:100%;">
<h1 style="margin: 0; color: #292929; direction: ltr; font-family: 'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; font-size: 32px; font-weight: 700; letter-spacing: normal; line-height: 120%; text-align: left; margin-top: 0; margin-bottom: 0; mso-line-height-alt: 38.4px;"><span class="tinyMce-placeholder">Reset your password!</span></h1>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="paragraph_block block-2" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;" width="100%">
<tr>
<td class="pad" style="padding-bottom:10px;padding-top:10px;">
<div style="color:#101112;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:19.2px;">
<p style="margin: 0; margin-bottom: 16px;">Dear {{name}},</p>
<p style="margin: 0; margin-bottom: 16px;">You recently requested to reset your AquaDX password. To reset your password, please click the link below.</p>
<p style="margin: 0;">This link will allow you to reset your password, and it is valid for 24 hours. If you did not initiate this request, please ignore this email.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="button_block block-3" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="padding-bottom:15px;padding-top:15px;text-align:left;">
<div align="left" class="alignment"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{url}}" style="height:48px;width:168px;v-text-anchor:middle;" arcsize="17%" stroke="false" fillcolor="#646cff">
<w:anchorlock/>
<v:textbox inset="0px,0px,0px,0px">
<center style="color:#ffffff; font-family:'Trebuchet MS', Tahoma, sans-serif; font-size:16px">
<![endif]--><a href="{{url}}" style="text-decoration:none;display:inline-block;color:#ffffff;background-color:#646cff;border-radius:8px;width:auto;border-top:0px solid transparent;font-weight:400;border-right:0px solid transparent;border-bottom:0px solid transparent;border-left:0px solid transparent;padding-top:8px;padding-bottom:8px;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;text-align:center;mso-border-alt:none;word-break:keep-all;" target="_blank"><span style="padding-left:16px;padding-right:16px;font-size:16px;display:inline-block;letter-spacing:normal;"><span style="word-break: break-word; line-height: 32px;">Reset password</span></span></a><!--[if mso]></center></v:textbox></v:roundrect><![endif]--></div>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="paragraph_block block-4" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;" width="100%">
<tr>
<td class="pad" style="padding-top:16px;">
<div style="color:#666666;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:12px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:14.399999999999999px;">
<p style="margin: 0; margin-bottom: 12px;">If you're having trouble clicking the link, you can also copy and paste the URL below into your web browser:</p>
<p style="margin: 0;">{{url}}</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-4" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 32px; padding-left: 48px; padding-right: 48px; padding-top: 32px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<div class="spacer_block block-1" style="height:60px;line-height:60px;font-size:1px;"></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</body>
</html>

View File

@@ -22,7 +22,7 @@
<file>${LOG_FILE}</file> <file>${LOG_FILE}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.gz</fileNamePattern> <fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.gz</fileNamePattern>
<maxHistory>7</maxHistory> <maxHistory>90</maxHistory>
</rollingPolicy> </rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/> <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender> </appender>