mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 06:23:37 -06:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 474d519b9d | |||
| fb27bdc119 |
@@ -27,8 +27,28 @@ check *flags:
|
|||||||
console.log("\x1b[1;36m→ Verifying...\x1b[0m");
|
console.log("\x1b[1;36m→ Verifying...\x1b[0m");
|
||||||
}
|
}
|
||||||
|
|
||||||
const checks = [
|
// Domain groups: format check name → { peers (other checks), formatter, sanity re-check }
|
||||||
|
const domains = {
|
||||||
|
rustfmt: {
|
||||||
|
peers: ["clippy", "rust-test"],
|
||||||
|
format: () => run(["cargo", "fmt", "--all"]),
|
||||||
|
recheck: [
|
||||||
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
|
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
|
||||||
|
{ name: "cargo-check", cmd: ["cargo", "check", "--all-features"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
biome: {
|
||||||
|
peers: ["svelte-check", "web-test"],
|
||||||
|
format: () => run(["bun", "run", "--cwd", "web", "format"]),
|
||||||
|
recheck: [
|
||||||
|
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
|
||||||
|
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"], terse: true },
|
||||||
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
|
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
|
||||||
{ name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] },
|
{ name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] },
|
||||||
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
|
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
|
||||||
@@ -60,14 +80,64 @@ check *flags:
|
|||||||
process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`);
|
process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`);
|
||||||
}, 100) : null;
|
}, 100) : null;
|
||||||
|
|
||||||
|
// Phase 1: collect all results, eagerly displaying whichever finishes first
|
||||||
|
const results = {};
|
||||||
let anyFailed = false;
|
let anyFailed = false;
|
||||||
for (const promise of promises) {
|
const tagged = promises.map((p, i) => p.then(r => ({ i, r })));
|
||||||
const r = await promise;
|
for (let n = 0; n < checks.length; n++) {
|
||||||
|
const { i, r } = await Promise.race(tagged);
|
||||||
|
tagged[i] = new Promise(() => {}); // sentinel: never resolves
|
||||||
|
results[r.name] = r;
|
||||||
remaining.delete(r.name);
|
remaining.delete(r.name);
|
||||||
if (isTTY) process.stderr.write(`\r\x1b[K`);
|
if (isTTY) process.stderr.write(`\r\x1b[K`);
|
||||||
if (r.exitCode !== 0) {
|
if (r.exitCode !== 0) {
|
||||||
anyFailed = true;
|
anyFailed = true;
|
||||||
process.stdout.write(`\x1b[31m✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
|
process.stdout.write(`\x1b[31m✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
|
||||||
|
if (!r.terse) {
|
||||||
|
if (r.stdout) process.stdout.write(r.stdout);
|
||||||
|
if (r.stderr) process.stderr.write(r.stderr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`\x1b[32m✓ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
if (isTTY) process.stderr.write(`\r\x1b[K`);
|
||||||
|
|
||||||
|
// Phase 2: auto-fix formatting if it's the only failure in a domain
|
||||||
|
let autoFixed = false;
|
||||||
|
for (const [fmtName, domain] of Object.entries(domains)) {
|
||||||
|
const fmtResult = results[fmtName];
|
||||||
|
if (!fmtResult || fmtResult.exitCode === 0) continue;
|
||||||
|
const peersAllPassed = domain.peers.every(p => results[p]?.exitCode === 0);
|
||||||
|
if (!peersAllPassed) continue;
|
||||||
|
|
||||||
|
process.stdout.write(`\n\x1b[1;36m→ Auto-formatting ${fmtName} (peers passed, only formatting failed)...\x1b[0m\n`);
|
||||||
|
domain.format();
|
||||||
|
|
||||||
|
// Re-verify format + sanity check in parallel
|
||||||
|
const recheckStart = Date.now();
|
||||||
|
const recheckPromises = domain.recheck.map(async (check) => {
|
||||||
|
const proc = Bun.spawn(check.cmd, {
|
||||||
|
env: { ...process.env, FORCE_COLOR: "1" },
|
||||||
|
stdout: "pipe", stderr: "pipe",
|
||||||
|
});
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
]);
|
||||||
|
await proc.exited;
|
||||||
|
return { ...check, stdout, stderr, exitCode: proc.exitCode,
|
||||||
|
elapsed: ((Date.now() - recheckStart) / 1000).toFixed(1) };
|
||||||
|
});
|
||||||
|
|
||||||
|
let recheckFailed = false;
|
||||||
|
for (const p of recheckPromises) {
|
||||||
|
const r = await p;
|
||||||
|
if (r.exitCode !== 0) {
|
||||||
|
recheckFailed = true;
|
||||||
|
process.stdout.write(`\x1b[31m ✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
|
||||||
if (r.stdout) process.stdout.write(r.stdout);
|
if (r.stdout) process.stdout.write(r.stdout);
|
||||||
if (r.stderr) process.stderr.write(r.stderr);
|
if (r.stderr) process.stderr.write(r.stderr);
|
||||||
} else {
|
} else {
|
||||||
@@ -75,9 +145,20 @@ check *flags:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interval) clearInterval(interval);
|
if (!recheckFailed) {
|
||||||
if (isTTY) process.stderr.write(`\r\x1b[K`);
|
process.stdout.write(`\x1b[32m ✓ ${fmtName} auto-fix succeeded\x1b[0m\n`);
|
||||||
process.exit(anyFailed ? 1 : 0);
|
results[fmtName].exitCode = 0;
|
||||||
|
autoFixed = true;
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`\x1b[31m ✗ ${fmtName} auto-fix failed sanity check\x1b[0m\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalFailed = Object.values(results).some(r => r.exitCode !== 0);
|
||||||
|
if (autoFixed && !finalFailed) {
|
||||||
|
process.stdout.write(`\n\x1b[1;32m✓ All checks passed (formatting was auto-fixed)\x1b[0m\n`);
|
||||||
|
}
|
||||||
|
process.exit(finalFailed ? 1 : 0);
|
||||||
|
|
||||||
# Format all Rust and TypeScript code
|
# Format all Rust and TypeScript code
|
||||||
format:
|
format:
|
||||||
|
|||||||
+13
-3
@@ -7,6 +7,9 @@ use sqlx::PgPool;
|
|||||||
use super::models::UserSession;
|
use super::models::UserSession;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Session lifetime: 7 days (in seconds).
|
||||||
|
pub const SESSION_DURATION_SECS: u64 = 7 * 24 * 3600;
|
||||||
|
|
||||||
/// Generate a cryptographically random 32-byte hex token.
|
/// Generate a cryptographically random 32-byte hex token.
|
||||||
fn generate_token() -> String {
|
fn generate_token() -> String {
|
||||||
let bytes: [u8; 32] = rand::rng().random();
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
@@ -48,10 +51,18 @@ pub async fn get_session(pool: &PgPool, token: &str) -> Result<Option<UserSessio
|
|||||||
.context("failed to get session")
|
.context("failed to get session")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the last-active timestamp for a session.
|
/// Update the last-active timestamp and extend session expiry (sliding window).
|
||||||
pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> {
|
pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> {
|
||||||
sqlx::query("UPDATE user_sessions SET last_active_at = now() WHERE id = $1")
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE user_sessions
|
||||||
|
SET last_active_at = now(),
|
||||||
|
expires_at = now() + make_interval(secs => $2::double precision)
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
|
.bind(SESSION_DURATION_SECS as f64)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.context("failed to touch session")?;
|
.context("failed to touch session")?;
|
||||||
@@ -80,7 +91,6 @@ pub async fn delete_user_sessions(pool: &PgPool, user_id: i64) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete all expired sessions. Returns the number of sessions cleaned up.
|
/// Delete all expired sessions. Returns the number of sessions cleaned up.
|
||||||
#[allow(dead_code)] // Called by SessionCache::cleanup_expired (not yet wired to periodic task)
|
|
||||||
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
|
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
|
||||||
let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()")
|
let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -51,6 +51,33 @@ impl WebService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Periodically cleans up expired sessions from the database and in-memory cache.
|
||||||
|
async fn session_cleanup_loop(state: AppState, mut shutdown_rx: broadcast::Receiver<()>) {
|
||||||
|
use std::time::Duration;
|
||||||
|
// Run every hour
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(3600));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {
|
||||||
|
match state.session_cache.cleanup_expired().await {
|
||||||
|
Ok(deleted) => {
|
||||||
|
if deleted > 0 {
|
||||||
|
info!(deleted, "cleaned up expired sessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "session cleanup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -87,6 +114,13 @@ impl Service for WebService {
|
|||||||
Self::db_health_check_loop(health_state, health_shutdown_rx).await;
|
Self::db_health_check_loop(health_state, health_shutdown_rx).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn session cleanup task
|
||||||
|
let cleanup_state = self.app_state.clone();
|
||||||
|
let cleanup_shutdown_rx = shutdown_tx.subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
Self::session_cleanup_loop(cleanup_state, cleanup_shutdown_rx).await;
|
||||||
|
});
|
||||||
|
|
||||||
// Use axum's graceful shutdown with the internal shutdown signal
|
// Use axum's graceful shutdown with the internal shutdown signal
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(async move {
|
.with_graceful_shutdown(async move {
|
||||||
|
|||||||
+6
-2
@@ -235,7 +235,7 @@ pub async fn auth_callback(
|
|||||||
let session = crate::data::sessions::create_session(
|
let session = crate::data::sessions::create_session(
|
||||||
&state.db_pool,
|
&state.db_pool,
|
||||||
discord_id,
|
discord_id,
|
||||||
Duration::from_secs(7 * 24 * 3600),
|
Duration::from_secs(crate::data::sessions::SESSION_DURATION_SECS),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -248,7 +248,11 @@ pub async fn auth_callback(
|
|||||||
|
|
||||||
// 6. Build response with session cookie
|
// 6. Build response with session cookie
|
||||||
let secure = redirect_uri.starts_with("https://");
|
let secure = redirect_uri.starts_with("https://");
|
||||||
let cookie = session_cookie(&session.id, 604800, secure);
|
let cookie = session_cookie(
|
||||||
|
&session.id,
|
||||||
|
crate::data::sessions::SESSION_DURATION_SECS as i64,
|
||||||
|
secure,
|
||||||
|
);
|
||||||
|
|
||||||
let redirect_to = if user.is_admin { "/admin" } else { "/" };
|
let redirect_to = if user.is_admin { "/admin" } else { "/" };
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
@@ -141,9 +141,8 @@ struct ScheduleRow {
|
|||||||
async fn load_snapshot(pool: &PgPool) -> anyhow::Result<ScheduleSnapshot> {
|
async fn load_snapshot(pool: &PgPool) -> anyhow::Result<ScheduleSnapshot> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let rows: Vec<ScheduleRow> = sqlx::query_as(
|
let rows: Vec<ScheduleRow> =
|
||||||
"SELECT subject, enrollment, meeting_times FROM courses",
|
sqlx::query_as("SELECT subject, enrollment, meeting_times FROM courses")
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ impl SessionCache {
|
|||||||
/// Delete expired sessions from the database and sweep the in-memory cache.
|
/// Delete expired sessions from the database and sweep the in-memory cache.
|
||||||
///
|
///
|
||||||
/// Returns the number of sessions deleted from the database.
|
/// Returns the number of sessions deleted from the database.
|
||||||
#[allow(dead_code)] // Intended for periodic cleanup task (not yet wired)
|
|
||||||
pub async fn cleanup_expired(&self) -> anyhow::Result<u64> {
|
pub async fn cleanup_expired(&self) -> anyhow::Result<u64> {
|
||||||
let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?;
|
let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?;
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -233,9 +233,8 @@ pub(crate) async fn timeline(
|
|||||||
s.active_during(local_date, wday_bit, slot_start_minutes, slot_end_minutes)
|
s.active_during(local_date, wday_bit, slot_start_minutes, slot_end_minutes)
|
||||||
});
|
});
|
||||||
if active {
|
if active {
|
||||||
*subject_totals
|
*subject_totals.entry(course.subject.clone()).or_default() +=
|
||||||
.entry(course.subject.clone())
|
course.enrollment as i64;
|
||||||
.or_default() += course.enrollment as i64;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authStore } from "$lib/auth.svelte";
|
||||||
import type {
|
import type {
|
||||||
CandidateResponse,
|
CandidateResponse,
|
||||||
CodeDescription,
|
CodeDescription,
|
||||||
@@ -212,6 +213,10 @@ export class BannerApiClient {
|
|||||||
|
|
||||||
const response = await this.fetchFn(...args);
|
const response = await this.fetchFn(...args);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
authStore.handleUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
@@ -229,6 +234,10 @@ export class BannerApiClient {
|
|||||||
|
|
||||||
const response = await this.fetchFn(...args);
|
const response = await this.fetchFn(...args);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
authStore.handleUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Idempotently mark the session as lost. Called by apiFetch on 401. */
|
||||||
|
handleUnauthorized() {
|
||||||
|
if (this.state.mode !== "unauthenticated") {
|
||||||
|
this.state = { mode: "unauthenticated" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
login() {
|
login() {
|
||||||
window.location.href = "/api/auth/login";
|
window.location.href = "/api/auth/login";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ const staticTabs = [
|
|||||||
|
|
||||||
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
|
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
|
||||||
|
|
||||||
let profileTab = $derived({
|
let profileTab = $derived(
|
||||||
href: authStore.isAuthenticated ? "/profile" : "/login",
|
authStore.isLoading
|
||||||
|
? { href: "/login" as const, label: null, icon: User }
|
||||||
|
: {
|
||||||
|
href: authStore.isAuthenticated ? ("/profile" as const) : ("/login" as const),
|
||||||
label: authStore.isAuthenticated ? "Account" : "Login",
|
label: authStore.isAuthenticated ? "Account" : "Login",
|
||||||
icon: User,
|
icon: User,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function isActive(tabHref: string): boolean {
|
function isActive(tabHref: string): boolean {
|
||||||
if (tabHref === "/") return page.url.pathname === "/";
|
if (tabHref === "/") return page.url.pathname === "/";
|
||||||
@@ -50,7 +54,7 @@ function isActive(tabHref: string): boolean {
|
|||||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
|
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
|
||||||
>
|
>
|
||||||
<User size={15} strokeWidth={2} />
|
<User size={15} strokeWidth={2} />
|
||||||
{profileTab.label}
|
{#if profileTab.label}{profileTab.label}{/if}
|
||||||
</a>
|
</a>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { onMount, tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -41,12 +41,6 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (authStore.isLoading) {
|
|
||||||
await authStore.init();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (authStore.state.mode === "unauthenticated") {
|
if (authStore.state.mode === "unauthenticated") {
|
||||||
goto("/login");
|
goto("/login");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import NavBar from "$lib/components/NavBar.svelte";
|
|||||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
||||||
import { initNavigation } from "$lib/stores/navigation.svelte";
|
import { initNavigation } from "$lib/stores/navigation.svelte";
|
||||||
import { themeStore } from "$lib/stores/theme.svelte";
|
import { themeStore } from "$lib/stores/theme.svelte";
|
||||||
|
import { authStore } from "$lib/auth.svelte";
|
||||||
import { Tooltip } from "bits-ui";
|
import { Tooltip } from "bits-ui";
|
||||||
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
|
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -34,6 +35,7 @@ useOverlayScrollbars(() => document.body, {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
themeStore.init();
|
themeStore.init();
|
||||||
|
authStore.init();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user