Compare commits
2
Commits
v1.8.1-alpha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972dd103cb | ||
|
|
12fff9f6ad |
No files matched your search
@@ -122,6 +122,9 @@ jobs:
|
||||
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
|
||||
ls -la "SoulStream-Setup-$VERSION.exe"
|
||||
|
||||
# Which of the files on the page a tester runs, said on the page: the
|
||||
# bare NSIS installer reads like the download and installs with no key
|
||||
# gate and no signature check.
|
||||
- name: Publish SoulStream Setup
|
||||
shell: bash
|
||||
env:
|
||||
@@ -129,4 +132,20 @@ jobs:
|
||||
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
|
||||
TAG: nightly
|
||||
VERSION: ${{ steps.stamp.outputs.version }}
|
||||
run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
{
|
||||
echo "### Download \`SoulStream-Setup-$VERSION.exe\`"
|
||||
echo
|
||||
echo "It asks for your alpha key, fetches this build with it, checks the signature and installs — no admin prompt."
|
||||
echo
|
||||
echo "The other files are the ones Setup uses:"
|
||||
echo
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check."
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature."
|
||||
echo "- \`latest.json\` — the manifest the app's Nightly channel reads."
|
||||
echo
|
||||
echo "Built from main at $SHA. Nightlies can break."
|
||||
} > notes.md
|
||||
gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md
|
||||
@@ -105,6 +105,10 @@ jobs:
|
||||
cp target/release/SoulStream-Setup.exe "SoulStream-Setup-$VERSION.exe"
|
||||
ls -la "SoulStream-Setup-$VERSION.exe"
|
||||
|
||||
# Six files on the page and nothing saying which one a tester runs: the
|
||||
# NSIS installer is the biggest and reads like the download, and running
|
||||
# it puts the app on with no key gate and no signature check. The notes
|
||||
# go on last, once the file they point at is actually up.
|
||||
- name: Publish SoulStream Setup
|
||||
shell: bash
|
||||
env:
|
||||
@@ -112,4 +116,19 @@ jobs:
|
||||
TARGET: ${{ vars.RELEASES_OWNER || github.repository_owner }}/${{ vars.RELEASES_REPO || github.event.repository.name }}
|
||||
TAG: v${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
run: |
|
||||
gh release upload "$TAG" "SoulStream-Setup-$VERSION.exe" --repo "$TARGET" --clobber
|
||||
{
|
||||
echo "### Download \`SoulStream-Setup-$VERSION.exe\`"
|
||||
echo
|
||||
echo "It asks for your alpha key, fetches this release with it, checks the signature and installs — no admin prompt."
|
||||
echo
|
||||
echo "The other files are the ones Setup uses:"
|
||||
echo
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe\` — the bare installer Setup downloads. Running it yourself skips the key and the signature check."
|
||||
echo "- \`SoulStream_${VERSION}_x64-setup.exe.sig\` — its signature."
|
||||
echo "- \`latest.json\` — the manifest the in-app updater reads."
|
||||
echo
|
||||
echo "See the commits since the last release."
|
||||
} > notes.md
|
||||
gh release edit "$TAG" --repo "$TARGET" --notes-file notes.md
|
||||
Generated
+11
-11
@@ -160,7 +160,7 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"audio-formats",
|
||||
"base64 0.22.1",
|
||||
@@ -429,7 +429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "audio-formats"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
@@ -1722,7 +1722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freenet-adapter"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"freenet-stdlib",
|
||||
@@ -2896,7 +2896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "library"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"audio-formats",
|
||||
"log",
|
||||
@@ -3034,7 +3034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metadata"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"httpdate",
|
||||
@@ -3701,7 +3701,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "persona-state"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"blake3",
|
||||
@@ -3799,7 +3799,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "player"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"audio-formats",
|
||||
"env_logger",
|
||||
@@ -4960,7 +4960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soulseek-client"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"audio-formats",
|
||||
"bytes",
|
||||
@@ -4975,7 +4975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soulseek-proto"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"flate2",
|
||||
@@ -4985,7 +4985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soulstream-setup"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"dirs 5.0.1",
|
||||
@@ -5392,7 +5392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tagger"
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
dependencies = [
|
||||
"lofty",
|
||||
"log",
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.8.1-alpha"
|
||||
version = "1.8.2-alpha"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
authors = ["Trifecta Solutions"]
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "soulstream",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "soulstream",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soulstream",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"type": "module",
|
||||
"description": "SoulStream — music player, downloader and streamer for the Soulseek network",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,17 @@ rust-version.workspace = true
|
||||
name = "SoulStream-Setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Tauri serves the frontend it embedded at compile time only with this on;
|
||||
# without it the window loads `devUrl` instead and a tester gets a blank
|
||||
# window saying "localhost refused to connect". The workflows build this
|
||||
# crate with plain `cargo build` — no Tauri CLI to turn it on for them — so
|
||||
# it is a DEFAULT feature here: every build that does not say otherwise is a
|
||||
# shippable one. `--no-default-features` is the build that talks to the vite
|
||||
# dev server on 1425.
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -4,9 +4,21 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
// The frontend is embedded only when tauri's `custom-protocol` feature is
|
||||
// on; without it the window points at devUrl and the exe we hand a tester
|
||||
// shows "localhost refused to connect". It is a default feature, so this
|
||||
// only fires when something turned the defaults off — and it fires at
|
||||
// build time rather than on the tester's machine.
|
||||
if tauri_build::is_dev() && std::env::var("PROFILE").as_deref() == Ok("release") {
|
||||
panic!("SoulStream Setup: a release build needs the `custom-protocol` feature (on by default); without it the window loads devUrl and is blank");
|
||||
}
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let conf = manifest.join("../../src-tauri/tauri.conf.json");
|
||||
println!("cargo:rerun-if-changed={}", conf.display());
|
||||
// tauri-build tracks the config, not the frontend the exe embeds: without
|
||||
// this, a changed Setup.svelte rebuilds `setup/dist` and cargo hands back
|
||||
// yesterday's binary, still carrying yesterday's window.
|
||||
println!("cargo:rerun-if-changed={}", manifest.join("../dist").display());
|
||||
let text = std::fs::read_to_string(&conf).expect("read src-tauri/tauri.conf.json");
|
||||
let json: serde_json::Value = serde_json::from_str(&text).expect("parse tauri.conf.json");
|
||||
let pubkey = json["plugins"]["updater"]["pubkey"]
|
||||
|
||||
+69
-10
@@ -26,6 +26,9 @@ use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const REPO: &str = "KeelrLeek/SoulStream";
|
||||
/// The rolling prerelease `.github/workflows/nightly.yml` replaces daily —
|
||||
/// never the one Setup installs.
|
||||
const NIGHTLY_TAG: &str = "nightly";
|
||||
const APP_DATA_DIRNAME: &str = "com.soulstream.app";
|
||||
const KEYRING_SERVICE: &str = "com.soulstream.app";
|
||||
const KEYRING_TOKEN_USER: &str = "updater-token";
|
||||
@@ -163,6 +166,44 @@ struct Release {
|
||||
sig_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a release lookup failed, and whether it was the KEY that GitHub
|
||||
/// refused. Only a refusal is a reason to throw the saved key away — a
|
||||
/// laptop between wifi networks is not, and the entry thrown away is the
|
||||
/// one the app reads for its own updates.
|
||||
struct Failure {
|
||||
message: String,
|
||||
key_refused: bool,
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn plain(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), key_refused: false }
|
||||
}
|
||||
fn refused(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), key_refused: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Failure> for String {
|
||||
fn from(f: Failure) -> Self {
|
||||
f.message
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare message is never a refusal — a release missing its installer says
|
||||
/// nothing about the key that read it.
|
||||
impl From<&str> for Failure {
|
||||
fn from(m: &str) -> Self {
|
||||
Failure::plain(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Failure {
|
||||
fn from(m: String) -> Self {
|
||||
Failure::plain(m)
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
@@ -171,23 +212,37 @@ fn client() -> Result<reqwest::Client, String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn fetch_release(token: &str) -> Result<Release, String> {
|
||||
async fn fetch_release(token: &str) -> Result<Release, Failure> {
|
||||
// Not `/releases/latest`: that endpoint answers with the newest FULL
|
||||
// release and every SoulStream release so far is a prerelease, so it
|
||||
// 404s and a perfectly good key reads back as a bad one. The list comes
|
||||
// newest first — take the first that is neither a draft nor the rolling
|
||||
// nightly, which is not what a tester is here to install.
|
||||
let resp = client()?
|
||||
.get(format!("https://api.github.com/repos/{REPO}/releases/latest"))
|
||||
.get(format!("https://api.github.com/repos/{REPO}/releases?per_page=30"))
|
||||
.bearer_auth(token)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Could not reach GitHub — is this computer online? ({e})"))?;
|
||||
.map_err(|e| Failure::plain(format!("Could not reach GitHub — is this computer online? ({e})")))?;
|
||||
match resp.status().as_u16() {
|
||||
200 => {}
|
||||
401 => return Err("That key was refused.".into()),
|
||||
403 => return Err("That key cannot read the SoulStream repository.".into()),
|
||||
404 => return Err("No release found — or that key cannot see the repository.".into()),
|
||||
s => return Err(format!("GitHub answered {s}.")),
|
||||
401 => return Err(Failure::refused("That key was refused.")),
|
||||
403 => return Err(Failure::refused("That key cannot read the SoulStream repository.")),
|
||||
// A private repository answers 404 to a key that cannot see it.
|
||||
404 => return Err(Failure::refused("No release found — or that key cannot see the repository.")),
|
||||
s => return Err(Failure::plain(format!("GitHub answered {s}."))),
|
||||
}
|
||||
let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let list: Vec<serde_json::Value> =
|
||||
resp.json().await.map_err(|e| Failure::plain(e.to_string()))?;
|
||||
// The key worked; there is simply nothing published yet. Not its fault.
|
||||
let v = list
|
||||
.into_iter()
|
||||
.find(|r| {
|
||||
r["draft"].as_bool() != Some(true) && r["tag_name"].as_str() != Some(NIGHTLY_TAG)
|
||||
})
|
||||
.ok_or_else(|| Failure::plain("That key works, but the repository has no release to install yet."))?;
|
||||
let tag = v["tag_name"].as_str().unwrap_or("");
|
||||
let assets = v["assets"].as_array().cloned().unwrap_or_default();
|
||||
let find = |suffix: &str| {
|
||||
@@ -339,12 +394,15 @@ async fn check_saved_key() -> Result<Option<Release>, String> {
|
||||
let Some(t) = saved_key() else { return Ok(None) };
|
||||
match fetch_release(&t).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
Err(e) if e.key_refused => {
|
||||
// The same Credential Manager entry the app reads for its own
|
||||
// updates, so it goes only when GitHub actually turned it down.
|
||||
if let Some(entry) = keyring_entry() {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
Err(format!("The key saved on this computer no longer works — {e}"))
|
||||
Err(format!("The key saved on this computer no longer works — {}", e.message))
|
||||
}
|
||||
Err(e) => Err(e.message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +529,7 @@ fn main() {
|
||||
quit
|
||||
])
|
||||
.run({
|
||||
#[cfg_attr(not(debug_assertions), allow(unused_mut))]
|
||||
let mut context = tauri::generate_context!();
|
||||
// Driving the window from a probe, debug builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SoulStream Setup",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"identifier": "com.soulstream.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npx vite --config vite.setup.config.js",
|
||||
|
||||
+55
-16
@@ -11,8 +11,12 @@
|
||||
//! private release cannot be read without a token. With a read-only token
|
||||
//! saved in the Credential Manager (Settings → Updates → Access token), a
|
||||
//! GitHub source is resolved through the API instead of the browser URLs:
|
||||
//! `GET /repos/{o}/{r}/releases/latest` (or `/tags/{tag}`) finds the
|
||||
//! `latest.json` asset, the plugin fetches THAT asset URL with
|
||||
//! `GET /repos/{o}/{r}/releases/tags/{tag}` for a pinned tag, and for
|
||||
//! `latest` a `GET /repos/{o}/{r}/releases` whose first entry that is
|
||||
//! neither a draft nor the rolling `nightly` wins. NOT `/releases/latest`:
|
||||
//! that answers only with full releases, and every release here is an
|
||||
//! alpha, so it 404s. Either way the `latest.json` asset is found, the
|
||||
//! plugin fetches THAT asset URL with
|
||||
//! `Authorization: Bearer` + `Accept: application/octet-stream`, and the
|
||||
//! manifest's own download URLs are API asset URLs too — the workflows
|
||||
//! rewrite them after publishing — so the installer downloads with the same
|
||||
@@ -37,6 +41,8 @@ pub const DEFAULT_STABLE: &str =
|
||||
"https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json";
|
||||
pub const DEFAULT_NIGHTLY: &str =
|
||||
"https://github.com/KeelrLeek/SoulStream/releases/download/nightly/latest.json";
|
||||
/// The tag `nightly.yml` rewrites daily. Never what `latest` should resolve to.
|
||||
const NIGHTLY_TAG: &str = "nightly";
|
||||
|
||||
/// First automatic check after launch, then the interval between checks.
|
||||
const FIRST_CHECK: Duration = Duration::from_secs(25);
|
||||
@@ -199,22 +205,15 @@ fn announce(app: &AppHandle) {
|
||||
let _ = app.emit("updater:status", status(app));
|
||||
}
|
||||
|
||||
/// Ask the GitHub API where a private release keeps its `latest.json`.
|
||||
async fn resolve_github_asset(
|
||||
src: &GitHubSource,
|
||||
/// One authenticated GitHub API GET, its failures already in words.
|
||||
async fn github_json(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
user_agent: &str,
|
||||
) -> Result<Url, String> {
|
||||
let api = format!(
|
||||
"https://api.github.com/repos/{}/{}/releases/{}",
|
||||
src.owner, src.repo, src.release
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.get(url)
|
||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.header(reqwest::header::USER_AGENT, user_agent)
|
||||
@@ -229,7 +228,47 @@ async fn resolve_github_asset(
|
||||
404 => return Err("no release found — or the token cannot see the repository".into()),
|
||||
s => return Err(format!("GitHub answered {s}")),
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Ask the GitHub API where a private release keeps its `latest.json`.
|
||||
async fn resolve_github_asset(
|
||||
src: &GitHubSource,
|
||||
token: &str,
|
||||
user_agent: &str,
|
||||
) -> Result<Url, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let repo = format!("https://api.github.com/repos/{}/{}", src.owner, src.repo);
|
||||
let body = if src.release == "latest" {
|
||||
// `/releases/latest` answers with the newest FULL release, and every
|
||||
// SoulStream release so far is a prerelease — so it 404s and Stable
|
||||
// reports the token cannot see the repository. The list comes newest
|
||||
// first: take the first that is neither a draft nor the rolling
|
||||
// nightly, which the Nightly channel asks for by tag instead.
|
||||
let list = github_json(&client, &format!("{repo}/releases?per_page=30"), token, user_agent)
|
||||
.await?;
|
||||
list.as_array()
|
||||
.and_then(|rs| {
|
||||
rs.iter()
|
||||
.find(|r| {
|
||||
r["draft"].as_bool() != Some(true)
|
||||
&& r["tag_name"].as_str() != Some(NIGHTLY_TAG)
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.ok_or("no release found — or the token cannot see the repository")?
|
||||
} else {
|
||||
github_json(
|
||||
&client,
|
||||
&format!("{repo}/releases/{}", src.release),
|
||||
token,
|
||||
user_agent,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let assets = body["assets"].as_array().cloned().unwrap_or_default();
|
||||
let asset = assets
|
||||
.iter()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "SoulStream",
|
||||
"version": "1.8.1-alpha",
|
||||
"version": "1.8.2-alpha",
|
||||
"identifier": "com.soulstream.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user