Skip to content

Commit 8560bd2

Browse files
feat: add unlisted visibility
Signed-off-by: Henry <mail@henrygressmann.de>
1 parent 1d7d33e commit 8560bd2

18 files changed

Lines changed: 129 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Since this is not a library, this changelog focuses on the changes that are rele
2323
- Added global and per-entity collection settings for visitor grouping, geolocation detail, session metrics, UTM parameters, and data retention
2424
- Added ingest drop rules to discard matching events before they are stored
2525
- Added project display settings to show or hide metrics and dimensions per project
26+
- Added unlisted project visibility
2627
- Added a pruning tool to apply retention and collection settings to historical data
2728

2829
### Bug Fixes

‎Cargo.lock‎

Lines changed: 8 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/app/core/entities.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,14 @@ impl LiwanEntities {
8282
pub fn projects(&self, entity_id: &str) -> Result<Vec<models::Project>> {
8383
let conn = self.pool.get()?;
8484
let mut stmt = conn.prepare_cached(
85-
"select p.id, p.display_name, p.public, p.secret from projects p join project_entities pe on p.id = pe.project_id where pe.entity_id = ?",
85+
"select p.id, p.display_name, p.public, p.unlisted, p.secret from projects p join project_entities pe on p.id = pe.project_id where pe.entity_id = ?",
8686
)?;
8787
let projects = stmt.query_map(rusqlite::params![entity_id], |row| {
8888
Ok(models::Project {
8989
id: row.get("id")?,
9090
display_name: row.get("display_name")?,
9191
public: row.get("public")?,
92+
unlisted: row.get("unlisted")?,
9293
secret: row.get("secret")?,
9394
})
9495
})?;

‎src/app/core/projects.rs‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,29 +51,30 @@ impl LiwanProjects {
5151
/// Get a project by ID
5252
pub fn get(&self, id: &str) -> Result<models::Project> {
5353
let conn = self.pool.get()?;
54-
let project = conn.prepare("select id, display_name, public, secret from projects where id = ?")?.query_row(
55-
rusqlite::params![id],
56-
|row| {
54+
let project = conn
55+
.prepare("select id, display_name, public, unlisted, secret from projects where id = ?")?
56+
.query_row(rusqlite::params![id], |row| {
5757
Ok(models::Project {
5858
id: row.get("id")?,
5959
display_name: row.get("display_name")?,
6060
public: row.get("public")?,
61+
unlisted: row.get("unlisted")?,
6162
secret: row.get("secret")?,
6263
})
63-
},
64-
)?;
64+
})?;
6565
Ok(project)
6666
}
6767

6868
/// Get all projects
6969
pub fn all(&self) -> Result<Vec<models::Project>> {
7070
let conn = self.pool.get()?;
71-
let mut stmt = conn.prepare("select id, display_name, public, secret from projects")?;
71+
let mut stmt = conn.prepare("select id, display_name, public, unlisted, secret from projects")?;
7272
let projects = stmt.query_map([], |row| {
7373
Ok(models::Project {
7474
id: row.get("id")?,
7575
display_name: row.get("display_name")?,
7676
public: row.get("public")?,
77+
unlisted: row.get("unlisted")?,
7778
secret: row.get("secret")?,
7879
})
7980
})?;
@@ -89,11 +90,12 @@ impl LiwanProjects {
8990
let mut conn = self.pool.get()?;
9091
let tx = conn.transaction()?;
9192
tx.execute(
92-
"insert into projects (id, display_name, public, secret) values (:id, :display_name, :public, :secret)",
93+
"insert into projects (id, display_name, public, unlisted, secret) values (:id, :display_name, :public, :unlisted, :secret)",
9394
rusqlite::named_params! {
9495
":id": project.id,
9596
":display_name": project.display_name,
9697
":public": project.public,
98+
":unlisted": project.unlisted,
9799
":secret": project.secret,
98100
},
99101
)?;
@@ -111,11 +113,12 @@ impl LiwanProjects {
111113
pub fn update(&self, project: &models::Project) -> Result<models::Project> {
112114
let conn = self.pool.get()?;
113115
let mut stmt = conn.prepare_cached(
114-
"update projects set display_name = :display_name, public = :public, secret = :secret where id = :id",
116+
"update projects set display_name = :display_name, public = :public, unlisted = :unlisted, secret = :secret where id = :id",
115117
)?;
116118
stmt.execute(rusqlite::named_params! {
117119
":display_name": project.display_name,
118120
":public": project.public,
121+
":unlisted": project.unlisted,
119122
":secret": project.secret,
120123
":id": project.id,
121124
})?;

‎src/app/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ impl Liwan {
199199
id: project_id.to_string(),
200200
display_name: display_name.to_string(),
201201
public,
202+
unlisted: false,
202203
secret: None,
203204
},
204205
&[],

‎src/app/models.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub struct Project {
3434
pub id: String,
3535
pub display_name: String,
3636
pub public: bool,
37+
pub unlisted: bool,
3738
pub secret: Option<String>, // currently unused
3839
}
3940

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
alter table projects add column unlisted boolean not null default false;

‎src/utils/validate.rs‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,43 +9,70 @@ pub fn is_valid_username(name: &str) -> bool {
99
name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && name.len() <= 64 && name.len() >= 3
1010
}
1111

12-
pub fn can_access_project(project: &Project, user: Option<&User>) -> bool {
12+
pub fn can_view_project(project: &Project, user: Option<&User>) -> bool {
1313
project.public || user.is_some_and(|u| u.role == UserRole::Admin || u.projects.contains(&project.id))
1414
}
1515

16+
pub fn can_enumerate_project(project: &Project, user: Option<&User>) -> bool {
17+
can_view_project(project, user)
18+
&& (!project.unlisted || user.is_some_and(|u| u.role == UserRole::Admin || u.projects.contains(&project.id)))
19+
}
20+
1621
#[cfg(test)]
1722
mod tests {
1823
use super::*;
1924
use crate::app::models::{Project, User};
2025

2126
#[test]
22-
fn test_can_access_project() {
27+
fn test_project_visibility() {
2328
let project = Project {
2429
id: "public_project".to_string(),
2530
display_name: "Public Project".to_string(),
2631
secret: None,
2732
public: true,
33+
unlisted: false,
2834
};
29-
assert!(can_access_project(&project, None), "Public project should be accessible without a user.");
35+
assert!(can_view_project(&project, None), "Public project should be accessible without a user.");
36+
assert!(can_enumerate_project(&project, None), "Public listed project should be enumerable.");
3037

3138
let user = User { username: "test".to_string(), role: UserRole::User, projects: vec!["other".to_string()] };
32-
assert!(can_access_project(&project, Some(&user)), "Public project should be accessible with any user.");
39+
assert!(can_view_project(&project, Some(&user)), "Public project should be accessible with any user.");
40+
41+
let project = Project { unlisted: true, ..project };
42+
assert!(can_view_project(&project, None), "Unlisted project should be accessible by direct link.");
43+
assert!(!can_enumerate_project(&project, None), "Unlisted project should not be enumerable anonymously.");
44+
assert!(
45+
!can_enumerate_project(&project, Some(&user)),
46+
"Unlisted project should not be enumerable by unrelated users."
47+
);
48+
49+
let assigned_user = User {
50+
username: "assigned".to_string(),
51+
role: UserRole::User,
52+
projects: vec!["public_project".to_string()],
53+
};
54+
assert!(
55+
can_enumerate_project(&project, Some(&assigned_user)),
56+
"Assigned users can enumerate unlisted projects."
57+
);
3358

3459
let project = Project {
3560
id: "admin_test_project".to_string(),
3661
display_name: "Admin Test Project".to_string(),
3762
secret: None,
3863
public: false,
64+
unlisted: false,
3965
};
4066
let admin_user = User { username: "admin".to_string(), role: UserRole::Admin, projects: vec![] };
41-
assert!(can_access_project(&project, Some(&admin_user)), "Admin should have access to any project.");
67+
assert!(can_view_project(&project, Some(&admin_user)), "Admin should have access to any project.");
4268

4369
let project = Project {
4470
id: "private_project".to_string(),
4571
display_name: "Private Project".to_string(),
4672
secret: None,
4773
public: false,
74+
unlisted: false,
4875
};
49-
assert!(!can_access_project(&project, None), "Private project should not be accessible without a user.");
76+
assert!(!can_view_project(&project, None), "Private project should not be accessible without a user.");
5077
}
5178
}

‎src/web/routes/admin.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use crate::{
1919
},
2020
reports::{Dimension, Metric},
2121
},
22-
utils::validate::can_access_project,
22+
utils::validate::{can_enumerate_project, can_view_project},
2323
web::{
2424
RouterState,
2525
session::{Auth, MaybeAuth},
@@ -91,6 +91,8 @@ struct UpdateProjectRequest {
9191
struct UpdateProjectInfo {
9292
display_name: String,
9393
public: bool,
94+
#[serde(default)]
95+
unlisted: bool,
9496
secret: Option<String>,
9597
}
9698

@@ -104,6 +106,8 @@ struct UpdatePasswordRequest {
104106
struct CreateProjectRequest {
105107
display_name: String,
106108
public: bool,
109+
#[serde(default)]
110+
unlisted: bool,
107111
secret: Option<String>,
108112
entities: Vec<String>,
109113
}
@@ -115,6 +119,7 @@ pub struct ProjectResponse {
115119
pub display_name: String,
116120
pub entities: Vec<ProjectEntity>,
117121
pub public: bool,
122+
pub unlisted: bool,
118123
pub hidden_metrics: Vec<Metric>,
119124
pub hidden_dimensions: Vec<Dimension>,
120125
}
@@ -144,6 +149,7 @@ impl ProjectResponse {
144149
.map(|entity| ProjectEntity { id: entity.id, display_name: entity.display_name })
145150
.collect(),
146151
public: project.public,
152+
unlisted: project.unlisted,
147153
hidden_metrics: Metric::all()
148154
.iter()
149155
.copied()
@@ -172,6 +178,7 @@ struct EntityProject {
172178
id: String,
173179
display_name: String,
174180
public: bool,
181+
unlisted: bool,
175182
}
176183

177184
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)]
@@ -343,6 +350,7 @@ async fn project_create_handler(
343350
id: project_id,
344351
display_name: project.display_name,
345352
public: project.public,
353+
unlisted: project.unlisted,
346354
secret: project.secret,
347355
},
348356
project.entities.as_slice(),
@@ -368,6 +376,7 @@ async fn project_update_handler(
368376
id: project_id.clone(),
369377
display_name: project.display_name,
370378
public: project.public,
379+
unlisted: project.unlisted,
371380
secret: project.secret,
372381
})
373382
.http_err("Failed to update project", StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -387,7 +396,7 @@ async fn projects_handler(
387396
MaybeAuth(user): MaybeAuth,
388397
) -> ApiResult<UseApi<impl IntoApiResponse, Json<ProjectsResponse>>> {
389398
let projects = app.projects.all().http_err("Failed to get projects", StatusCode::INTERNAL_SERVER_ERROR)?;
390-
let projects: Vec<Project> = projects.into_iter().filter(|p| can_access_project(p, user.as_ref())).collect();
399+
let projects: Vec<Project> = projects.into_iter().filter(|p| can_enumerate_project(p, user.as_ref())).collect();
391400

392401
let mut resp = Vec::new();
393402
for project in projects {
@@ -405,7 +414,7 @@ async fn project_handler(
405414
Path(project_id): Path<String>,
406415
) -> ApiResult<UseApi<impl IntoApiResponse, Json<ProjectResponse>>> {
407416
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
408-
if !can_access_project(&project, user.as_ref()) {
417+
if !can_view_project(&project, user.as_ref()) {
409418
return Err(StatusCode::NOT_FOUND.into());
410419
}
411420

@@ -595,6 +604,7 @@ async fn entities_handler(
595604
id: project.id,
596605
display_name: project.display_name,
597606
public: project.public,
607+
unlisted: project.unlisted,
598608
})
599609
.collect(),
600610
});

‎src/web/routes/dashboard.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::app::reports::{self, DateRange, Dimension, DimensionFilter, GraphInterval, Metric, ReportStats};
2-
use crate::utils::validate::{self, can_access_project};
2+
use crate::utils::validate::{self, can_view_project};
33
use crate::web::RouterState;
44
use crate::web::session::MaybeAuth;
55
use crate::web::webext::{ApiResult, AxumErrExt, http_bail};
@@ -97,7 +97,7 @@ async fn project_earliest_handler(
9797
) -> ApiResult<Json<EarliestResponse>> {
9898
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
9999

100-
if !can_access_project(&project, user.as_ref()) {
100+
if !can_view_project(&project, user.as_ref()) {
101101
http_bail!(StatusCode::NOT_FOUND, "Project not found")
102102
}
103103

@@ -117,7 +117,7 @@ async fn project_graph_handler(
117117
let project = app.projects.get(&project_id).http_status(StatusCode::IM_A_TEAPOT)?;
118118
let entities = app.projects.entity_ids(&project.id).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
119119

120-
if !can_access_project(&project, user.as_ref()) {
120+
if !can_view_project(&project, user.as_ref()) {
121121
http_bail!(StatusCode::NOT_FOUND, "Project not found")
122122
}
123123

@@ -150,7 +150,7 @@ async fn project_stats_handler(
150150
Json(req): Json<StatsRequest>,
151151
) -> ApiResult<Json<StatsResponse>> {
152152
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
153-
if !can_access_project(&project, user.as_ref()) {
153+
if !can_view_project(&project, user.as_ref()) {
154154
http_bail!(StatusCode::NOT_FOUND, "Project not found")
155155
}
156156

@@ -199,7 +199,7 @@ async fn project_detailed_handler(
199199
let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?;
200200
let entities = app.projects.entity_ids(&project.id).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
201201

202-
if !can_access_project(&project, user.as_ref()) {
202+
if !can_view_project(&project, user.as_ref()) {
203203
http_bail!(StatusCode::NOT_FOUND, "Project not found")
204204
}
205205

0 commit comments

Comments
 (0)