Skip to main content

max / makenotwork

7.4 KB · 242 lines History Blame Raw
1 //! SyncKit app management: create, list, delete, regenerate keys, update links.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 response::IntoResponse,
7 };
8
9 use sqlx::PgPool;
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, SyncAppId},
14 error::{AppError, Result},
15 validation,
16 };
17
18 use super::UpdateAppSlugRequest;
19
20 use super::{CreateAppRequest, UpdateAppLinkRequest};
21
22 /// Create a new sync app and generate its API key.
23 ///
24 /// `POST /api/sync/apps`: Session auth required.
25 /// Returns the app data plus the plaintext API key (shown only once).
26 #[tracing::instrument(skip_all, name = "synckit::create_app")]
27 pub(super) async fn create_app(
28 State(db): State<PgPool>,
29 AuthUser(user): AuthUser,
30 Json(req): Json<CreateAppRequest>,
31 ) -> Result<impl IntoResponse> {
32 user.check_not_sandbox()?;
33 validation::validate_sync_app_name(&req.name)?;
34
35 let project_id = parse_and_verify_project(&db, user.id, req.project_id.as_deref()).await?;
36 let item_id = parse_and_verify_item(&db, user.id, req.item_id.as_deref()).await?;
37
38 let api_key = super::generate_api_key();
39 let app = db::synckit::create_sync_app(&db, user.id, &req.name, &api_key, project_id, item_id)
40 .await?;
41
42 Ok((
43 axum::http::StatusCode::CREATED,
44 Json(super::AppWithKey { app, api_key }),
45 ))
46 }
47
48 /// List all sync apps owned by the authenticated user.
49 ///
50 /// `GET /api/sync/apps`: Session auth required.
51 #[tracing::instrument(skip_all, name = "synckit::list_apps")]
52 pub(super) async fn list_apps(
53 State(db): State<PgPool>,
54 AuthUser(user): AuthUser,
55 ) -> Result<impl IntoResponse> {
56 let apps = db::synckit::get_sync_apps_by_creator(&db, user.id).await?;
57
58 Ok(Json(apps))
59 }
60
61 /// Regenerate the API key for a sync app, invalidating the old one.
62 ///
63 /// `POST /api/sync/apps/{id}/regenerate-key`: Session auth required.
64 #[tracing::instrument(skip_all, name = "synckit::regenerate_app_key")]
65 pub(super) async fn regenerate_app_key(
66 State(db): State<PgPool>,
67 AuthUser(user): AuthUser,
68 Path(app_id): Path<SyncAppId>,
69 ) -> Result<impl IntoResponse> {
70 let app = db::synckit::get_sync_app_by_id(&db, app_id)
71 .await?
72 .ok_or(AppError::NotFound)?;
73
74 if app.creator_id != user.id {
75 return Err(AppError::Forbidden);
76 }
77
78 let new_key = super::generate_api_key();
79 let updated = db::synckit::regenerate_sync_app_key(&db, app_id, &new_key).await?;
80
81 Ok(Json(super::AppWithKey {
82 app: updated,
83 api_key: new_key,
84 }))
85 }
86
87 /// Generate (or rotate) the app's keys-endpoint secret, invalidating the old one.
88 ///
89 /// `POST /api/sync/apps/{id}/keys-secret`: Session auth required.
90 /// Returns the plaintext secret once; only its hash is stored.
91 ///
92 /// This is the credential for the server-to-server `/api/sync/keys/*` routes.
93 /// It is separate from the api_key because the api_key is compiled into every
94 /// shipped client and can be recovered from a binary. Keep this one on a
95 /// developer backend.
96 #[tracing::instrument(skip_all, name = "synckit::regenerate_app_keys_secret")]
97 pub(super) async fn regenerate_app_keys_secret(
98 State(db): State<PgPool>,
99 AuthUser(user): AuthUser,
100 Path(app_id): Path<SyncAppId>,
101 ) -> Result<impl IntoResponse> {
102 user.check_not_sandbox()?;
103
104 let app = db::synckit::get_sync_app_by_id(&db, app_id)
105 .await?
106 .ok_or(AppError::NotFound)?;
107
108 if app.creator_id != user.id {
109 return Err(AppError::Forbidden);
110 }
111
112 let new_secret = super::generate_app_secret();
113 let updated = db::synckit::set_sync_app_keys_secret(&db, app_id, &new_secret).await?;
114
115 Ok(Json(super::AppKeysSecret {
116 app: updated,
117 app_secret: new_secret,
118 }))
119 }
120
121 /// Delete a sync app and all its associated data.
122 ///
123 /// `DELETE /api/sync/apps/{id}`: Session auth required.
124 #[tracing::instrument(skip_all, name = "synckit::delete_app")]
125 pub(super) async fn delete_app(
126 State(db): State<PgPool>,
127 AuthUser(user): AuthUser,
128 Path(app_id): Path<SyncAppId>,
129 ) -> Result<impl IntoResponse> {
130 let app = db::synckit::get_sync_app_by_id(&db, app_id)
131 .await?
132 .ok_or(AppError::NotFound)?;
133
134 if app.creator_id != user.id {
135 return Err(AppError::Forbidden);
136 }
137
138 db::synckit::delete_sync_app(&db, app_id).await?;
139
140 Ok(axum::http::StatusCode::NO_CONTENT)
141 }
142
143 /// Update the project and/or item link for a sync app.
144 ///
145 /// `PUT /api/sync/apps/{id}/link`: Session auth required.
146 #[tracing::instrument(skip_all, name = "synckit::update_app_link")]
147 pub(super) async fn update_app_link(
148 State(db): State<PgPool>,
149 AuthUser(user): AuthUser,
150 Path(app_id): Path<SyncAppId>,
151 Json(req): Json<UpdateAppLinkRequest>,
152 ) -> Result<impl IntoResponse> {
153 let app = db::synckit::get_sync_app_by_id(&db, app_id)
154 .await?
155 .ok_or(AppError::NotFound)?;
156
157 if app.creator_id != user.id {
158 return Err(AppError::Forbidden);
159 }
160
161 let project_id = parse_and_verify_project(&db, user.id, req.project_id.as_deref()).await?;
162 let item_id = parse_and_verify_item(&db, user.id, req.item_id.as_deref()).await?;
163
164 let updated = db::synckit::update_sync_app_link(&db, app_id, project_id, item_id).await?;
165
166 Ok(Json(updated))
167 }
168
169 /// Set the OTA slug for a sync app.
170 ///
171 /// `PUT /api/sync/apps/{id}/slug`: Session auth required.
172 #[tracing::instrument(skip_all, name = "synckit::update_app_slug")]
173 pub(super) async fn update_app_slug(
174 State(db): State<PgPool>,
175 AuthUser(user): AuthUser,
176 Path(app_id): Path<SyncAppId>,
177 Json(req): Json<UpdateAppSlugRequest>,
178 ) -> Result<impl IntoResponse> {
179 let app = db::synckit::get_sync_app_by_id(&db, app_id)
180 .await?
181 .ok_or(AppError::NotFound)?;
182
183 if app.creator_id != user.id {
184 return Err(AppError::Forbidden);
185 }
186
187 // Reuse the OTA slug validation
188 crate::routes::ota::validate_slug_public(&req.slug)?;
189
190 db::ota::set_app_slug(&db, app_id, &req.slug).await?;
191
192 Ok(axum::http::StatusCode::NO_CONTENT)
193 }
194
195 // --- Link helpers ---
196
197 /// Parse an optional UUID string and verify the project belongs to the user.
198 async fn parse_and_verify_project(
199 db: &PgPool,
200 user_id: db::UserId,
201 raw: Option<&str>,
202 ) -> Result<Option<db::ProjectId>> {
203 let Some(s) = raw.filter(|s| !s.is_empty()) else {
204 return Ok(None);
205 };
206 let pid: db::ProjectId = s
207 .parse()
208 .map_err(|_| AppError::BadRequest("Invalid project_id".to_string()))?;
209 let project = db::projects::get_project_by_id(db, pid)
210 .await?
211 .ok_or(AppError::BadRequest("Project not found".to_string()))?;
212 if project.user_id != user_id {
213 return Err(AppError::Forbidden);
214 }
215 Ok(Some(pid))
216 }
217
218 /// Parse an optional UUID string and verify the item belongs to the user
219 /// (via its parent project).
220 async fn parse_and_verify_item(
221 db: &PgPool,
222 user_id: db::UserId,
223 raw: Option<&str>,
224 ) -> Result<Option<db::ItemId>> {
225 let Some(s) = raw.filter(|s| !s.is_empty()) else {
226 return Ok(None);
227 };
228 let iid: db::ItemId = s
229 .parse()
230 .map_err(|_| AppError::BadRequest("Invalid item_id".to_string()))?;
231 let item = db::items::get_item_by_id(db, iid)
232 .await?
233 .ok_or(AppError::BadRequest("Item not found".to_string()))?;
234 let project = db::projects::get_project_by_id(db, item.project_id)
235 .await?
236 .ok_or(AppError::BadRequest("Item's project not found".to_string()))?;
237 if project.user_id != user_id {
238 return Err(AppError::Forbidden);
239 }
240 Ok(Some(iid))
241 }
242