Release v0.5.1: namespace integrity, dashboard parity and QR hardening
This commit is contained in:
1 parent
0295b4bd7c
commit
115f6e9a23
45 files changed
+5851
-1765
No files matched your search
+1046
-162
File diff suppressed because it is too large.
Load diff
+209
-45
@@ -149,20 +149,105 @@ pub async fn api_create_url(
|
||||
None
|
||||
};
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
// Check quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "Quota limit exceeded".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
payload.title.as_deref(),
|
||||
payload.description.as_deref(),
|
||||
&tags,
|
||||
payload.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
payload.max_access_count,
|
||||
) {
|
||||
let res = {
|
||||
let conn = content_db.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
payload.title.as_deref(),
|
||||
payload.description.as_deref(),
|
||||
&tags,
|
||||
payload.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
payload.max_access_count,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -189,24 +274,17 @@ pub async fn api_create_url(
|
||||
}
|
||||
(StatusCode::CREATED, Json(url)).into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,16 +512,109 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&payload.slug,
|
||||
&payload.title,
|
||||
&payload.html_content,
|
||||
&payload.state,
|
||||
) {
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
// Check quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "Quota limit exceeded".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = content_db.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&payload.slug,
|
||||
&payload.title,
|
||||
&payload.html_content,
|
||||
&payload.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
// Activate slug
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if payload.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
target_user_id,
|
||||
"landings",
|
||||
);
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -457,24 +628,17 @@ pub async fn api_create_page(
|
||||
);
|
||||
(StatusCode::CREATED, Json(page)).into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-13
@@ -165,7 +165,53 @@ pub async fn api_bulk_url(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut conn = state.content_db.lock().unwrap();
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
// Check quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Some(quotas) =
|
||||
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
|
||||
{
|
||||
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Quota limit exceeded".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(BulkErrorResponse {
|
||||
error: "User quota not found".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let mut conn = content_db.lock().unwrap();
|
||||
let tx = match conn.transaction() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -180,6 +226,7 @@ pub async fn api_bulk_url(
|
||||
};
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||
|
||||
for item in payload {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
@@ -188,6 +235,12 @@ pub async fn api_bulk_url(
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let _ = tx.rollback();
|
||||
// Release reserving slugs
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
@@ -198,11 +251,66 @@ pub async fn api_bulk_url(
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve slug
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
// Check availability in system.db and also check in our currently reserved slugs in this batch
|
||||
let available = crate::db::users::is_slug_available(&system_conn, &code)
|
||||
.unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
|
||||
if !available {
|
||||
let _ = tx.rollback();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' already exists", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
let _ = tx.rollback();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to reserve slug '{}': {}", code, e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
reserved_slugs.push(code.clone());
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&system_conn,
|
||||
slug,
|
||||
target_user_id,
|
||||
);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
@@ -229,20 +337,13 @@ pub async fn api_bulk_url(
|
||||
item.max_access_count,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' already exists", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
@@ -255,6 +356,10 @@ pub async fn api_bulk_url(
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
@@ -264,6 +369,25 @@ pub async fn api_bulk_url(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Activate slugs
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for url in &created_urls {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increment quota counters
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
for _ in 0..created_urls.len() {
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
||||
}
|
||||
}
|
||||
|
||||
// Write Audit Log for the entire batch
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
|
||||
+10
-5
@@ -63,14 +63,19 @@ pub async fn resolve_page(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
||||
// tenant users use per-user content databases
|
||||
let content_conn = if owner_user_id == 1 {
|
||||
state.content_db.clone()
|
||||
} else {
|
||||
match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let conn = content_conn.lock().unwrap();
|
||||
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
||||
Ok(page) => page,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
|
||||
+59
-71
@@ -10,7 +10,6 @@ use std::net::SocketAddr;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -38,12 +37,12 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
// We need to look up owner_user_id, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
@@ -52,19 +51,25 @@ pub async fn qr_handler(
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Ok(Some((uid, tid, status))) => (uid, tid, status),
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
if slug_status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
@@ -73,31 +78,16 @@ pub async fn qr_handler(
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &file) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
|
||||
rusqlite::params![target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
@@ -113,15 +103,17 @@ pub async fn qr_handler(
|
||||
let code = parts[0];
|
||||
let ext = parts[1].to_lowercase();
|
||||
|
||||
if !crate::utils::validation::validate_redirect_code(code) {
|
||||
if !crate::utils::validation::validate_redirect_code(code)
|
||||
&& !crate::utils::validation::validate_page_code(code)
|
||||
{
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
// We need to look up owner_user_id, target_type, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_type, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
.prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
@@ -129,38 +121,27 @@ pub async fn qr_handler(
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
if slug_status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
// Construct public base URL
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
@@ -178,7 +159,11 @@ pub async fn qr_handler(
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
|
||||
let full_url = if target_type == "page" {
|
||||
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
|
||||
} else {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), code)
|
||||
};
|
||||
|
||||
// Generate QR code based on format
|
||||
let (body, content_type) = if ext == "svg" {
|
||||
@@ -211,22 +196,25 @@ pub async fn qr_handler(
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Log the QR access event
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
// Log the QR access event in a try-catch style
|
||||
let _ = {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = user_dbs.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
|
||||
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&target_id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", content_type)
|
||||
|
||||
+32
-12
@@ -24,8 +24,10 @@ pub async fn resolve_redirect(
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
// Basic validation of code (must be 6 hex characters or a valid custom slug)
|
||||
if !crate::utils::validation::validate_redirect_code(&code) {
|
||||
// Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug)
|
||||
if !crate::utils::validation::validate_redirect_code(&code)
|
||||
&& !crate::utils::validation::validate_page_code(&code)
|
||||
{
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ pub async fn resolve_redirect(
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
@@ -67,14 +69,24 @@ pub async fn resolve_redirect(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
return Redirect::permanent(&format!("/p/{}", code)).into_response();
|
||||
}
|
||||
|
||||
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
||||
// tenant users use per-user content databases
|
||||
let content_conn = if owner_user_id == 1 {
|
||||
state.content_db.clone()
|
||||
} else {
|
||||
match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let conn = content_conn.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
@@ -96,7 +108,7 @@ pub async fn resolve_redirect(
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let conn = content_conn.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
@@ -131,12 +143,12 @@ pub async fn resolve_redirect(
|
||||
|
||||
// 6. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let conn = content_conn.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let conn = content_conn.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -188,6 +200,14 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
.into_response()
|
||||
} else {
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
{
|
||||
use axum::http::{header, HeaderValue};
|
||||
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
HeaderValue::from_str(&url.destination).unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,26 @@ pub fn create_router(state: AppState) -> Router {
|
||||
"/user/analytics/url/:id",
|
||||
get(admin::user_url_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/url/:id/export/csv",
|
||||
get(admin::user_url_analytics_csv_export),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/url/:id/export/json",
|
||||
get(admin::user_url_analytics_json_export),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id",
|
||||
get(admin::user_page_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id/export/csv",
|
||||
get(admin::user_page_analytics_csv_export),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id/export/json",
|
||||
get(admin::user_page_analytics_json_export),
|
||||
)
|
||||
.route("/api-tokens", get(admin::api_tokens_get))
|
||||
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
||||
.route(
|
||||
|
||||
Reference in new issue
Block a user