Compare commits

30 Commits

Author SHA1 Message Date
Ethan O'Brien
86e1e6050a Bump version to 1.1.0 2026-02-19 14:32:11 -06:00
Ethan O'Brien
956634d8ab Add ability to host webui card image files 2026-02-19 14:04:31 -06:00
Ethan O'Brien
8a1d33bd2b Downgrade jni for now 2026-02-19 12:20:08 -06:00
Ethan O'Brien
aef2c4986d Upgrade usage of JNI 2026-02-19 11:09:55 -06:00
Ethan O'Brien
b35aa35b1a Upgrade dependencies 2026-02-19 10:33:15 -06:00
Ethan O'Brien
0ef0a83685 Remove nodejs from build 2026-02-19 10:18:30 -06:00
Ethan O'Brien
586f4612ed Update readme 2026-02-19 10:18:19 -06:00
Ethan O'Brien
f0183370be Add error message when webui submodules are not pulled 2026-02-19 09:59:16 -06:00
Ethan O'Brien
f4fe727a0e New webui 2026-02-19 09:51:51 -06:00
Ethan O'Brien
d61b27af8f Add coins/primogems and item limits 2026-02-10 14:51:51 -06:00
Ethan O'Brien
8e4300a7ea Fix cheat endpoint and jp client 2026-02-10 14:35:19 -06:00
Ethan O'Brien
8a666a56b5 Add endpoint to cheat 2026-02-06 14:39:49 -06:00
Ethan O'Brien
f4fcbf30ed Add ability to get item list 2026-02-06 14:03:05 -06:00
Ethan O'Brien
db46013ee0 Remove duplicated logic 2026-02-06 13:50:48 -06:00
Ethan O'Brien
0b532ee191 Change behaviour for item limits 2026-02-06 13:32:59 -06:00
Ethan O'Brien
95c253b65a Add ability to pull login bonus list 2026-02-04 11:52:58 -06:00
Ethan O'Brien
c4ba03b289 Add ability to pull all cards from card webui endpoint 2026-02-02 22:27:31 -06:00
Ethan O'Brien
2effc61292 Endpoint fixes 2026-02-02 15:49:24 -06:00
Ethan O'Brien
54e84fbaac Fixes 2026-02-01 22:26:54 -06:00
Ethan O'Brien
41b3ea2c7f Add more stuff 2026-02-01 21:27:22 -06:00
Ethan O'Brien
4836f332f0 Put missing gl keys into exchange json 2026-01-31 18:41:39 -06:00
Ethan O'Brien
da49d06209 Add new webui endpoint 2026-01-30 22:20:23 -06:00
Ethan O'Brien
6688655370 Add super cool Live Clear Rates webui 2025-12-24 21:10:37 -06:00
Ethan O'Brien
955e3819b5 Add music masterdata 2025-12-24 21:09:34 -06:00
Ethan O'Brien
591990a040 Track rage-quits as failed lives 2025-12-24 19:46:30 -06:00
Ethan O'Brien
0fc7f1d36e Redo migration table 2025-12-24 17:42:41 -06:00
Ethan O'Brien
15155bd8df Move migration related userdata functions to a new file 2025-12-24 14:34:13 -06:00
Ethan O'Brien
816589ae22 Fix deck size for new accounts 2025-12-24 14:18:31 -06:00
Ethan O'Brien
d14533d966 Tell workflow to checkout submodules 2025-12-24 13:21:49 -06:00
Ethan O'Brien
697f4188c2 Ship files for offline android/ios servers 2025-12-24 13:13:46 -06:00
53 changed files with 42668 additions and 5889 deletions

View File

@@ -11,16 +11,13 @@ jobs:
steps:
- name: Checking out branch
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust
shell: bash
run: curl -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable --profile minimal
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install android NDK
id: setup-ndk
uses: https://github.com/nttld/setup-ndk@v1
@@ -31,12 +28,6 @@ jobs:
run: |
mkdir out
- name: Build webui
run: |
cd webui
npm install
npm run build
- name: Install cargo ndk
shell: bash
run: |

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "assets"]
path = assets
url = https://git.ethanthesleepy.one/ethanaobrien/sif2-runtime-files
[submodule "webui"]
path = webui
url = https://git.ethanthesleepy.one/ethanaobrien/ew-webui

712
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
[package]
name = "ew"
version = "1.0.0"
version = "1.1.0"
edition = "2024"
[dependencies]
actix-web = "4.12"
rusqlite = { version = "0.37.0", features = ["bundled"] }
clap = { version = "4.5.37", features = ["derive"]}
actix-web = "4.13"
rusqlite = { version = "0.38.0", features = ["bundled"] }
clap = { version = "4.5.59", features = ["derive"]}
base64 = "0.22.1"
json = "0.12.4"
rand = "0.9.2"
rand = "0.10.0"
lazy_static = "1.5.0"
hex = "0.4.3"
hmac = "0.12.1"
@@ -17,8 +17,8 @@ md5 = "0.8.0"
urlencoding = "2.1.3"
sha1 = "0.10.6"
substring = "1.4.5"
uuid = { version = "1.18.1", features = ["v7"] }
rsa = { version = "0.9.9", features = ["sha1"] }
uuid = { version = "1.21.0", features = ["v7"] }
rsa = { version = "0.9.10", features = ["sha1"] }
mime = "0.3.17"
sha2 = "0.10.9"
include-flate-codegen = "0.3.1"
@@ -26,21 +26,23 @@ libflate = "2.2.1"
cbc = { version = "0.1.2", features = ["alloc"] }
aes = "0.8.4"
pem = "3.0.6"
ureq = "3.1.4"
ureq = "3.2.0"
mime_guess = "2.0.5"
include_dir = "0.7.4"
[target.aarch64-linux-android.dependencies]
jni = { version = "0.21.1", features = ["invocation", "default"] }
jni = { version = "0.21.0", features = ["invocation", "default"], optional = true }
[target.aarch64-apple-ios.dependencies]
objc2 = "0.6.3"
objc2-foundation = { version = "0.3.2", features = ["NSFileManager"] }
objc2 = { version = "0.6.3", optional = true }
objc2-foundation = { version = "0.3.2", features = ["NSFileManager"], optional = true }
[build-dependencies]
cc = "1.0"
cc = "1.2"
# To enable this library you MUST comment out lib block below and add --features library
[features]
library = []
library = ["jni", "objc2", "objc2-foundation"]
#[lib]
#crate-type = ["cdylib"]

1
assets Submodule

Submodule assets added at dbb068e72c

View File

@@ -9,4 +9,8 @@ fn main() {
.compile("libc_code.a");
println!("cargo:rerun-if-changed=src/log.c");
}
if !std::fs::exists("webui/index.html").unwrap_or(false) {
panic!("Could not compile crate! Missing webui! Did you pull submodules?");
}
}

View File

@@ -3,18 +3,12 @@ FROM docker.io/library/debian:latest AS builder
# First - build
RUN apt update && apt install -y curl libssl-dev perl git gcc make
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash
RUN apt update && apt install -y nodejs
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable --profile minimal
WORKDIR /ew/
COPY ./ ./
WORKDIR /ew/webui/
RUN npm i && npm run build
WORKDIR /ew/
RUN . "$HOME/.cargo/env" && cargo build --release

View File

@@ -1,4 +1,3 @@
version: '3'
services:
sif2-ew:
image: sif2-ew:latest
@@ -14,6 +13,7 @@ services:
HIDDEN: false # Will disable the webui
DISABLE_IMPORTS: false # Will disable account imports
DISABLE_EXPORTS: false # Will disable account exports
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
# Everything below is for the "Help" page
#ANDROID_GLOBAL: "link.to/patched/android/global.apk"
@@ -25,4 +25,7 @@ services:
- 8080:8080
volumes:
- ./data:/data
# You can download from https://git.ethanthesleepy.one/ethanaobrien/ew-webui/releases/download/images/card-thumbnails.zip
# No, you should not have a nested folder EXAMPLE: `images/FILENAME.webp`
#- ./images:/images
restart: unless-stopped

View File

@@ -25,4 +25,6 @@ asset_android_en=$([ "$EN_ANDROID_ASSET_HASH" != "" ] && echo "--en-android-asse
asset_ios_en=$([ "$EN_IOS_ASSET_HASH" != "" ] && echo "--en-ios-asset-hash $EN_IOS_ASSET_HASH" || echo "")
/root/ew/ew --path $directory --port $port --npps4 $npps4 $asset_android_jp $asset_ios_jp $asset_android_en $asset_ios_en $exports $imports $purge $hidden $https --global-android "$ANDROID_GLOBAL" --japan-android "$ANDROID_JAPAN" --global-ios "$IOS_GLOBAL" --japan-ios "$IOS_JAPAN" --assets-url "$ASSET_URL" --max-time $maxTime
image_asset_path=$([ "$IMAGE_ASSET_PATH" != "" ] && echo "--image-asset-path $IMAGE_ASSET_PATH" || echo "")
/root/ew/ew --path $directory --port $port --npps4 $npps4 $asset_android_jp $asset_ios_jp $asset_android_en $asset_ios_en $exports $imports $purge $hidden $https $image_asset_path --global-android "$ANDROID_GLOBAL" --japan-android "$ANDROID_JAPAN" --global-ios "$IOS_GLOBAL" --japan-ios "$IOS_JAPAN" --assets-url "$ASSET_URL" --max-time $maxTime

View File

@@ -5,27 +5,15 @@ A (mostly functioning) server for Love Live! School idol festival 2 MIRACLE LIVE
## Building
### Linux
#### Requirements
- [perl](https://www.perl.org/get.html) (This is normally pre-installed)
- [rust](https://www.rust-lang.org/tools/install)
- [npm](https://www.npmjs.com/)
- The [libssl-dev](https://packages.debian.org/buster/libssl-dev) package. This will vary across distros.
`apt install -y npm libssl-dev perl`
### Windows
#### Requirements
- [Strawberry Perl](https://strawberryperl.com/)
### Requirements
- [rust](https://www.rust-lang.org/tools/install)
### Packaging/Running
**Build npm:**
`cd webui && npm install && npm run build`
**Build Rust:**
**Build:**
Debug: `cargo run`
Release: `cargo build --release`
**Docker:**
`docker build --tag ew --file docker/Dockerfile .`
Will create docker image with tag "ew"

View File

@@ -1,5 +1,5 @@
use base64::{Engine as _, engine::general_purpose};
use rand::Rng;
use rand::RngExt;
use aes::cipher::BlockEncryptMut;
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};

View File

@@ -48,9 +48,7 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
srv.call(req)
})
.app_data(web::PayloadConfig::default().limit(1024 * 1024 * 25))
.service(static_handlers::css)
.service(static_handlers::maintenance)
.service(static_handlers::js)
.service(static_handlers::files_jp)
.service(static_handlers::files_gl)
.default_service(web::route().to(router::request))

View File

@@ -57,7 +57,10 @@ pub struct Args {
pub en_android_asset_hash: String,
#[arg(long, default_value = "", help = "Asset hash for JP Android client.")]
pub jp_android_asset_hash: String
pub jp_android_asset_hash: String,
#[arg(long, default_value = "", help = "Path to image assets.")]
pub image_asset_path: String
}
pub fn get_args() -> Args {

View File

@@ -170,7 +170,7 @@ async fn api_req(req: HttpRequest, body: String) -> HttpResponse {
pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
let args = crate::get_args();
let headers = req.headers();
if args.hidden && req.path().starts_with("/api/webui/") {
if args.hidden && (req.path().starts_with("/api/webui/") || req.path().starts_with("/live_clear_rate.html")) {
return not_found(headers);
}
if headers.get("aoharu-asset-version").is_none() && req.path().starts_with("/api") && !req.path().starts_with("/api/webui") {
@@ -199,6 +199,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/startLoginbonus" => webui::start_loginbonus(req, body),
"/api/webui/import" => webui::import(req, body),
"/api/webui/set_time" => webui::set_time(req, body),
"/api/webui/cheat" => webui::cheat(req, body),
_ => api_req(req, body).await
}
} else {
@@ -212,9 +213,14 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/v1.0/payment/balance" => gree::balance(req),
"/web/announcement" => web::announcement(req),
"/api/webui/userInfo" => webui::user(req),
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
"/webui/logout" => webui::logout(req),
"/api/webui/export" => webui::export(req),
"/api/webui/serverInfo" => webui::server_info(req),
"/api/webui/listCards" => webui::get_card_info(req),
"/api/webui/listMusic" => webui::get_music_info(req),
"/api/webui/listLoginBonus" => webui::list_login_bonus(req),
"/api/webui/listItems" => webui::list_items(req),
_ => api_req(req, body).await
}
}

View File

@@ -1,5 +1,5 @@
use json::{object, array, JsonValue};
use actix_web::{HttpRequest};
use actix_web::{HttpRequest, HttpResponse, http::header::ContentType};
use rusqlite::params;
use std::sync::Mutex;
use lazy_static::lazy_static;
@@ -7,6 +7,7 @@ use lazy_static::lazy_static;
use crate::encryption;
use crate::sql::SQLite;
use crate::router::{global, databases};
use crate::include_file;
trait SqlClearRate {
fn get_live_data(&self, id: i64) -> Result<Live, rusqlite::Error>;
@@ -34,6 +35,7 @@ impl SqlClearRate for SQLite {
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("live_statistics.db", setup_tables);
static ref CACHED_DATA: Mutex<Option<JsonValue>> = Mutex::new(None);
static ref CACHED_HTML_DATA: Mutex<Option<JsonValue>> = Mutex::new(None);
}
pub struct Live {
@@ -150,6 +152,18 @@ pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
};
}
fn get_song_title(live_id: i32, english: bool) -> String {
let details = if english {
databases::MUSIC_EN[live_id.to_string()].clone()
} else {
databases::MUSIC[live_id.to_string()].clone()
};
if !details.is_null() {
return details["name"].to_string();
}
String::from("Unknown Song")
}
fn get_pass_percent(failed: i64, pass: i64) -> String {
let total = (failed + pass) as f64;
if failed + pass == 0 {
@@ -236,3 +250,101 @@ pub fn ranking(_req: HttpRequest, body: String) -> Option<JsonValue> {
"ranking_list": rank
})
}
fn get_html() -> JsonValue {
let lives = DATABASE.lock_and_select_all("SELECT live_id FROM lives", params!()).unwrap();
let mut table = String::new();
for id in lives.members() {
let live_id = id.as_i64().unwrap();
let info = match DATABASE.get_live_data(live_id) {
Ok(i) => i,
Err(_) => continue,
};
let calc_rate = |pass: i64, fail: i64| -> f64 {
let total = pass + fail;
if total == 0 { 0.0 } else { pass as f64 / total as f64 }
};
let title_jp = get_song_title(info.live_id, false);
let title_en = get_song_title(info.live_id, true);
let normal_txt = get_pass_percent(info.normal_failed, info.normal_pass);
let hard_txt = get_pass_percent(info.hard_failed, info.hard_pass);
let expert_txt = get_pass_percent(info.expert_failed, info.expert_pass);
let master_txt = get_pass_percent(info.master_failed, info.master_pass);
let normal_plays = info.normal_pass + info.normal_failed;
let hard_plays = info.hard_pass + info.hard_failed;
let expert_plays = info.expert_pass + info.expert_failed;
let master_plays = info.master_pass + info.master_failed;
let normal_rate_sort = calc_rate(info.normal_pass, info.normal_failed);
let hard_rate_sort = calc_rate(info.hard_pass, info.hard_failed);
let expert_rate_sort = calc_rate(info.expert_pass, info.expert_failed);
let master_rate_sort = calc_rate(info.master_pass, info.master_failed);
table.push_str(&format!(
r#"<tr>
<td class="title-cell"
data-val="{title_jp}"
data-title-en="{title_en}"
data-title-jp="{title_jp}">
{title_jp}
</td>
<td data-plays="{normal_plays}" data-rate="{normal_rate_sort}">
<span class="rate-text">{normal_txt}</span>
<span class="meta-text">{normal_plays} plays</span>
</td>
<td data-plays="{hard_plays}" data-rate="{hard_rate_sort}">
<span class="rate-text">{hard_txt}</span>
<span class="meta-text">{hard_plays} plays</span>
</td>
<td data-plays="{expert_plays}" data-rate="{expert_rate_sort}">
<span class="rate-text">{expert_txt}</span>
<span class="meta-text">{expert_plays} plays</span>
</td>
<td data-plays="{master_plays}" data-rate="{master_rate_sort}">
<span class="rate-text">{master_txt}</span>
<span class="meta-text">{master_plays} plays</span>
</td>
</tr>"#
));
}
let html = include_file!("src/router/clear_rate_template.html").replace("{{TABLEBODY}}", &table);
object!{
"cache": html,
"last_updated": global::timestamp()
}
}
async fn get_clearrate_html() -> String {
let cache = {
let mut result = crate::lock_onto_mutex!(CACHED_HTML_DATA);
if result.is_none() {
result.replace(get_html());
}
result.as_ref().unwrap().clone()
};
if cache["last_updated"].as_u64().unwrap() + (60 * 60) < global::timestamp() {
let mut result = crate::lock_onto_mutex!(CACHED_HTML_DATA);
result.replace(get_html());
}
cache["cache"].to_string()
}
pub async fn clearrate_html(_req: HttpRequest) -> HttpResponse {
let html = get_clearrate_html().await;
HttpResponse::Ok()
.content_type(ContentType::html())
.body(html)
}

View File

@@ -0,0 +1,239 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Live Clear Rates</title>
<style>
:root {
--bg-color: #f4f4f9;
--text-color: #333;
--table-bg: #ffffff;
--primary-color: #6c5ce7;
--primary-hover: #5649c0;
--header-text: #ffffff;
--border-color: #dddddd;
--row-even: #f8f9fa;
--row-hover: #eef2ff;
--meta-text: #666;
--shadow: rgba(0, 0, 0, 0.1);
}
body.dark-mode {
--bg-color: #18181b;
--text-color: #e4e4e7;
--table-bg: #27272a;
--primary-color: #818cf8;
--primary-hover: #6366f1;
--header-text: #ffffff;
--border-color: #3f3f46;
--row-even: #2f2f35;
--row-hover: #353540;
--meta-text: #a1a1aa;
--shadow: rgba(0, 0, 0, 0.4);
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
padding: 20px;
transition: background-color 0.3s, color 0.3s;
}
h1 { text-align: center; margin-bottom: 20px; }
.controls {
display: flex;
justify-content: center;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: 2px solid var(--primary-color);
background: transparent;
color: var(--text-color);
border-radius: 20px;
cursor: pointer;
font-weight: 600;
transition: 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn:hover, .btn.active {
background-color: var(--primary-color);
color: var(--header-text);
}
.table-container {
overflow-x: auto;
box-shadow: 0 0 20px var(--shadow);
border-radius: 8px;
background: var(--table-bg);
}
table { width: 100%; border-collapse: collapse; min-width: 600px; }
th, td {
padding: 12px 15px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--primary-color);
color: var(--header-text);
cursor: pointer;
user-select: none;
position: sticky;
top: 0;
white-space: nowrap;
}
th:hover { background-color: var(--primary-hover); }
tr:nth-of-type(even) { background-color: var(--row-even); }
tr:last-of-type { border-bottom: 2px solid var(--primary-color); }
tr:hover { background-color: var(--row-hover); transition: 0.2s; }
th::after { content: ' \2195'; opacity: 0.3; font-size: 0.8em; margin-left: 5px; }
th.asc::after { content: ' \2191'; opacity: 1; }
th.desc::after { content: ' \2193'; opacity: 1; }
.meta-text { font-size: 0.85em; color: var(--meta-text); display: block; margin-top: 4px; }
.rate-text { font-weight: bold; font-size: 1.1em; }
.title-cell { text-align: left; font-weight: 600; }
</style>
</head>
<body>
<h1>Live Clear Rates</h1>
<div class="controls">
<button class="btn" id="btnLang" onclick="toggleLanguage()">Language: JP</button>
<button class="btn active" id="btnPlays" onclick="setSortMode('plays')">Sort by Plays</button>
<button class="btn" id="btnRate" onclick="setSortMode('rate')">Sort by Rate %</button>
<button class="btn" onclick="toggleTheme()">Dark Mode 🌓</button>
</div>
<div class="table-container">
<table id="rateTable">
<thead>
<tr>
<th onclick="sortTable(0)">Song Title</th>
<th onclick="sortTable(1)">Normal</th>
<th onclick="sortTable(2)">Hard</th>
<th onclick="sortTable(3)">Expert</th>
<th onclick="sortTable(4)">Master</th>
</tr>
</thead>
<tbody>
{{TABLEBODY}}
</tbody>
</table>
</div>
<script>
let currentSortMode = 'plays';
let currentLang = localStorage.getItem('lang') || 'jp';
updateLanguageDisplay();
function toggleTheme() {
document.body.classList.toggle('dark-mode');
const isDark = document.body.classList.contains('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
}
function toggleLanguage() {
currentLang = currentLang === 'jp' ? 'en' : 'jp';
localStorage.setItem('lang', currentLang);
updateLanguageDisplay();
}
function updateLanguageDisplay() {
const btn = document.getElementById('btnLang');
btn.innerText = 'Language: ' + (currentLang === 'jp' ? 'JP' : 'EN');
const titleCells = document.querySelectorAll('.title-cell');
titleCells.forEach(td => {
const newTitle = td.getAttribute(`data-title-${currentLang}`);
if (newTitle) {
td.innerText = newTitle;
td.setAttribute('data-val', newTitle);
}
});
}
function setSortMode(mode) {
currentSortMode = mode;
document.getElementById('btnPlays').classList.toggle('active', mode === 'plays');
document.getElementById('btnRate').classList.toggle('active', mode === 'rate');
}
function sortTable(n) {
var table, rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0;
table = document.getElementById("rateTable");
switching = true;
dir = "desc";
var headers = table.getElementsByTagName("TH");
for (var h of headers) { h.classList.remove("asc", "desc"); }
while (switching) {
switching = false;
rows = table.rows;
for (i = 1; i < (rows.length - 1); i++) {
shouldSwitch = false;
x = rows[i].getElementsByTagName("TD")[n];
y = rows[i + 1].getElementsByTagName("TD")[n];
let attrName = (n === 0) ? 'data-val' : (currentSortMode === 'plays' ? 'data-plays' : 'data-rate');
var xVal = x.getAttribute(attrName);
var yVal = y.getAttribute(attrName);
var xNum = parseFloat(xVal);
var yNum = parseFloat(yVal);
if (!isNaN(xNum) && !isNaN(yNum)) {
if (dir == "asc") {
if (xNum > yNum) { shouldSwitch = true; break; }
} else if (dir == "desc") {
if (xNum < yNum) { shouldSwitch = true; break; }
}
} else {
// String sorting (for titles)
if (dir == "asc") {
if (xVal.toLowerCase() > yVal.toLowerCase()) { shouldSwitch = true; break; }
} else if (dir == "desc") {
if (xVal.toLowerCase() < yVal.toLowerCase()) { shouldSwitch = true; break; }
}
}
}
if (shouldSwitch) {
rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
switching = true;
switchcount ++;
} else {
if (switchcount == 0 && dir == "desc") {
dir = "asc";
switching = true;
}
}
}
headers[n].classList.add(dir);
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -274,6 +274,40 @@ lazy_static! {
}
info
};
pub static ref MUSIC: JsonValue = {
let mut info = object!{};
let items = json::parse(&include_file!("src/router/databases/json/music.json")).unwrap();
for live in LIVE_LIST.entries() {
info[live.1["id"].to_string()] = loop {
let mut val = object!{};
for data in items.members() {
if live.1["masterMusicId"] == data["id"] {
val = data.clone();
break;
}
}
break val;
};
};
info
};
pub static ref MUSIC_EN: JsonValue = {
let mut info = object!{};
let items = json::parse(&include_file!("src/router/databases/json/global/music.json")).unwrap();
for live in LIVE_LIST.entries() {
info[live.1["id"].to_string()] = loop {
let mut val = object!{};
for data in items.members() {
if live.1["masterMusicId"] == data["id"] {
val = data.clone();
break;
}
}
break val;
};
};
info
};
pub static ref RANKS: JsonValue = {
json::parse(&include_file!("src/router/databases/json/user_rank.json")).unwrap()
};

View File

@@ -1,6 +1,6 @@
use json::{JsonValue, object, array};
use actix_web::HttpRequest;
use rand::Rng;
use rand::RngExt;
use crate::encryption;
use crate::include_file;

View File

@@ -15,7 +15,6 @@ use rsa::pkcs8::DecodePublicKey;
use crate::router::global;
use crate::router::userdata;
use crate::encryption;
use crate::router::user::{code_to_uid, uid_to_code};
use crate::sql::SQLite;
lazy_static! {
@@ -231,11 +230,9 @@ pub fn migration_verify(req: HttpRequest, body: String) -> HttpResponse {
let body = json::parse(&body).unwrap();
let password = decrypt_transfer_password(&body["migration_password"].to_string());
let uid = code_to_uid(body["migration_code"].to_string()).parse::<i64>().unwrap_or(0);
let user = userdata::user::migration::get_acc_transfer(&body["migration_code"].to_string(), &password);
let user = userdata::get_acc_transfer(uid, &body["migration_code"].to_string(), &password);
let resp = if !user["success"].as_bool().unwrap() || uid == 0 {
let resp = if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
object!{
result: "ERR",
messsage: "User Not Found"
@@ -245,7 +242,7 @@ pub fn migration_verify(req: HttpRequest, body: String) -> HttpResponse {
object!{
result: "OK",
src_uuid: user["login_token"].clone(),
src_x_uid: uid.to_string(),
src_x_uid: user["user_id"].to_string(),
migration_token: user["login_token"].clone(),
balance_charge_gem: data_user["gem"]["charge"].to_string(),
balance_free_gem: data_user["gem"]["free"].to_string(),
@@ -316,7 +313,7 @@ pub fn migration_code(req: HttpRequest) -> HttpResponse {
let resp = object!{
result: "OK",
migration_code: uid_to_code(user["user"]["id"].to_string())
migration_code: userdata::user::migration::get_acc_token(user["user"]["id"].as_i64().unwrap())
};
send(req, resp)
@@ -336,10 +333,10 @@ pub fn migration_password_register(req: HttpRequest, body: String) -> HttpRespon
}
let user = userdata::get_acc(&uid);
let code = uid_to_code(user["user"]["id"].to_string());
let pass = decrypt_transfer_password(&body["migration_password"].to_string());
userdata::save_acc_transfer(&code, &pass);
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &pass);
let resp = object!{
result: "OK"

View File

@@ -1,5 +1,5 @@
use json::{array, object, JsonValue};
use rand::Rng;
use rand::RngExt;
use actix_web::{HttpRequest, http::header::{HeaderMap, HeaderValue}};
use crate::encryption;
@@ -43,78 +43,92 @@ pub fn get_region(headers: &HeaderMap) -> bool {
}
pub fn check_for_region(user: &mut JsonValue, headers: &HeaderMap) {
let items = if user["data"]["updated_value_list"]["item_list"].is_empty() {user["data"]["item_list"].clone()} else {user["data"]["updated_value_list"]["item_list"].clone()};
let is_jp = get_region(headers);
if !is_jp || items.is_empty() {
// returns true if jp
if !get_region(headers) {
return;
}
let mut id = 0;
for (i, data) in items.members().enumerate() {
if data["master_item_id"] == 15570008 {
id = i + 1;
break;
}
}
if id > 0 {
if user["data"]["updated_value_list"]["item_list"].is_empty() {
user["data"]["item_list"].array_remove(id - 1);
} else {
user["data"]["updated_value_list"]["item_list"].array_remove(id - 1);
}
let targets = [
15570001, 15570002, 15570003, 15570004,
15570005, 15570006, 15570007, 15570008
];
let list = if !user["data"]["updated_value_list"]["item_list"].is_empty() {
&mut user["data"]["updated_value_list"]["item_list"]
} else {
&mut user["data"]["item_list"]
};
let mut to_remove: Vec<usize> = list
.members()
.enumerate()
.filter(|(_, item)| {
targets.contains(&item["master_item_id"].as_i64().unwrap_or(0))
})
.map(|(i, _)| i)
.collect();
to_remove.reverse();
for i in to_remove {
list.array_remove(i);
}
}
// true - limit reached
// false - all good
const GIFT_LIMIT: usize = 100000;
const LIMIT_ITEMS: i64 = 200000000;
const LIMIT_COINS: i64 = 2000000000;
const LIMIT_PRIMOGEMS: i64 = 1000000;
pub const GIFT_LIMIT: usize = 100000;
pub const LIMIT_ITEMS: i64 = 9999999;
pub const LIMIT_COINS: i64 = 999999999;
pub const LIMIT_PRIMOGEMS: i64 = 9999999;
pub fn give_shop(master_item_id: i64, count: i64, user: &mut JsonValue) -> bool {
let mut has = false;
for dataa in user["shop_list"].members_mut() {
if dataa["master_shop_item_id"].as_i64().unwrap() == master_item_id {
has = true;
let new_amount = dataa["count"].as_i64().unwrap() + count;
if new_amount > LIMIT_ITEMS {
fn give(array: &mut JsonValue, shop_id: &str, master_item_id: i64, limit: i64, count_id: &str, count: i64, default_push: JsonValue) -> bool {
for data in array.members_mut() {
if data[shop_id].as_i64().unwrap() == master_item_id {
if data[count_id].as_i64().unwrap() >= limit {
return true;
}
dataa["count"] = new_amount.into();
break;
let mut new_amount = data[count_id].as_i64().unwrap() + count;
if new_amount > limit {
new_amount = limit;
}
data[count_id] = new_amount.into();
return false;
}
}
if !has {
user["shop_list"].push(object!{
master_shop_item_id: master_item_id,
count: count
}).unwrap();
}
array.push(default_push).unwrap();
false
}
pub fn give_item(master_item_id: i64, amount: i64, user: &mut JsonValue) -> bool {
let mut has = false;
for dataa in user["item_list"].members_mut() {
if dataa["master_item_id"].as_i64().unwrap() == master_item_id {
has = true;
let new_amount = dataa["amount"].as_i64().unwrap() + amount;
if new_amount > LIMIT_ITEMS {
return true;
}
dataa["amount"] = new_amount.into();
break;
pub fn give_shop(master_item_id: i64, count: i64, user: &mut JsonValue) -> bool {
give(
&mut user["shop_list"],
"master_shop_item_id",
master_item_id,
LIMIT_ITEMS,
"count",
count,
object!{
master_shop_item_id: master_item_id,
count: count
}
}
if !has {
user["item_list"].push(object!{
)
}
pub fn give_item(master_item_id: i64, amount: i64, user: &mut JsonValue) -> bool {
give(
&mut user["item_list"],
"master_item_id",
master_item_id,
LIMIT_ITEMS,
"amount",
amount,
object!{
id: master_item_id,
master_item_id: master_item_id,
amount: amount,
expire_date_time: null
}).unwrap();
}
false
}
)
}
pub fn use_item(item: &JsonValue, multiplier: i64, user: &mut JsonValue) {
@@ -175,25 +189,18 @@ pub fn give_points(master_item_id: i64, amount: i64, user: &mut JsonValue, missi
}
}
}
let mut has = false;
for data in user["point_list"].members_mut() {
if data["type"].as_i64().unwrap() == master_item_id {
has = true;
let new_amount = data["amount"].as_i64().unwrap() + amount;
if new_amount > LIMIT_COINS {
return true;
}
data["amount"] = new_amount.into();
break;
}
}
if !has {
user["point_list"].push(object!{
give(
&mut user["point_list"],
"type",
master_item_id,
LIMIT_COINS,
"amount",
amount,
object!{
type: master_item_id,
amount: amount
}).unwrap();
}
false
}
)
}
pub fn use_itemm(master_item_id: i64, amount: i64, user: &mut JsonValue) {
@@ -210,10 +217,13 @@ pub fn use_itemm(master_item_id: i64, amount: i64, user: &mut JsonValue) {
}
pub fn give_primogems(amount: i64, user: &mut JsonValue) -> bool {
let new_amount = user["gem"]["free"].as_i64().unwrap() + amount;
if new_amount > LIMIT_PRIMOGEMS {
if user["gem"]["free"].as_i64().unwrap() >= LIMIT_PRIMOGEMS {
return true;
}
let new_amount = user["gem"]["free"].as_i64().unwrap() + amount;
if user["gem"]["free"].as_i64().unwrap() > LIMIT_PRIMOGEMS {
user["gem"]["free"] = LIMIT_PRIMOGEMS.into();
}
user["gem"]["free"] = new_amount.into();
false

View File

@@ -1,6 +1,6 @@
use json::{object, array, JsonValue};
use actix_web::{HttpRequest};
use rand::Rng;
use rand::RngExt;
use lazy_static::lazy_static;
use crate::router::{global, userdata, items, databases};
@@ -182,6 +182,10 @@ fn check_for_stale_data(server_data: &mut JsonValue, live_id: i64) {
if live["expire_date_time"].as_u64().unwrap() < curr_time || live["master_live_id"] == live_id {
expired.push(i).unwrap();
}
if live["expire_date_time"].as_u64().unwrap() < curr_time {
// User closed game after losing. Count this as a fail.
live_completed(live["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), true, 0, 0);
}
}
for i in expired.members() {
server_data["last_live_started"].array_remove(i.as_usize().unwrap());
@@ -225,8 +229,8 @@ fn start_live(login_token: &str, body: &JsonValue) {
}
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap());
let mut to_save = body.clone();
// The user has 24 hours to complete a live
to_save["expire_date_time"] = (global::timestamp() + (24 * 60 * 60)).into();
// The user has 1 hour to complete a live
to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into();
server_data["last_live_started"].push(to_save).unwrap();
userdata::save_server_data(login_token, server_data);

View File

@@ -1,6 +1,6 @@
use json::{array, object, JsonValue};
use actix_web::{HttpRequest};
use rand::Rng;
use rand::RngExt;
use crate::router::{global, userdata, items, databases};
use crate::encryption;

View File

@@ -169,41 +169,10 @@ pub fn announcement(req: HttpRequest) -> Option<JsonValue> {
})
}
pub fn uid_to_code(uid: String) -> String {
//just replace uid with numbers because im too lazy to have a real database and this is close enough anyways
uid
.replace('1', "A")
.replace('2', "G")
.replace('3', "W")
.replace('4', "Q")
.replace('5', "Y")
.replace('6', "6")
.replace('7', "I")
.replace('8', "P")
.replace('9', "U")
.replace('0', "M")
+ "7"
}
pub fn code_to_uid(code: String) -> String {
//just replace uid with numbers because im too lazy to have a real database and this is close enough anyways
code
.replace('7', "")
.replace('A', "1")
.replace('G', "2")
.replace('W', "3")
.replace('Q', "4")
.replace('Y', "5")
.replace('6', "6")
.replace('I', "7")
.replace('P', "8")
.replace('U', "9")
.replace('M', "0")
}
pub fn get_migration_code(_req: HttpRequest, body: String) -> Option<JsonValue> {
let body = json::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap();
let code = uid_to_code(body["user_id"].to_string());
let code = userdata::user::migration::get_acc_token(body["user_id"].as_i64()?);
Some(object!{
"migrationCode": code
@@ -215,9 +184,8 @@ pub fn register_password(req: HttpRequest, body: String) -> Option<JsonValue> {
let body = json::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap();
let user = userdata::get_acc(&key);
let code = uid_to_code(user["user"]["id"].to_string());
userdata::save_acc_transfer(&code, &body["pass"].to_string());
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &body["pass"].to_string());
Some(array![])
}
@@ -225,18 +193,16 @@ pub fn register_password(req: HttpRequest, body: String) -> Option<JsonValue> {
pub fn verify_migration_code(_req: HttpRequest, body: String) -> Option<JsonValue> {
let body = json::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap();
let uid = code_to_uid(body["migrationCode"].to_string()).parse::<i64>().unwrap_or(0);
let user = userdata::user::migration::get_acc_transfer(&body["migrationCode"].to_string(), &body["pass"].to_string());
let user = userdata::get_acc_transfer(uid, &body["migrationCode"].to_string(), &body["pass"].to_string());
if !user["success"].as_bool().unwrap() || uid == 0 {
if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
return None;
}
let data_user = userdata::get_acc(&user["login_token"].to_string());
Some(object!{
"user_id": uid,
"user_id": user["user_id"].clone(),
"uuid": user["login_token"].to_string(),
"charge": data_user["gem"]["charge"].clone(),
"free": data_user["gem"]["free"].clone()
@@ -245,11 +211,9 @@ pub fn verify_migration_code(_req: HttpRequest, body: String) -> Option<JsonValu
pub fn request_migration_code(_req: HttpRequest, body: String) -> Option<JsonValue> {
let body = json::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap();
let uid = code_to_uid(body["migrationCode"].to_string()).parse::<i64>().unwrap_or(0);
let user = userdata::user::migration::get_acc_transfer(&body["migrationCode"].to_string(), &body["pass"].to_string());
let user = userdata::get_acc_transfer(uid, &body["migrationCode"].to_string(), &body["pass"].to_string());
if !user["success"].as_bool().unwrap() || uid == 0 {
if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
return None;
}
@@ -440,7 +404,7 @@ pub fn initialize(req: HttpRequest, body: String) -> Option<JsonValue> {
for (i, data) in cardstoreward.members().enumerate() {
items::give_character(data.as_i64().unwrap(), &mut user, &mut missions, &mut array![], &mut array![]);
if i < 10 {
if i < 9 {
user["deck_list"][0]["main_card_ids"][i] = data.clone();
}
}

View File

@@ -1,9 +1,9 @@
pub mod user;
use rusqlite::params;
use lazy_static::lazy_static;
use json::{JsonValue, array, object};
use rand::Rng;
use sha2::{Digest, Sha256};
use base64::{Engine as _, engine::general_purpose};
use rand::RngExt;
use crate::router::global;
use crate::router::items;
@@ -17,16 +17,17 @@ lazy_static! {
};
}
fn get_userdata_database() -> &'static SQLite {
&DATABASE
}
fn setup_tables(conn: &rusqlite::Connection) {
user::migration::setup_sql(conn).unwrap();
conn.execute_batch("
CREATE TABLE IF NOT EXISTS tokens (
user_id BIGINT NOT NULL PRIMARY KEY,
token TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS migration (
token TEXT NOT NULL PRIMARY KEY,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS userdata (
user_id BIGINT NOT NULL PRIMARY KEY,
userdata TEXT NOT NULL,
@@ -301,67 +302,6 @@ pub fn save_acc_sif(auth_key: &str, data: JsonValue) {
save_data(auth_key, "sifcards", data);
}
fn generate_salt() -> Vec<u8> {
let mut rng = rand::rng();
let mut bytes = vec![0u8; 16];
rng.fill(&mut bytes[..]);
bytes
}
fn hash_password(password: &str) -> String {
let salt = &generate_salt();
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt);
let hashed_password = hasher.finalize();
let salt_hash = [&salt[..], &hashed_password[..]].concat();
general_purpose::STANDARD.encode(salt_hash)
}
fn verify_password(password: &str, salted_hash: &str) -> bool {
if password.is_empty() || salted_hash.is_empty() {
return false;
}
let bytes = general_purpose::STANDARD.decode(salted_hash);
if bytes.is_err() {
return password == salted_hash;
}
let bytes = bytes.unwrap();
if bytes.len() < 17 {
return password == salted_hash;
}
let (salt, hashed_password) = bytes.split_at(16);
let hashed_password = &hashed_password[0..32];
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt);
let input_hash = hasher.finalize();
input_hash.as_slice() == hashed_password
}
pub fn get_acc_transfer(uid: i64, token: &str, password: &str) -> JsonValue {
let data = DATABASE.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token));
if data.is_err() {
return object!{success: false};
}
if verify_password(password, &data.unwrap()) {
let login_token = get_login_token(uid);
if login_token == String::new() {
return object!{success: false};
}
return object!{success: true, login_token: login_token};
}
object!{success: false}
}
pub fn save_acc_transfer(token: &str, password: &str) {
DATABASE.lock_and_exec("DELETE FROM migration WHERE token=?1", params!(token));
DATABASE.lock_and_exec("INSERT INTO migration (token, password) VALUES (?1, ?2)", params!(token, hash_password(password)));
}
pub fn get_name_and_rank(uid: i64) -> JsonValue {
let login_token = get_login_token(uid);
if login_token == String::new() {
@@ -479,8 +419,8 @@ fn create_webui_token() -> String {
}
pub fn webui_login(uid: i64, password: &str) -> Result<String, String> {
let pass = DATABASE.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(crate::router::user::uid_to_code(uid.to_string()))).unwrap_or_default();
if !verify_password(password, &pass) {
let pass = DATABASE.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(uid)).unwrap_or_default();
if !user::migration::verify_password(password, &pass) {
if acc_exists(uid) && pass.is_empty() {
return Err(String::from("Migration token not set. Set token in game settings."));
}
@@ -490,7 +430,8 @@ pub fn webui_login(uid: i64, password: &str) -> Result<String, String> {
let new_token = create_webui_token();
DATABASE.lock_and_exec("DELETE FROM webui WHERE user_id=?1", params!(uid));
DATABASE.lock_and_exec("INSERT INTO webui (user_id, token, last_login) VALUES (?1, ?2, ?3)", params!(uid, new_token, global::timestamp()));
// This could overflow given enough time... though rusqlite doesnt currently support ToSql on a u64...... maybe someday
DATABASE.lock_and_exec("INSERT INTO webui (user_id, token, last_login) VALUES (?1, ?2, ?3)", params!(uid, new_token, global::timestamp() as i64));
Ok(new_token)
}
@@ -505,17 +446,16 @@ pub fn webui_import_user(user: JsonValue) -> Result<JsonValue, String> {
let token = global::create_token();
DATABASE.lock_and_exec("INSERT INTO tokens (user_id, token) VALUES (?1, ?2)", params!(uid, token));
let mig = crate::router::user::uid_to_code(uid.to_string());
save_acc_transfer(&mig, &user["password"].to_string());
let token = user::migration::save_acc_transfer(uid, &user["password"].to_string());
Ok(object!{
uid: uid,
migration_token: mig
migration_token: token
})
}
fn webui_login_token(token: &str) -> Option<String> {
pub fn webui_login_token(token: &str) -> Option<String> {
let uid = DATABASE.lock_and_select("SELECT user_id FROM webui WHERE token=?1", params!(token)).unwrap_or_default();
if uid == String::new() || token.is_empty() {
return None;
@@ -640,7 +580,7 @@ pub fn purge_accounts() -> usize {
DATABASE.lock_and_exec("DELETE FROM server_data WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM webui WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM tokens WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM migration WHERE token=?1", params!(crate::router::user::uid_to_code(user_id.to_string())));
DATABASE.lock_and_exec("DELETE FROM migration WHERE user_id=?1", params!(user_id));
}
DATABASE.lock_and_exec("VACUUM", params!());
crate::router::gree::vacuum_database();

View File

@@ -0,0 +1 @@
pub mod migration;

View File

@@ -0,0 +1,157 @@
use rusqlite::params;
use json::{JsonValue, object};
use crate::router::userdata;
use rand::RngExt;
use sha2::{Digest, Sha256};
use base64::{Engine as _, engine::general_purpose};
fn generate_token() -> String {
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::rng();
let random_string: String = (0..16)
.map(|_| {
let idx = rng.random_range(0..charset.len());
charset.chars().nth(idx).unwrap()
})
.collect();
random_string
}
pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
let database = userdata::get_userdata_database();
let data = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token));
if data.is_err() {
return object!{success: false};
}
if verify_password(password, &data.unwrap()) {
let uid: i64 = database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap();
let login_token = userdata::get_login_token(uid);
if login_token == String::new() {
return object!{success: false};
}
return object!{success: true, login_token: login_token, user_id: uid};
}
object!{success: false}
}
pub fn save_acc_transfer(uid: i64, password: &str) -> String {
let database = userdata::get_userdata_database();
let token = if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) {
value
} else {
generate_token()
};
database.lock_and_exec("DELETE FROM migration WHERE user_id=?1", params!(uid));
database.lock_and_exec("INSERT INTO migration (user_id, token, password) VALUES (?1, ?2, ?3)", params!(uid, &token, hash_password(password)));
token
}
pub fn get_acc_token(uid: i64) -> String {
let database = userdata::get_userdata_database();
if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) {
value
} else {
save_acc_transfer(uid, "")
}
}
fn generate_salt() -> Vec<u8> {
let mut rng = rand::rng();
let mut bytes = vec![0u8; 16];
rng.fill(&mut bytes[..]);
bytes
}
fn hash_password(password: &str) -> String {
if password.is_empty() { return String::new(); };
let salt = &generate_salt();
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt);
let hashed_password = hasher.finalize();
let salt_hash = [&salt[..], &hashed_password[..]].concat();
general_purpose::STANDARD.encode(salt_hash)
}
pub fn verify_password(password: &str, salted_hash: &str) -> bool {
if password.is_empty() || salted_hash.is_empty() {
return false;
}
let bytes = general_purpose::STANDARD.decode(salted_hash);
if bytes.is_err() {
return password == salted_hash;
}
let bytes = bytes.unwrap();
if bytes.len() < 17 {
return password == salted_hash;
}
let (salt, hashed_password) = bytes.split_at(16);
let hashed_password = &hashed_password[0..32];
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt);
let input_hash = hasher.finalize();
input_hash.as_slice() == hashed_password
}
pub fn setup_sql(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
conn.execute("
CREATE TABLE IF NOT EXISTS migration (
user_id BIGINT NOT NULL,
token TEXT NOT NULL,
password TEXT NOT NULL,
PRIMARY KEY (user_id, token)
);
", [])?;
let is_updated = conn.prepare("SELECT user_id FROM migration LIMIT 1;").is_ok();
if is_updated { return Ok(()); }
println!("Upgrading migration table");
conn.execute("DROP TABLE IF EXISTS migration_new;", [])?;
conn.execute("
CREATE TABLE migration_new (
user_id BIGINT NOT NULL,
token TEXT NOT NULL,
password TEXT NOT NULL,
PRIMARY KEY (user_id, token)
);
", [])?;
let mut stmt = conn.prepare("SELECT token, password FROM migration")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
))
})?;
let mut insert_new_row = conn.prepare("INSERT INTO migration_new (user_id, token, password) VALUES (?1, ?2, ?3)")?;
for row in rows {
let (token, password) = row?;
let user_id = code_to_uid(&token);
insert_new_row.execute(params![user_id, token, password])?;
}
conn.execute("DROP TABLE migration;", params!())?;
conn.execute("ALTER TABLE migration_new RENAME TO migration;", params!())?;
Ok(())
}
fn code_to_uid(code: &str) -> String {
code
.replace('7', "")
.replace('A', "1")
.replace('G', "2")
.replace('W', "3")
.replace('Q', "4")
.replace('Y', "5")
.replace('6', "6")
.replace('I', "7")
.replace('P', "8")
.replace('U', "9")
.replace('M', "0")
}

View File

@@ -5,6 +5,9 @@ use actix_web::{
http::header::ContentType
};
use json::{JsonValue, object};
use lazy_static::lazy_static;
use include_dir::{include_dir, Dir};
use std::fs;
use crate::include_file;
use crate::router::{userdata, items};
@@ -32,10 +35,8 @@ fn error(msg: &str) -> HttpResponse {
message: msg
};
HttpResponse::Ok()
.insert_header(("Access-Control-Allow-Origin", "*"))
.insert_header(ContentType::json())
.body(json::stringify(resp))
}
pub fn login(_req: HttpRequest, body: String) -> HttpResponse {
@@ -137,30 +138,61 @@ pub fn logout(req: HttpRequest) -> HttpResponse {
HttpResponse::Found()
.insert_header(ContentType::json())
.insert_header(("Set-Cookie", "ew_token=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT"))
.insert_header(("Location", "/"))
.insert_header(("Location", "/login.html"))
.body(json::stringify(resp))
}
static WEBUI_ASSETS: Dir<'_> = include_dir!("webui/");
pub fn main(req: HttpRequest) -> HttpResponse {
if req.path() == "/" {
let path = if req.path().ends_with("/") { format!("{}index.html", req.path()) } else { req.path().to_string() };
let mut chars = path.chars();
chars.next();
let path = chars.as_str();
if path == "login.html" {
let token = get_login_token(&req);
if token.is_some() {
let data = userdata::webui_get_user(&token.unwrap());
if data.is_some() {
return HttpResponse::Found()
.insert_header(("Location", "/home/"))
.insert_header(("Location", "/account.html"))
.body("");
}
}
}
if req.path() != "/" && req.path() != "/home/" && req.path() != "/import/" && req.path() != "/help/" {
return HttpResponse::Found()
.insert_header(("Location", "/"))
.body("");
if let Some(file) = WEBUI_ASSETS.get_file(&path) {
let body = file.contents();
let mime = mime_guess::from_path(path).first_or_octet_stream();
return HttpResponse::Ok()
.insert_header(ContentType(mime))
.insert_header(("content-length", body.len()))
.body(body);
} else if path.starts_with("webui/images/card-thumbnails") {
let args = crate::get_args();
let file_name = path.split("/").last().unwrap_or("");
let file_path = format!("{}/{}", args.image_asset_path, file_name).replace("//", "/");
if args.image_asset_path != "" && let Ok(body) = fs::read(file_path) {
let mime = mime_guess::from_path(path).first_or_octet_stream();
return HttpResponse::Ok()
.insert_header(ContentType(mime))
.insert_header(("content-length", body.len()))
.body(body);
} else {
if args.image_asset_path != "" {
println!("File '{file_name}' was requested, but no file was found on the disk!");
}
return HttpResponse::SeeOther()
.insert_header(("location", format!("https://sif2-api.ethanthesleepy.one{}", req.path())))
.body("");
}
}
HttpResponse::Ok()
.insert_header(ContentType::html())
.body(include_file!("webui/dist/index.html"))
HttpResponse::Found()
.insert_header(("Location", "/"))
.body("")
}
pub fn export(req: HttpRequest) -> HttpResponse {
@@ -199,7 +231,167 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(json::stringify(resp))
.insert_header(ContentType::json())
.body(json::stringify(resp))
}
fn get_query_str(req: &HttpRequest, key: &str, def: &str) -> String {
let query_str = req.query_string();
query_str
.split('&')
.find(|s| s.starts_with(&format!("{key}=")))
.and_then(|s| s.split('=').nth(1))
.unwrap_or(def).to_string()
}
pub fn get_card_info(req: HttpRequest) -> HttpResponse {
let page = get_query_str(&req, "page", "1").parse::<usize>().unwrap_or(1) - 1;
let max = get_query_str(&req, "max", "10").parse::<usize>().unwrap_or(10);
let all = get_query_str(&req, "all", "false");
let name_query = get_query_str(&req, "query", "");
let start = page * max;
let items = json::parse(&include_file!("src/router/webui/cards.json")).unwrap();
if all == "true" {
let resp = object!{
total_pages: 1,
current: items
};
return HttpResponse::Ok()
.content_type(ContentType::json())
.body(json::stringify(resp));
}
let mut filtered_items: Vec<_> = items.members().collect();
if !name_query.is_empty() {
let lowercase_query = name_query.to_lowercase();
filtered_items.retain(|item| {
item["name"].to_string().to_lowercase().contains(&lowercase_query)
});
}
let total_len = filtered_items.len();
let page_items: Vec<_> = filtered_items
.into_iter()
.skip(start)
.take(max)
.cloned()
.collect();
if page_items.is_empty() {
return HttpResponse::NotFound()
.finish();
}
let total_pages = (total_len as f64 / max as f64).ceil() as usize;
let args = crate::get_args();
let resp = object!{
total_pages: total_pages,
current: page_items,
image_path: args.image_asset_path
};
HttpResponse::Ok()
.content_type(ContentType::json())
.body(json::stringify(resp))
}
pub fn get_music_info(req: HttpRequest) -> HttpResponse {
let page = get_query_str(&req, "page", "1").parse::<usize>().unwrap_or(1) - 1;
let max = get_query_str(&req, "max", "10").parse::<usize>().unwrap_or(10);
let lang = get_query_str(&req, "lang", "JP");
let start = page * max;
let items = if lang == "EN" {
json::parse(&include_file!("src/router/databases/json/global/music.json")).unwrap()
} else {
json::parse(&include_file!("src/router/databases/json/music.json")).unwrap()
};
let page_items: Vec<_> = items.members()
.skip(start)
.take(max)
.cloned()
.collect();
if page_items.is_empty() {
return HttpResponse::NotFound()
.finish();
}
let total_items = items.len();
let total_pages = (total_items as f64 / max as f64).ceil() as usize;
let resp = object!{
total_pages: total_pages,
current: page_items
};
HttpResponse::Ok()
.content_type(ContentType::json())
.body(json::stringify(resp))
}
lazy_static! {
static ref ITEM: JsonValue = json::parse(&include_file!("src/router/webui/item.json")).unwrap();
static ref LOGIN_BONUS: JsonValue = json::parse(&include_file!("src/router/webui/login_bonus.json")).unwrap();
}
pub fn list_login_bonus(_req: HttpRequest) -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::json())
.body(json::stringify(LOGIN_BONUS.clone()))
}
pub fn list_items(_req: HttpRequest) -> HttpResponse {
HttpResponse::Ok()
.content_type(ContentType::json())
.body(json::stringify(ITEM.clone()))
}
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req);
if token.is_none() {
return error("Not logged in");
}
let key = userdata::webui_login_token(&token.unwrap());
if key.is_none() {
return error("Not logged in");
}
let key = key.unwrap();
let mut user = userdata::get_acc_home(&key);
for item in ITEM.entries() {
let id = item.0.parse::<i32>().unwrap_or(0);
let data = item.1;
if id == 0 {
continue;
}
let reward_type = data["reward_type"].as_i32().unwrap();
let limit = if reward_type == 4 {
items::LIMIT_COINS
} else if reward_type == 1 {
items::LIMIT_PRIMOGEMS
} else {
items::LIMIT_ITEMS
};
items::gift_item_basic(id, limit, reward_type, "You have cheated. Here are \"gifts\".", &mut user);
}
userdata::save_acc_home(&key, user);
let resp = object!{
result: "OK"
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(json::stringify(resp))
}

4626
src/router/webui/cards.json Normal file

File diff suppressed because it is too large Load Diff

1054
src/router/webui/item.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,13 @@ impl SQLite {
}
})
}
pub fn lock_and_select_type<T: rusqlite::types::FromSql>(&self, command: &str, args: &[&dyn ToSql]) -> Result<T, rusqlite::Error> {
let conn = Connection::open(&self.path).unwrap();
let mut stmt = conn.prepare(command)?;
stmt.query_row(args, |row| {
row.get(0)
})
}
pub fn lock_and_select_all(&self, command: &str, args: &[&dyn ToSql]) -> Result<JsonValue, rusqlite::Error> {
let conn = Connection::open(&self.path).unwrap();
let mut stmt = conn.prepare(command)?;

View File

@@ -5,20 +5,7 @@ use actix_web::{
http::header::ContentType
};
use std::fs;
use crate::include_file;
#[get("/index.css")]
async fn css(_req: HttpRequest) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType(mime::TEXT_CSS))
.body(include_file!("webui/dist/index.css"))
}
#[get("/index.js")]
async fn js(_req: HttpRequest) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType(mime::APPLICATION_JAVASCRIPT_UTF_8))
.body(include_file!("webui/dist/index.js"))
}
#[get("/maintenance/maintenance.json")]
async fn maintenance(_req: HttpRequest) -> HttpResponse {
HttpResponse::Ok()
@@ -26,7 +13,29 @@ async fn maintenance(_req: HttpRequest) -> HttpResponse {
.body(r#"{"opened_at":"2024-02-05 02:00:00","closed_at":"2024-02-05 04:00:00","message":":(","server":1,"gamelib":0}"#)
}
#[cfg(feature = "library")]
use include_dir::{include_dir, Dir};
#[cfg(all(feature = "library", target_os = "ios"))]
static SPART_FILES: Dir<'_> = include_dir!("assets/iOS/");
#[cfg(all(feature = "library", target_os = "android"))]
static SPART_FILES: Dir<'_> = include_dir!("assets/Android/");
fn handle_assets(req: HttpRequest) -> HttpResponse {
#[cfg(feature = "library")]
{
let lang: String = req.match_info().get("lang").unwrap_or("JP").parse().unwrap_or(String::from("JP"));
let file_name: String = req.match_info().get("file").unwrap().parse().unwrap();
let hash: String = req.match_info().get("file").unwrap().parse().unwrap();
if let Some(file) = SPART_FILES.get_file(format!("{lang}/{hash}/{file_name}")) {
let body = file.contents();
return HttpResponse::Ok()
.insert_header(ContentType(mime::APPLICATION_OCTET_STREAM))
.insert_header(("content-length", body.len()))
.body(body);
}
}
let file_path = format!("assets{}", req.path());
let exists = fs::exists(&file_path);

1
webui Submodule

Submodule webui added at e5b5d3d84d

View File

@@ -1,21 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
webui/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EW private server</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4554
webui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"vite": "^5.2.0"
}
}

View File

@@ -1,18 +0,0 @@
const serverUrl = "";
async function api(url, body) {
try {
let options = body ? {method: "POST", body: JSON.stringify(body)} : {}
const resp = await fetch(serverUrl + url, options);
const text = await resp.text();
return JSON.parse(text);
} catch(e) {
return {
result: "ERR",
message: e.message
}
}
}
export default api;

View File

@@ -1,36 +0,0 @@
body {
background-color: #616161;
}
#home {
width: 90%;
margin: 50px auto;
background-color: #43A047;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
padding: 20px 10px;
}
#logout {
border: none;
text-align: center;
text-decoration: underline;
display: inline-block;
font-size: 16px;
transition-duration: 0.4s;
float: right;
background-color: yellow;
border-radius: 30px;
padding: 5px 20px;
cursor: pointer;
}
#logout:hover {
background-color: red;
}
#error p {
color: orange;
grid-template-columns: auto auto auto;
}

View File

@@ -1,89 +0,0 @@
import { useState, useParams, useEffect } from 'react'
import './Help.css'
import Request from '../Request.jsx'
let init = false;
function Help() {
const [downloadUrl, setDownloadUrl] = useState(<div>Your server admin has no pre-patched apks to download. See the question below..</div>);
const [downloadUrliOSGL, setDownloadUrliOSGL] = useState("https://ethanthesleepy.one/public/lovelive/sif2/sif2-gl.ipa");
const [downloadUrliOSJP, setDownloadUrliOSJP] = useState("https://ethanthesleepy.one/public/lovelive/sif2/sif2-jp.ipa");
const [assetUrl, setAssetUrl] = useState("https://sif2.sif.moe");
useEffect(() => {
(async () => {
if (init) return;
init = true;
let resp = await Request("/api/webui/serverInfo");
if (resp.result !== "OK") {
return;
}
if (!resp.data.links) return;
if (resp.data.links.global && resp.data.links.japan) {
setDownloadUrl(
<div>Your server admin has a link to download! Download <a href={resp.data.links.japan}>Japan</a> or <a href={resp.data.links.global}>Global</a></div>
);
} else if (resp.data.links.global) {
setDownloadUrl(
<div>Your server admin has a link to download! Download <a href={resp.data.links.global}>Global</a></div>
);
} else if (resp.data.links.japan) {
setDownloadUrl(
<div>Your server admin has a link to download! Download <a href={resp.data.links.japan}>Japan</a></div>
);
}
if (resp.data.links.assets) {
setAssetUrl(resp.data.links.assets);
}
if (!resp.data.links.ios) return;
if (resp.data.links.ios.japan) {
setDownloadUrliOSJP(resp.data.links.ios.japan);
}
if (resp.data.links.ios.global) {
setDownloadUrliOSGL(resp.data.links.ios.global);
}
})();
});
return (
<div id="home">
<h1>Help/About</h1>
<h2>What is "ew"? What is this server for?</h2>
<p>"ew" is a private server, written in Rust, for the short lived game "Love Live! School idol festival 2 MIRACLE LIVE!", a Love Live! themed mobile rhythm game.</p>
<h2>I'm just trying to play on this server, how do I install the app? (Android)</h2>
<p>{downloadUrl}</p>
<h2>My server admin has no download URLs, how do I patch the apk? (Android)</h2>
<p>You can use <a href="https://arasfon.ru/sif2/patcher/">@arasfon's sif2 apk patcher</a>. Plug and the asset url, which is "{assetUrl}", into the "Assets URL" textbox, and then the server url (Which is likely "{window.location.origin}", though this may not be the case). Select your game version, set "header format" to "Lowercase", and press patch. Once that done, use <a href="https://github.com/patrickfav/uber-apk-signer">uber-apk-signer</a> to sign the apk. Then, get it onto your phone and install!</p>
<h2>How do I install the app? (iOS)</h2>
<p>Running on iOS is much simpler than Android, thanks to triangle on the discord. You first download an ipa file for <a href={downloadUrliOSGL}>global</a> or <a href={downloadUrliOSJP}>Japan</a>, and use <a href="https://sideloadly.io/">Sideloadly</a> (or your preferred application installer) to install the app. Then open settings, navigate to the app you just installed, and input the server url (Which is likely "{window.location.origin}", though this may not be the case), and the asset url, which is "{assetUrl}". If you have any errors opening the app, make sure none of the urls in settings end with a slash (/).</p>
<h2>Help! I'm trying to open the app and it shows as "unavailable" (iOS)</h2>
<p>Do not delete it, Just re-sideload the app. This is an Apple security feature.</p>
<h2>How well does this work?</h2>
<p>Works well enough. The server itself takes up not even 20mb of storage, and it's written in rust. I personally think it's pretty well written.</p>
<h2>Could my computer/laptop run a server?</h2>
<p>Very very likely. If the platform is <a href="https://doc.rust-lang.org/nightly/rustc/platform-support.html">supported by rust</a>, then the answer is yes! It is recommended to manually compile the project until I get the time to setup actions. <a href="https://git.ethanthesleepy.one/ethanaobrien/ew">ew repo</a></p>
<h2>Is the server down right now? I can't connect</h2>
<p>Assuming you have just loaded this page on the server you use, then the answer is no, otherwise please contact your server admin.</p>
<h2>Do events work?</h2>
<p>Most events do not, though most should not crash the game. Star events are partially implemented. You can get your rank up, and compete with other players in a ranking table, but no rewards are currently implemented.</p>
<h2>But then, how do I get event URs?</h2>
<p>There are serial codes for several things, one of which includes all the event URs. I don't remember what does what but it is recommended to look at the serial code file to get the latest codes.</p>
<h2>Why does the game crash when I do x?</h2>
<p>This likely means something on the server is broken. If you're self hosting, please contact me via matrix. Otherwise, contact your server admin and ask them to report the issue.</p>
</div>
);
}
export default Help;

View File

@@ -1,36 +0,0 @@
body {
background-color: #616161;
}
#home {
width: 90%;
margin: 50px auto;
background-color: #43A047;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
padding: 20px 10px;
}
#logout {
border: none;
text-align: center;
text-decoration: underline;
display: inline-block;
font-size: 16px;
transition-duration: 0.4s;
float: right;
background-color: yellow;
border-radius: 30px;
padding: 5px 20px;
cursor: pointer;
}
#logout:hover {
background-color: red;
}
#error p {
color: orange;
grid-template-columns: auto auto auto;
}

View File

@@ -1,196 +0,0 @@
import { useState, useParams, useEffect } from 'react'
import './Home.css'
import Request from '../Request.jsx'
let bonusItems = [];
function getMigrationID(uid) {
return uid.toString()
.replaceAll('1', "A")
.replaceAll('2', "G")
.replaceAll('3', "W")
.replaceAll('4', "Q")
.replaceAll('5', "Y")
.replaceAll('6', "6")
.replaceAll('7', "I")
.replaceAll('8', "P")
.replaceAll('9', "U")
.replaceAll('0', "M")
+ "7";
}
function Bonus() {
const [inputValue, setInputValue] = useState('');
const error = useState("");
let itemz = [];
bonusItems.forEach((e) => {
itemz.push(e.master_login_bonus_id);
})
const [submittedItems, setSubmittedItems] = useState(itemz);
const handleSubmit = async (event) => {
event.preventDefault();
let input = parseInt(inputValue.trim());
if (isNaN(input) || submittedItems.includes(input)) return;
let resp = await Request("/api/webui/startLoginbonus", {
bonus_id: input
});
if (resp.result !== "OK") {
error[1](resp.message);
return;
}
error[1]("");
setSubmittedItems([...submittedItems, resp.id]);
setInputValue('');
};
const handleRemoveItem = (index) => {
const updatedItems = [...submittedItems];
updatedItems.splice(index, 1);
setSubmittedItems(updatedItems);
};
// <button onClick={() => handleRemoveItem(index)}>X</button>
return (
<div>
<h2>Current login bonus list</h2>
<div id="error"> { error[0] ? <p>Error: { error[0] } </p> : <p></p> } </div>
<ul>
{submittedItems.map((item, index) => (
<li key={index}>
{item}
</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
placeholder="Enter login bonus ID"
/>
<button type="submit">Submit</button>
</form>
<p>You can find a list of available login bonus IDs <a href="https://git.ethanthesleepy.one/ethanaobrien/ew/src/branch/main/src/router/databases/json/login_bonus.json">here</a>. You should input the <code>id</code> field</p>
</div>
);
}
function Home() {
const [user, userdata] = useState();
const [inputValue, setInputValue] = useState('');
const [serverTime, setServerTime] = useState('');
const error = useState("");
const logout = () => {
window.location.href = "/webui/logout";
}
const downloadFile = (contents, name) => {
let a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([contents], {type: "application/json"}));
a.download = name;
a.click();
}
const expor = async (e) => {
e.preventDefault();
let resp = await Request("/api/webui/export");
if (resp.result !== "OK") {
error[1](resp.message);
return;
}
downloadFile(resp.data.userdata, "userdata.json");
downloadFile(resp.data.userhome, "userhome.json");
downloadFile(resp.data.missions, "missions.json");
downloadFile(resp.data.sifcards, "sifcards.json");
}
const handleSubmit = async (event) => {
event.preventDefault();
let time = Math.round(new Date(inputValue.trim()).getTime() / 1000);
if (inputValue.trim() === "-1") {
time = 1711741114;
} else if (inputValue.trim() === "0") {
time = 0;
}
if (time < 0 || isNaN(time)) return;
let resp = await Request("/api/webui/set_time", {
timestamp: time
});
if (resp.result !== "OK") {
error[1](resp.message);
return;
}
error[1]("");
if (time === 0) {
setServerTime("now");
} else {
setServerTime((new Date(time * 1000)).toString());
}
setInputValue('');
};
useEffect(() => {
if (user) return;
(async () => {
let resp = await Request("/api/webui/userInfo");
if (resp.result !== "OK") {
window.location.href = "/?message=" + encodeURIComponent(resp.message);
return;
}
let user = resp.data.userdata;
bonusItems = resp.data.loginbonus.bonus_list;
/*
bonusItems = [{"master_login_bonus_id":1,"day_counts":[1,2],"event_bonus_list":[]}];
let user = {
user: {
id: 1,
rank: 3,
exp: 10,
last_login_time: 5
},
time: new Date()
}*/
if (resp.data.time === 0) {
setServerTime("now");
} else {
setServerTime((new Date(resp.data.time * 1000)).toString());
}
userdata(
<div>
<p>User id: { user.user.id } </p>
<p>Migration id: { getMigrationID(user.user.id) } </p>
<p>Rank: { user.user.rank } ({ user.user.exp } exp)</p>
<p>Last Login: { (new Date(user.user.last_login_time * 1000)).toString() } </p>
<Bonus />
</div>
);
})();
});
return (
<div id="home">
<button id="logout" onClick={expor}>Export account</button><br/><br/><br/>
<button id="logout" onClick={logout}>Logout</button>
<h1>Home</h1>
{ user ? <div>
{ user }
<h2>Server time</h2>
<div id="error"> { error[0] ? <p>Error: { error[0] } </p> : <p></p> } </div>
<p>Currently set to { serverTime }. Setting to 0 will set it to now, and -1 will reset it. Time will still progress, based off of when you set this timestamp.</p>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
placeholder="Enter Server Time"
/>
<button type="submit">Submit</button>
</form></div>
: <p>Loading...</p> }
</div>
);
}
export default Home;

View File

@@ -1,95 +0,0 @@
body {
background-color: #616161;
}
#login-form {
width: 400px;
max-width: 100%;
margin: 50px auto;
background-color: green;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
}
#login-form h1 {
text-align: center;
margin: 0;
padding: 20px 0;
font-size: 28px;
font-weight: bold;
color: white;
}
#login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
font-family: "Poppins", sans-serif;
}
#login-form form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: black;
font-family: "Poppins", sans-serif;
}
#login-form form input[type="file"],
#login-form form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
}
#login-form form input[type="submit"] {
width: 100%;
padding: 12px;
background-color: dodgerblue;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
#login-form form input[type="submit"]:hover {
background-color: deepskyblue;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
#sub_div p {
color: red;
grid-template-columns: auto auto auto;
}
#sub_div {
padding-top: 10px;
}
#sub_div button {
width: 40%;
padding: 12px;
background-color: blue;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
#sub_div button:hover {
background-color: green;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}

View File

@@ -1,84 +0,0 @@
import { useState } from 'react'
import './Import.css'
import Request from '../Request.jsx'
function Login() {
const error = useState(new URL(window.location).searchParams.get("message") || "");
const status = useState("");
const uid = useState((window.localStorage && window.localStorage.getItem("ew_uid")) || "");
let file=[], file1=[], file2=[], file3=[], password;
let has_imported = false;
const handleSubmit = async (event) => {
event.preventDefault();
if (!file[0] || !file1[0] || has_imported || !password) return;
try {
has_imported = true;
let data = {
userdata: JSON.parse(await file[0].text()),
home: JSON.parse(await file1[0].text()),
missions: file2[0] ? JSON.parse(await file2[0].text()) : undefined,
sif_cards: file3[0] ? JSON.parse(await file3[0].text()) : undefined,
password: password,
jp: true
};
if (!data.userdata || !data.userdata.user || !data.userdata.user.id) {
error[1]("Incorrect user data file format");
return;
}
if (!data.home || !data.home.home || !data.home.home.information_list) {
error[1]("Incorrect home data file format");
return;
}
if (!Array.isArray(data.missions) && data.missions) {
error[1]("Incorrect mission data file format");
return;
}
if (!Array.isArray(data.sif_cards) && data.sif_cards) {
error[1]("Incorrect sif card data file format");
return;
}
let resp = await Request(
"/api/webui/import",
data
);
if (resp.result == "OK") {
status[1](<div><p>Account imported!</p><p>User id: {resp.uid}</p><p>Migration token: {resp.migration_token}</p></div>);
} else {
error[1](resp.message);
}
} catch(e) {
error[1](e.message);
}
};
return (
<div id="login-form">
<h1>Transfer</h1>
<form>
{ <p>{ status[0] } </p> }
<label htmlFor="id">User data file (required):</label>
<input type="file" id="id" name="id" onChange={(event) => {file = event.target.files}} accept="application/json"/>
<label htmlFor="file1">User Home data file (required):</label>
<input type="file" id="file1" name="file1" onChange={(event) => {file1 = event.target.files}} accept="application/json"/>
<label htmlFor="file2">User Missions data file (optional):</label>
<input type="file" id="file2" name="file2" onChange={(event) => {file2 = event.target.files}} accept="application/json"/>
<label htmlFor="file3">Sif cards data file (optional):</label>
<input type="file" id="file3" name="file3" onChange={(event) => {file3 = event.target.files}} accept="application/json"/>
<label htmlFor="password">Transfer passcode (game will not recognize special characters, only use letters and numbers or you will be locked out):</label>
<input type="password" id="password" name="password" onChange={(event) => {password = event.target.value}} />
<input type="submit" value="Submit" onClick={handleSubmit}/>
<div id="sub_div">
{ error[0] ? <p>Error: { error[0] }. Please reload the page and try again.</p> : <p></p> }
</div>
</form>
</div>
);
}
export default Login;

View File

@@ -1,95 +0,0 @@
body {
background-color: #616161;
}
#login-form {
width: 400px;
max-width: 100%;
margin: 50px auto;
background-color: green;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
}
#login-form h1 {
text-align: center;
margin: 0;
padding: 20px 0;
font-size: 28px;
font-weight: bold;
color: white;
}
#login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
font-family: "Poppins", sans-serif;
}
#login-form form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: black;
font-family: "Poppins", sans-serif;
}
#login-form form input[type="text"],
#login-form form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
}
#login-form form input[type="submit"] {
width: 100%;
padding: 12px;
background-color: dodgerblue;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
#login-form form input[type="submit"]:hover {
background-color: deepskyblue;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
#sub_div p {
color: red;
grid-template-columns: auto auto auto;
}
#sub_div {
padding-top: 10px;
}
#sub_div button {
width: 40%;
padding: 12px;
background-color: blue;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
#sub_div button:hover {
background-color: green;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}

View File

@@ -1,69 +0,0 @@
import { useState } from 'react'
import './Login.css'
import Request from '../Request.jsx'
function Login() {
const error = useState(new URL(window.location).searchParams.get("message") || "");
const uid = useState((window.localStorage && window.localStorage.getItem("ew_uid")) || "");
const password = useState("");
function showError(message) {
error[1](message);
}
const handleSubmit = async (event) => {
event.preventDefault();
if (!uid[0] || !password[0]) {
showError("Missing userid/password");
return;
}
if (isNaN(uid[0])) {
showError("UserID should be a number. (The \"Friend ID\" in your profile)");
return;
}
if (window.localStorage) window.localStorage.setItem("ew_uid", uid[0]);
let resp = await Request(
"/api/webui/login",
{
uid: parseInt(uid[0]),
password: password[0]
}
);
if (resp.result == "OK") {
window.location.href = "/home/";
} else {
showError(resp.message);
}
};
const import_user = (e) => {
e.preventDefault();
window.location.href = "/import/";
}
const help = (e) => {
e.preventDefault();
window.location.href = "/help/";
}
return (
<div id="login-form">
<h1>Login</h1>
<form>
<label htmlFor="id">SIF2 ID:</label>
<input type="text" id="id" name="id" onChange={(event) => {uid[1](event.target.value)}} value={uid[0]} />
<label htmlFor="password">Transfer passcode:</label>
<input type="password" id="password" name="password" onChange={(event) => {password[1](event.target.value)}} />
<input type="submit" value="Submit" onClick={handleSubmit}/>
<div id="sub_div">
<button onClick={import_user}>Import User</button><br/><br/>
<button onClick={help}>Need help?</button><br/><br/>
{ error[0] ? <p>Error: { error[0] } </p> : <p></p> }
</div>
<p>EW Version 1.0.0 - <a href="https://git.ethanthesleepy.one/ethanaobrien/ew">View source</a> - <a href="https://git.ethanthesleepy.one/ethanaobrien/ew/src/branch/main/LICENSE">View license</a></p>
</form>
</div>
);
}
export default Login;

View File

@@ -1,32 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import Login from './login/Login.jsx'
import Home from './home/Home.jsx'
import Import from './import/Import.jsx'
import Help from './help/Help.jsx'
let Elem;
switch (window.location.pathname) {
case "/":
Elem = Login;
break;
case "/home/":
Elem = Home;
break;
case "/import/":
Elem = Import;
break;
case "/help/":
Elem = Help;
break;
default:
window.location.pathname = "/";
}
if (Elem) {
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Elem />
</React.StrictMode>,
)
}

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
entryFileNames: 'index.js',
assetFileNames: 'index.css',
},
},
},
})