Skip to main content

mimick/api_client/
shared_links.rs

1//! Shared links: public URLs for an album or a set of assets.
2
3use super::ImmichApiClient;
4
5/// A shared link as returned by `GET /api/shared-links`.
6#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
7#[serde(rename_all = "camelCase")]
8pub struct SharedLink {
9    pub id: String,
10    /// Secret part of the public URL (`/share/{key}`).
11    pub key: String,
12    /// Optional custom path (`/s/{slug}`), used instead of the key when set.
13    #[serde(default)]
14    pub slug: Option<String>,
15    /// `"ALBUM"` or `"INDIVIDUAL"`.
16    #[serde(rename = "type")]
17    pub link_type: String,
18    #[serde(default)]
19    pub description: Option<String>,
20    /// Present only when the link is password protected.
21    #[serde(default)]
22    pub password: Option<String>,
23    #[serde(default)]
24    pub expires_at: Option<String>,
25    #[serde(default)]
26    pub created_at: String,
27    #[serde(default)]
28    pub allow_download: bool,
29    #[serde(default)]
30    pub allow_upload: bool,
31    #[serde(default)]
32    pub show_metadata: bool,
33    #[serde(default)]
34    pub album: Option<SharedLinkAlbum>,
35    #[serde(default)]
36    pub assets: Vec<SharedLinkAsset>,
37}
38
39#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
40#[serde(rename_all = "camelCase")]
41pub struct SharedLinkAlbum {
42    pub id: String,
43    pub album_name: String,
44}
45
46#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
47pub struct SharedLinkAsset {
48    pub id: String,
49}
50
51/// Settings shared by creating and editing a link.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SharedLinkOptions {
54    pub description: String,
55    /// New password; empty keeps no password (create) or the current one (edit).
56    pub password: String,
57    /// RFC3339 expiry, or `None` for a link that never expires.
58    pub expires_at: Option<String>,
59    pub allow_download: bool,
60    pub allow_upload: bool,
61    pub show_metadata: bool,
62}
63
64/// What a new link shares.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum SharedLinkTarget {
67    Album(String),
68    Assets(Vec<String>),
69}
70
71/// Body for `POST /api/shared-links`.
72fn create_body(target: &SharedLinkTarget, options: &SharedLinkOptions) -> serde_json::Value {
73    let mut body = options_body(options);
74    match target {
75        SharedLinkTarget::Album(album_id) => {
76            body["type"] = "ALBUM".into();
77            body["albumId"] = album_id.clone().into();
78        }
79        SharedLinkTarget::Assets(asset_ids) => {
80            body["type"] = "INDIVIDUAL".into();
81            body["assetIds"] = asset_ids.clone().into();
82        }
83    }
84    if let Some(expires_at) = &options.expires_at {
85        body["expiresAt"] = expires_at.clone().into();
86    }
87    body
88}
89
90/// Body for `PATCH /api/shared-links/{id}`. Clients can't send `null` to clear the expiry, so
91/// "never expires" is sent as `changeExpiryTime: true` with no `expiresAt`.
92fn edit_body(options: &SharedLinkOptions) -> serde_json::Value {
93    let mut body = options_body(options);
94    body["changeExpiryTime"] = true.into();
95    if let Some(expires_at) = &options.expires_at {
96        body["expiresAt"] = expires_at.clone().into();
97    }
98    body
99}
100
101fn options_body(options: &SharedLinkOptions) -> serde_json::Value {
102    let mut body = serde_json::json!({
103        "description": options.description,
104        "allowDownload": options.allow_download,
105        "allowUpload": options.allow_upload,
106        "showMetadata": options.show_metadata,
107    });
108    if !options.password.is_empty() {
109        body["password"] = options.password.clone().into();
110    }
111    body
112}
113
114/// Public URL for a link: `{base}/s/{slug}` when it has a slug, `{base}/share/{key}` otherwise.
115pub fn share_url(base: &str, link: &SharedLink) -> String {
116    let base = base.trim_end_matches('/');
117    match link.slug.as_deref().filter(|s| !s.is_empty()) {
118        Some(slug) => format!("{base}/s/{slug}"),
119        None => format!("{base}/share/{}", link.key),
120    }
121}
122
123impl ImmichApiClient {
124    /// The user's shared links, newest first.
125    pub async fn fetch_shared_links(&self) -> Result<Vec<SharedLink>, String> {
126        let resp = self
127            .send_json(reqwest::Method::GET, "/api/shared-links", None)
128            .await?;
129        let mut links: Vec<SharedLink> = resp.json().await.map_err(|err| err.to_string())?;
130        links.sort_by(|a, b| b.created_at.cmp(&a.created_at));
131        Ok(links)
132    }
133
134    pub async fn create_shared_link(
135        &self,
136        target: &SharedLinkTarget,
137        options: &SharedLinkOptions,
138    ) -> Result<SharedLink, String> {
139        let body = create_body(target, options);
140        let resp = self
141            .send_json(reqwest::Method::POST, "/api/shared-links", Some(body))
142            .await?;
143        resp.json().await.map_err(|err| err.to_string())
144    }
145
146    pub async fn edit_shared_link(
147        &self,
148        link_id: &str,
149        options: &SharedLinkOptions,
150    ) -> Result<SharedLink, String> {
151        let path = format!("/api/shared-links/{link_id}");
152        let resp = self
153            .send_json(reqwest::Method::PATCH, &path, Some(edit_body(options)))
154            .await?;
155        resp.json().await.map_err(|err| err.to_string())
156    }
157
158    pub async fn delete_shared_link(&self, link_id: &str) -> Result<(), String> {
159        let path = format!("/api/shared-links/{link_id}");
160        self.send_json(reqwest::Method::DELETE, &path, None)
161            .await
162            .map(|_| ())
163    }
164
165    /// Base URL for public links: the server's configured external domain, or the
166    /// address Mimick is connected through when none is set.
167    pub async fn shared_link_base(&self) -> Result<String, String> {
168        #[derive(serde::Deserialize)]
169        #[serde(rename_all = "camelCase")]
170        struct ServerConfig {
171            #[serde(default)]
172            external_domain: String,
173        }
174        let configured = match self
175            .send_json(reqwest::Method::GET, "/api/server/config", None)
176            .await
177        {
178            Ok(resp) => resp
179                .json::<ServerConfig>()
180                .await
181                .map(|c| c.external_domain)
182                .unwrap_or_default(),
183            Err(_) => String::new(),
184        };
185        if !configured.trim().is_empty() {
186            return Ok(configured);
187        }
188        self.get_active_url()
189            .await
190            .ok_or_else(|| "No active connection".to_string())
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn options() -> SharedLinkOptions {
199        SharedLinkOptions {
200            description: "Trip".into(),
201            password: String::new(),
202            expires_at: None,
203            allow_download: true,
204            allow_upload: false,
205            show_metadata: true,
206        }
207    }
208
209    fn link(slug: Option<&str>) -> SharedLink {
210        serde_json::from_value(serde_json::json!({
211            "id": "l1",
212            "key": "abc123",
213            "slug": slug,
214            "type": "ALBUM",
215            "createdAt": "2026-10-01T00:00:00.000Z",
216            "allowDownload": true,
217            "allowUpload": false,
218            "showMetadata": true,
219            "album": { "id": "a1", "albumName": "Trips" },
220            "assets": []
221        }))
222        .unwrap()
223    }
224
225    #[test]
226    fn create_body_for_album_and_assets() {
227        let album = create_body(&SharedLinkTarget::Album("a1".into()), &options());
228        assert_eq!(album["type"], "ALBUM");
229        assert_eq!(album["albumId"], "a1");
230        assert!(album.get("expiresAt").is_none());
231        assert!(album.get("password").is_none());
232
233        let with_expiry = SharedLinkOptions {
234            expires_at: Some("2026-11-01T00:00:00Z".into()),
235            password: "secret".into(),
236            ..options()
237        };
238        let assets = create_body(&SharedLinkTarget::Assets(vec!["x".into()]), &with_expiry);
239        assert_eq!(assets["type"], "INDIVIDUAL");
240        assert_eq!(assets["assetIds"], serde_json::json!(["x"]));
241        assert_eq!(assets["expiresAt"], "2026-11-01T00:00:00Z");
242        assert_eq!(assets["password"], "secret");
243    }
244
245    #[test]
246    fn edit_body_clears_expiry_with_change_flag() {
247        let body = edit_body(&options());
248        assert_eq!(body["changeExpiryTime"], true);
249        assert!(body.get("expiresAt").is_none());
250        assert_eq!(body["allowDownload"], true);
251    }
252
253    #[test]
254    fn share_url_prefers_slug_and_trims_slash() {
255        assert_eq!(
256            share_url("https://photos.example.com/", &link(None)),
257            "https://photos.example.com/share/abc123"
258        );
259        assert_eq!(
260            share_url("https://photos.example.com", &link(Some("trips"))),
261            "https://photos.example.com/s/trips"
262        );
263    }
264
265    #[test]
266    fn shared_link_reads_album_name() {
267        assert_eq!(link(None).album.unwrap().album_name, "Trips");
268    }
269}