mirror of
https://github.com/agersant/polaris
synced 2024-12-03 18:19:09 +00:00
Changed LastFM auth flow from application flow to web-flow
This commit is contained in:
parent
c52ec3d30c
commit
2092813258
11 changed files with 191 additions and 48 deletions
28
Cargo.lock
generated
28
Cargo.lock
generated
|
@ -802,6 +802,11 @@ name = "matches"
|
|||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "0.1.11"
|
||||
|
@ -1193,6 +1198,7 @@ dependencies = [
|
|||
"iron 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"lewton 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"md5 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"metaflac 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"mount 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"mp3-duration 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
|
@ -1206,6 +1212,7 @@ dependencies = [
|
|||
"rustfm-scrobble 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"secure-session 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde-xml-rs 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_derive 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"simplelog 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
|
@ -1500,6 +1507,16 @@ name = "serde"
|
|||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "serde-xml-rs"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"xml-rs 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.24"
|
||||
|
@ -1927,6 +1944,14 @@ name = "xdg"
|
|||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[metadata]
|
||||
"checksum adler32 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6cbd0b9af8587c72beadc9f72d35b9fbb070982c9e6203e46e93f10df25f8f45"
|
||||
"checksum advapi32-sys 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e06588080cb19d0acb6739808aafa5f26bfb2ca015b2b6370028b44cf7cb8a9a"
|
||||
|
@ -2025,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
"checksum lru-cache 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4d06ff7ff06f729ce5f4e227876cb88d10bc59cd4ae1e09fbb2bde15c850dc21"
|
||||
"checksum lzw 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084"
|
||||
"checksum matches 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "100aabe6b8ff4e4a7e32c1c13523379802df0772b82466207ac25b013f193376"
|
||||
"checksum md5 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "995999bcecec06dff8499bfafab45119c1d33a57d75a705b429d6d49f38c2f40"
|
||||
"checksum memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20"
|
||||
"checksum memchr 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "796fba70e76612589ed2ce7f45282f5af869e0fdd7cc6199fa1aa1f1d591ba9d"
|
||||
"checksum metaflac 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "1839a57e30c651fb9647d1c140dcda407282a2228cddb25a21c1708645621219"
|
||||
|
@ -2096,6 +2122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
"checksum sequence_trie 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c915714ca833b1d4d6b8f6a9d72a3ff632fe45b40a8d184ef79c81bec6327eed"
|
||||
"checksum serde 0.8.23 (registry+https://github.com/rust-lang/crates.io-index)" = "9dad3f759919b92c3068c696c15c3d17238234498bbdcc80f2c469606f948ac8"
|
||||
"checksum serde 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)" = "1c57ab4ec5fa85d08aaf8ed9245899d9bbdd66768945b21113b84d5f595cb6a1"
|
||||
"checksum serde-xml-rs 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "0c06881f4313eec67d4ecfcd8e14339f6042cfc0de4b1bd3ceae74c29d597f68"
|
||||
"checksum serde_derive 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)" = "02c92ea07b6e49b959c1481804ebc9bfd92d3c459f1274c9a9546829e42a66ce"
|
||||
"checksum serde_derive_internals 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)" = "75c6aac7b99801a16db5b40b7bf0d7e4ba16e76fbf231e32a4677f271cac0603"
|
||||
"checksum serde_json 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)" = "67f7d2e9edc3523a9c8ec8cd6ec481b3a27810aafee3e625d311febd3e656b4c"
|
||||
|
@ -2150,3 +2177,4 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
"checksum wrapped-vec 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "06c29bb4abe93d1c8ef79b60f270d0efcaa6c5c97aaaaaaa0d477ea72f5f9e45"
|
||||
"checksum ws2_32-sys 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e"
|
||||
"checksum xdg 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a66b7c2281ebde13cf4391d70d4c7e5946c3c25e72a7b859ca8f677dcd0b0c61"
|
||||
"checksum xml-rs 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "7ec6c39eaa68382c8e31e35239402c0a9489d4141a8ceb0c716099a0b515b562"
|
||||
|
|
|
@ -20,6 +20,7 @@ image = "0.15.0"
|
|||
iron = "0.5.1"
|
||||
rustfm-scrobble = "0.9.1"
|
||||
lewton = "0.6.2"
|
||||
md5 = "0.4.0"
|
||||
metaflac = "0.1.8"
|
||||
mount = "0.3.0"
|
||||
mp3-duration = "0.1.0"
|
||||
|
@ -33,6 +34,7 @@ secure-session = "0.2.0"
|
|||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde-xml-rs = "0.2.1"
|
||||
staticfile = "0.4.0"
|
||||
toml = "0.4.5"
|
||||
typemap = "0.3"
|
||||
|
|
22
src/api.rs
22
src/api.rs
|
@ -204,6 +204,12 @@ fn get_endpoints(db: Arc<DB>, index_channel: Arc<Mutex<Sender<index::Command>>>)
|
|||
|
||||
auth_api_mount.mount("/playlist/", playlist_router);
|
||||
}
|
||||
{
|
||||
let db = db.clone();
|
||||
auth_api_mount.mount("/lastfm/auth/", move |request: &mut Request| {
|
||||
self::lastfm_auth(request, db.deref())
|
||||
});
|
||||
}
|
||||
{
|
||||
let db = db.clone();
|
||||
auth_api_mount.mount("/lastfm/now_playing/", move |request: &mut Request| {
|
||||
|
@ -698,6 +704,22 @@ fn delete_playlist(request: &mut Request, db: &DB) -> IronResult<Response> {
|
|||
Ok(Response::with(status::Ok))
|
||||
}
|
||||
|
||||
fn lastfm_auth(request: &mut Request, db: &DB) -> IronResult<Response> {
|
||||
let input = request.get_ref::<params::Params>().unwrap();
|
||||
let username = match input.find(&["username"]) {
|
||||
Some(¶ms::Value::String(ref username)) => username.clone(),
|
||||
_ => return Err(Error::from(ErrorKind::MissingUsername).into()),
|
||||
};
|
||||
let token = match input.find(&["token"]) {
|
||||
Some(¶ms::Value::String(ref token)) => token.clone(),
|
||||
_ => return Err(Error::from(ErrorKind::MissingPassword).into()),
|
||||
};
|
||||
|
||||
lastfm::auth(db, &username, &token)?;
|
||||
|
||||
Ok(Response::with(status::Ok))
|
||||
}
|
||||
|
||||
fn lastfm_now_playing(request: &mut Request, db: &DB) -> IronResult<Response> {
|
||||
let username = match request.extensions.get::<SessionKey>() {
|
||||
Some(s) => s.username.clone(),
|
||||
|
|
|
@ -26,10 +26,7 @@ pub struct MiscSettings {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preferences {
|
||||
pub lastfm_username: Option<String>,
|
||||
pub lastfm_password: Option<String>,
|
||||
}
|
||||
pub struct Preferences {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
|
@ -256,31 +253,15 @@ pub fn amend<T>(db: &T, new_config: &Config) -> Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_preferences<T>(db: &T, username: &str) -> Result<Preferences>
|
||||
pub fn read_preferences<T>(_: &T, _: &str) -> Result<Preferences>
|
||||
where T: ConnectionSource
|
||||
{
|
||||
use self::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
let (read_lastfm_username, read_lastfm_password) = users
|
||||
.select((lastfm_username, lastfm_password))
|
||||
.filter(name.eq(username))
|
||||
.get_result(connection.deref())?;
|
||||
Ok(Preferences {
|
||||
lastfm_username: read_lastfm_username,
|
||||
lastfm_password: read_lastfm_password,
|
||||
})
|
||||
Ok(Preferences {})
|
||||
}
|
||||
|
||||
pub fn write_preferences<T>(db: &T, username: &str, preferences: &Preferences) -> Result<()>
|
||||
pub fn write_preferences<T>(_: &T, _: &str, _: &Preferences) -> Result<()>
|
||||
where T: ConnectionSource
|
||||
{
|
||||
use self::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
diesel::update(users)
|
||||
.set((lastfm_username.eq(&preferences.lastfm_username),
|
||||
lastfm_password.eq(&preferences.lastfm_password)))
|
||||
.filter(name.eq(username))
|
||||
.execute(connection.deref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -490,14 +471,7 @@ fn test_preferences_read_write() {
|
|||
};
|
||||
amend(&db, &initial_config).unwrap();
|
||||
|
||||
let old_preferences = read_preferences(&db, "Teddy🐻").unwrap();
|
||||
assert_eq!(old_preferences.lastfm_username, None);
|
||||
assert_eq!(old_preferences.lastfm_password, None);
|
||||
|
||||
let new_preferences = Preferences {
|
||||
lastfm_username: Some("🐻FM".into()),
|
||||
lastfm_password: Some("Secret🐻Secret".into()),
|
||||
};
|
||||
let new_preferences = Preferences {};
|
||||
write_preferences(&db, "Teddy🐻", &new_preferences).unwrap();
|
||||
|
||||
let read_preferences = read_preferences(&db, "Teddy🐻").unwrap();
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
ALTER TABLE users ADD COLUMN lastfm_username TEXT;
|
||||
ALTER TABLE users ADD COLUMN lastfm_password TEXT;
|
||||
ALTER TABLE users ADD COLUMN lastfm_session_key TEXT;
|
||||
|
|
Binary file not shown.
|
@ -54,6 +54,8 @@ error_chain! {
|
|||
MissingPlaylistName {}
|
||||
EncodingError {}
|
||||
MissingLastFMCredentials {}
|
||||
LastFMAuthError {}
|
||||
LastFMDeserializationError {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -768,7 +768,7 @@ fn test_recent() {
|
|||
|
||||
#[test]
|
||||
fn test_get_song() {
|
||||
let db = db::_get_test_db("recent.sqlite");
|
||||
let db = db::_get_test_db("get_song.sqlite");
|
||||
update(&db).unwrap();
|
||||
|
||||
let mut song_path = PathBuf::new();
|
||||
|
|
122
src/lastfm.rs
122
src/lastfm.rs
|
@ -1,16 +1,53 @@
|
|||
use md5;
|
||||
use reqwest;
|
||||
use rustfm_scrobble::{Scrobbler, Scrobble};
|
||||
use serde_xml_rs::deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use db::ConnectionSource;
|
||||
use errors::*;
|
||||
use errors;
|
||||
use index;
|
||||
use user;
|
||||
use vfs::VFSSource;
|
||||
|
||||
const LASTFM_API_KEY: &str = "02b96c939a2b451c31dfd67add1f696e";
|
||||
const LASTFM_API_SECRET: &str = "0f25a80ceef4b470b5cb97d99d4b3420";
|
||||
const LASTFM_API_ROOT: &str = "http://ws.audioscrobbler.com/2.0/";
|
||||
|
||||
fn scrobble_from_path<T>(db: &T, track: &Path) -> Result<Scrobble>
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionName {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionKey {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionSubscriber {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSession {
|
||||
pub name: AuthResponseSessionName,
|
||||
pub key: AuthResponseSessionKey,
|
||||
pub subscriber: AuthResponseSessionSubscriber,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponse {
|
||||
pub status: String,
|
||||
pub session: AuthResponseSession,
|
||||
}
|
||||
|
||||
fn scrobble_from_path<T>(db: &T, track: &Path) -> Result<Scrobble, errors::Error>
|
||||
where T: ConnectionSource + VFSSource
|
||||
{
|
||||
let song = index::get_song(db, track)?;
|
||||
|
@ -19,26 +56,91 @@ fn scrobble_from_path<T>(db: &T, track: &Path) -> Result<Scrobble>
|
|||
song.album.unwrap_or("".into())))
|
||||
}
|
||||
|
||||
pub fn scrobble<T>(db: &T, username: &str, track: &Path) -> Result<()>
|
||||
pub fn auth<T>(db: &T, username: &str, token: &str) -> Result<(), errors::Error>
|
||||
where T: ConnectionSource + VFSSource
|
||||
{
|
||||
let mut params = HashMap::new();
|
||||
params.insert("token".to_string(), token.to_string());
|
||||
params.insert("api_key".to_string(), LASTFM_API_KEY.to_string());
|
||||
let mut response = match api_request("auth.getSession", ¶ms) {
|
||||
Ok(r) => r,
|
||||
Err(_) => bail!(errors::ErrorKind::LastFMAuthError),
|
||||
};
|
||||
|
||||
let mut body = String::new();
|
||||
response.read_to_string(&mut body)?;
|
||||
if !response.status().is_success() {
|
||||
bail!(errors::ErrorKind::LastFMAuthError)
|
||||
}
|
||||
|
||||
let auth_response: AuthResponse = match deserialize(body.as_bytes()) {
|
||||
Ok(d) => d,
|
||||
Err(_) => bail!(errors::ErrorKind::LastFMDeserializationError)
|
||||
};
|
||||
|
||||
user::set_lastfm_session_key(db, username, &auth_response.session.key.body)
|
||||
}
|
||||
|
||||
pub fn scrobble<T>(db: &T, username: &str, track: &Path) -> Result<(), errors::Error>
|
||||
where T: ConnectionSource + VFSSource
|
||||
{
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let scrobble = scrobble_from_path(db, track)?;
|
||||
let (lastfm_username, lastfm_password) = user::get_lastfm_credentials(db, username)?;
|
||||
scrobbler
|
||||
.authenticate_with_password(lastfm_username, lastfm_password)?;
|
||||
let auth_token = user::get_lastfm_session_key(db, username)?;
|
||||
scrobbler.authenticate_with_session_key(auth_token);
|
||||
scrobbler.scrobble(scrobble)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn now_playing<T>(db: &T, username: &str, track: &Path) -> Result<()>
|
||||
pub fn now_playing<T>(db: &T, username: &str, track: &Path) -> Result<(), errors::Error>
|
||||
where T: ConnectionSource + VFSSource
|
||||
{
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let scrobble = scrobble_from_path(db, track)?;
|
||||
let (lastfm_username, lastfm_password) = user::get_lastfm_credentials(db, username)?;
|
||||
scrobbler
|
||||
.authenticate_with_password(lastfm_username, lastfm_password)?;
|
||||
let auth_token = user::get_lastfm_session_key(db, username)?;
|
||||
scrobbler.authenticate_with_session_key(auth_token);
|
||||
scrobbler.now_playing(scrobble)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_request(method: &str, params: &HashMap<String, String>) -> Result<reqwest::Response, reqwest::Error>
|
||||
{
|
||||
let mut url = LASTFM_API_ROOT.to_string();
|
||||
url.push_str("?");
|
||||
|
||||
url.push_str(&format!("method={}&", method));
|
||||
for (k, v) in params.iter()
|
||||
{
|
||||
url.push_str(&format!("{}={}&", k, v));
|
||||
}
|
||||
let api_signature = get_signature(method, params);
|
||||
url.push_str(&format!("api_sig={}", api_signature));
|
||||
|
||||
let client = reqwest::Client::new()?;
|
||||
let request = client.get(url.as_str());
|
||||
request.send()
|
||||
}
|
||||
|
||||
fn get_signature(method: &str, params: &HashMap<String, String>) -> String
|
||||
{
|
||||
let mut signature_data = params.clone();
|
||||
signature_data.insert("method".to_string(), method.to_string());
|
||||
|
||||
let mut param_names = Vec::new();
|
||||
for param_name in signature_data.keys()
|
||||
{
|
||||
param_names.push(param_name);
|
||||
}
|
||||
param_names.sort();
|
||||
|
||||
let mut signature = String::new();
|
||||
for param_name in param_names
|
||||
{
|
||||
signature.push_str((param_name.to_string() + signature_data[param_name].as_str()).as_str())
|
||||
}
|
||||
|
||||
signature.push_str(LASTFM_API_SECRET);
|
||||
|
||||
let digest = md5::compute(signature.as_bytes());
|
||||
format!("{:X}", digest)
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ extern crate id3;
|
|||
extern crate image;
|
||||
extern crate iron;
|
||||
extern crate lewton;
|
||||
extern crate md5;
|
||||
extern crate metaflac;
|
||||
extern crate mount;
|
||||
extern crate mp3_duration;
|
||||
|
@ -32,6 +33,7 @@ extern crate serde;
|
|||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
extern crate serde_json;
|
||||
extern crate serde_xml_rs;
|
||||
extern crate staticfile;
|
||||
extern crate toml;
|
||||
extern crate typemap;
|
||||
|
|
21
src/user.rs
21
src/user.rs
|
@ -93,17 +93,28 @@ pub fn is_admin<T>(db: &T, username: &str) -> Result<bool>
|
|||
Ok(is_admin != 0)
|
||||
}
|
||||
|
||||
pub fn get_lastfm_credentials<T>(db: &T, username: &str) -> Result<(String, String)>
|
||||
pub fn set_lastfm_session_key<T>(db: &T, username: &str, token: &str) -> Result<()>
|
||||
where T: ConnectionSource
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
let credentials = users
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set(lastfm_session_key.eq(token))
|
||||
.execute(connection.deref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_lastfm_session_key<T>(db: &T, username: &str) -> Result<String>
|
||||
where T: ConnectionSource
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
let token = users
|
||||
.filter(name.eq(username))
|
||||
.select((lastfm_username, lastfm_password))
|
||||
.select(lastfm_session_key)
|
||||
.get_result(connection.deref())?;
|
||||
match credentials {
|
||||
(Some(u), Some(p)) => Ok((u, p)),
|
||||
match token {
|
||||
Some(t) => Ok(t),
|
||||
_ => bail!(ErrorKind::MissingLastFMCredentials),
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue