···10101111## Features
12121313-- Blob validation - verifies blob content matches its CID and rejects invalid/tampered content.
1414-- Secure serving - blobs are always served with secure headers to help improve end-user security.
1515-- MIME filtering - detects blob content MIME-types and enforces an optional allowlist of permitted types.
1616-- Policy enforcement - optionally integrate with an external policy service (like an AppView) to control which blobs can be served.
1717-- In-memory cache - configurable in-memory caching for fast repeat access with support for manual cache purging via authenticated HTTP DELETE.
1818-1919-## Routes
2020-2121-- [GET] `/{did}/{cid}`: Resolve and fetch a blob from its origin.
2222-- [DELETE] `/cache/{cid or did}`: Invalidate all valid cache items for a specific blob CID or for an entire user DID. Requires configured bearer auth token.
1313+- Blob validation: verifies blob content matches its CID and rejects invalid/tampered content.
1414+- Secure serving: blobs are always served with secure headers to help improve end-user security.
1515+- MIME filtering: detects blob content MIME-types and enforces an optional allowlist of permitted types.
1616+- Policy enforcement: optionally integrate with an external policy service (like an AppView) to control which blobs can be served.
1717+- In-memory cache: configurable in-memory caching for fast repeat access with support for manual cache purging via authenticated HTTP DELETE.
23182419## Usage
25202621> [!NOTE]
2727-> Porxie does not handle TLS, so it should be placed behind a reverse proxy like [Caddy](https://caddyserver.com), [Traefik](https://traefik.io/traefik), or [NGINX](https://nginx.org). Ensure that any intermediaries between Porxie and the client pass through the `Cache-Control`, `Content-Security-Policy` and `Content-Disposition` headers, or otherwise set them securely.
2828->
2929-> Putting a CDN in front of Porxie is also recommended for better long-term caching and worldwide latency.
2222+> Porxie does not handle TLS, so it should be placed behind a reverse proxy like [Caddy](https://caddyserver.com), [Traefik](https://traefik.io/traefik), or [NGINX](https://nginx.org). It is also recommended to use a dedicated caching layer in-between Porxie and your clients such as Varnish, Cloudflare, or similar.
2323+>
2424+> Please ensure that any intermediary services between Porxie and the client pass through the following headers or set them the same as Porxie does:
2525+> - `Content-Type` (if unmodified by the service)
2626+> - `Cache-Control`
2727+> - `Content-Security-Policy`
2828+> - `Content-Disposition`
2929+> - `X-Content-Type-Options`
30303131### Run: Binary
3232···4444 porxie
4545 ```
46464747-### Run: Docker
4848-4949-To run Porxie with the Docker CLI and default settings, use the following command:
5050-5151-```sh
5252-docker run -d \
5353- --name porxie \
5454- --restart unless-stopped \
5555- -p 6314:6314 \
5656- blooym/porxie:latest
5757-```
5858-5947### Run: Docker Compose
60486149To run Porxie with Docker Compose, you can start with the following `compose.yml` template:
···78667967To run Porxie with Nix, you can use the [package](https://search.nixos.org/packages?channel=unstable&query=porxie) or [NixOS module](https://search.nixos.org/options?channel=unstable&query=porxie) provided directly in nixpkgs.
80686969+## Routes
7070+7171+- [GET] `/{did}/{cid}`: Fetch a blob either from cache or origin.
7272+- [GET] `/xrpc/dev.blooym.porxie.getBlob?did=<did>&cid=<cid>`: XRPC Compatibility alias for the fetch blob endpoint.
7373+- [POST] `/xrpc/dev.blooym.porxie.cache.purgeActor?did=<did>`: Purge all cached items relating to an actor DID.
7474+- [POST] `/xrpc/dev.blooym.porxie.cache.purgeBlob?cid=<cid>`: Purge all cache items relating to a blob CID.
7575+7676+8177## Policy Service
82788379Porxie can optionally check with an external HTTP service before serving any blob. You build and run this service yourself - Porxie just calls it and acts on the response. This is useful for things like content takedowns or blob allow lists.
84808585-For every incoming request, Porxie sends `GET <policy-service-url>/<did>/<cid>` and expects one of the following responses:
8686-8787-- **200 OK** - the blob is allowed and will be served.
8888-- **410 Gone** - the blob is restricted and Porxie will refuse to serve it to the client.
8181+For every incoming request, Porxie sends `GET <policy-service-url>/xrpc/dev.blooym.porxie.getBlobPolicy` and expects a response that conforms to the (`lexicon xrpc output`)[lexicons/dev/blooym/porxie/getBlobPolicy.json].
89829090-Any other status code is treated as an error for now.
9191-9292-Policy decisions are cached per DID+CID pair, so your service won't be hit on every request. To clear a cached decision early, use the `DELETE /cache/{cid}` endpoint.
8383+Policy decisions are cached per DID+CID pair, so your service won't be hit on every request. The policy cache can be cleared for a blob or actor via the cache clearing xrpc endpoints.
93849485By default, Porxie will fail-closed: if the policy service errors, the blob request fails too. This can be changed to fail-open if preferred.
9586···111102 [env: PORXIE_SERVER_ADDRESS=]
112103 [default: ip:127.0.0.1:6314]
113104114114---server-auth-token <SA_SERVER_AUTH_TOKEN>
115115- Bearer token for authenticating admin requests.
105105+--server-admin-password <SA_SERVER_ADMIN_PASSWORD>
106106+ Admin password for authenticating privileged requests.
116107117108 When unset, all authenticated endpoints will reject requests with HTTP 401.
118109119119- [env: PORXIE_SERVER_AUTH_TOKEN=]
110110+ Authenticated requests always expect the username `admin` as per specification.
111111+112112+ [env: PORXIE_SERVER_ADMIN_TOKEN=]
120113```
121114122115### Blob
···139132140133--blob-max-size <BA_BLOB_MAX_SIZE>
141134 Maximum blob size that can be fetched and served.
142142-135135+143136 Blobs that exceed this limit will return HTTP 413.
144144-137137+145138 The minimum value is 512kb and the maximum is the system's total memory.
146139147140 [env: PORXIE_BLOB_MAX_SIZE=]
···155148 cleared manually for changes to take effect quickly.
156149157150 [env: PORXIE_BLOB_CACHE_HEADER=]
158158- [default: "public, max-age=604800, must-revalidate, immutable"]
151151+ [default: "public, max-age=604800, immutable"]
159152160153--blob-processing-timeout <BA_BLOB_PROCESSING_TIMEOUT>
161154 Maximum duration a blob can be processed by this server before aborting
···209202```
210203--cache-allocation <CA_CACHE_ALLOCATION>
211204 Total memory allocation for the internal cache.
212212-205205+213206 Blobs are cached using an LFU policy. The most frequently requested blobs are kept longest when the cache approaches its limit.
214214-207207+215208 For production deployments, a CDN or caching layer in front of this server is recommended for lower latency and better global availability.
216216-209209+217210 The minimum value is 8mb and the maximum is the system's total memory.
218211219212 [env: PORXIE_CACHE_ALLOCATION=]
···250243--policy-url <PA_POLICY_URL>
251244 Policy service URL that DID+CID pairs will be checked against.
252245253253- Requests are sent as HTTP GET <url>/<did>/<cid>.
254254-255255- The service is expected to return HTTP 200 (OK) if permitted or HTTP 410 (GONE) if
256256- restricted.
246246+ Requests are sent via XRPC tp <url>/xrpc/dev.blooym.porxie.getBlobPolicy?did=<did>&cid=<cid>.
257247258248 [env: PORXIE_POLICY_URL=]
259249···266256267257 As pipes are used as a delimiter, they cannot be contained in headers.
268258269269- Example (cli): '--policy-request-headers "Authorization: Bearer token"
270270- --policy-request-headers "X-Api-Key: your-key"'
259259+ Example (cli): '--policy-request-headers "X-Hello: world" --policy-request-headers "X-Foo: bar"'
271260272272- Example (env): 'PORXIE_POLICY_REQUEST_HEADERS="Authorization: Bearer
273273- token|X-Api-Key: your-key"'
261261+ Example (env): 'PORXIE_POLICY_REQUEST_HEADERS="X-Hello: world|X-Foo: bar"'
274262275263 [env: PORXIE_POLICY_REQUEST_HEADERS=]
276264277265--policy-fail-open
278278- Allow requests to proceed if the policy service is unavailable or returns an
279279- unexpected status code.
266266+ Allow requests to proceed if the policy service is unavailable.
280267281268 Warning: enabling this means restricted blobs may be served when the policy service
282269 is unreachable.
···11+// @generated by jacquard-lexicon. DO NOT EDIT.
22+//
33+// This file was automatically generated from Lexicon schemas.
44+// Any manual changes will be overwritten on the next regeneration.
55+66+/// Marker type indicating a builder field has been set
77+pub struct Set<T>(pub T);
88+impl<T> Set<T> {
99+ /// Extract the inner value
1010+ #[inline]
1111+ pub fn into_inner(self) -> T {
1212+ self.0
1313+ }
1414+}
1515+1616+/// Marker type indicating a builder field has not been set
1717+pub struct Unset;
1818+/// Trait indicating a builder field is set (has a value)
1919+2020+#[jacquard_common::deps::codegen::rustversion::attr(
2121+ since(1.78.0),
2222+ diagnostic::on_unimplemented(
2323+ message = "the field `{Self}` was not set, but this method requires it to be set",
2424+ label = "the field `{Self}` was not set"
2525+ )
2626+)]
2727+pub trait IsSet: private::Sealed {}
2828+/// Trait indicating a builder field is unset (no value yet)
2929+3030+#[jacquard_common::deps::codegen::rustversion::attr(
3131+ since(1.78.0),
3232+ diagnostic::on_unimplemented(
3333+ message = "the field `{Self}` was already set, but this method requires it to be unset",
3434+ label = "the field `{Self}` was already set"
3535+ )
3636+)]
3737+pub trait IsUnset: private::Sealed {}
3838+impl<T> IsSet for Set<T> {}
3939+impl IsUnset for Unset {}
4040+mod private {
4141+ /// Sealed trait to prevent external implementations
4242+ pub trait Sealed {}
4343+ impl<T> Sealed for super::Set<T> {}
4444+ impl Sealed for super::Unset {}
4545+}
+6
crates/lexgen/src/dev_blooym.rs
···11+// @generated by jacquard-lexicon. DO NOT EDIT.
22+//
33+// This file was automatically generated from Lexicon schemas.
44+// Any manual changes will be overwritten on the next regeneration.
55+66+pub mod porxie;
+8
crates/lexgen/src/dev_blooym/porxie.rs
···11+// @generated by jacquard-lexicon. DO NOT EDIT.
22+//
33+// This file was automatically generated from Lexicon schemas.
44+// Any manual changes will be overwritten on the next regeneration.
55+66+pub mod cache;
77+pub mod get_blob;
88+pub mod get_blob_policy;
+7
crates/lexgen/src/dev_blooym/porxie/cache.rs
···11+// @generated by jacquard-lexicon. DO NOT EDIT.
22+//
33+// This file was automatically generated from Lexicon schemas.
44+// Any manual changes will be overwritten on the next regeneration.
55+66+pub mod purge_actor;
77+pub mod purge_blob;
···11+// @generated by jacquard-lexicon. DO NOT EDIT.
22+//
33+// Lexicon: dev.blooym.porxie.getBlobPolicy
44+//
55+// This file was automatically generated from Lexicon schemas.
66+// Any manual changes will be overwritten on the next regeneration.
77+88+#[allow(unused_imports)]
99+use alloc::collections::BTreeMap;
1010+1111+#[allow(unused_imports)]
1212+use core::marker::PhantomData;
1313+1414+#[allow(unused_imports)]
1515+use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation;
1616+use jacquard_common::types::string::{Did, Cid};
1717+use jacquard_derive::{IntoStatic, lexicon};
1818+use jacquard_lexicon::lexicon::LexiconDoc;
1919+use jacquard_lexicon::schema::LexiconSchema;
2020+2121+#[allow(unused_imports)]
2222+use jacquard_lexicon::validation::{ConstraintError, ValidationPath};
2323+use serde::{Serialize, Deserialize};
2424+use crate::dev_blooym::porxie::get_blob_policy;
2525+/// Blob is allowed to be served.
2626+2727+#[lexicon]
2828+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)]
2929+#[serde(rename_all = "camelCase")]
3030+pub struct Allowed<'a> {}
3131+/// Blob is not allowed to be served.
3232+3333+#[lexicon]
3434+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)]
3535+#[serde(rename_all = "camelCase")]
3636+pub struct Forbidden<'a> {}
3737+3838+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)]
3939+#[serde(rename_all = "camelCase")]
4040+pub struct GetBlobPolicy<'a> {
4141+ #[serde(borrow)]
4242+ pub cid: Cid<'a>,
4343+ #[serde(borrow)]
4444+ pub did: Did<'a>,
4545+}
4646+4747+4848+#[lexicon]
4949+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)]
5050+#[serde(rename_all = "camelCase")]
5151+pub struct GetBlobPolicyOutput<'a> {
5252+ #[serde(borrow)]
5353+ pub policy: GetBlobPolicyOutputPolicy<'a>,
5454+}
5555+5656+5757+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)]
5858+#[serde(tag = "$type")]
5959+#[serde(bound(deserialize = "'de: 'a"))]
6060+pub enum GetBlobPolicyOutputPolicy<'a> {
6161+ #[serde(rename = "dev.blooym.porxie.getBlobPolicy#allowed")]
6262+ Allowed(Box<get_blob_policy::Allowed<'a>>),
6363+ #[serde(rename = "dev.blooym.porxie.getBlobPolicy#forbidden")]
6464+ Forbidden(Box<get_blob_policy::Forbidden<'a>>),
6565+}
6666+6767+impl<'a> LexiconSchema for Allowed<'a> {
6868+ fn nsid() -> &'static str {
6969+ "dev.blooym.porxie.getBlobPolicy"
7070+ }
7171+ fn def_name() -> &'static str {
7272+ "allowed"
7373+ }
7474+ fn lexicon_doc() -> LexiconDoc<'static> {
7575+ lexicon_doc_dev_blooym_porxie_getBlobPolicy()
7676+ }
7777+ fn validate(&self) -> Result<(), ConstraintError> {
7878+ Ok(())
7979+ }
8080+}
8181+8282+impl<'a> LexiconSchema for Forbidden<'a> {
8383+ fn nsid() -> &'static str {
8484+ "dev.blooym.porxie.getBlobPolicy"
8585+ }
8686+ fn def_name() -> &'static str {
8787+ "forbidden"
8888+ }
8989+ fn lexicon_doc() -> LexiconDoc<'static> {
9090+ lexicon_doc_dev_blooym_porxie_getBlobPolicy()
9191+ }
9292+ fn validate(&self) -> Result<(), ConstraintError> {
9393+ Ok(())
9494+ }
9595+}
9696+9797+/// Response type for dev.blooym.porxie.getBlobPolicy
9898+pub struct GetBlobPolicyResponse;
9999+impl jacquard_common::xrpc::XrpcResp for GetBlobPolicyResponse {
100100+ const NSID: &'static str = "dev.blooym.porxie.getBlobPolicy";
101101+ const ENCODING: &'static str = "application/json";
102102+ type Output<'de> = GetBlobPolicyOutput<'de>;
103103+ type Err<'de> = jacquard_common::xrpc::GenericError<'de>;
104104+}
105105+106106+impl<'a> jacquard_common::xrpc::XrpcRequest for GetBlobPolicy<'a> {
107107+ const NSID: &'static str = "dev.blooym.porxie.getBlobPolicy";
108108+ const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query;
109109+ type Response = GetBlobPolicyResponse;
110110+}
111111+112112+/// Endpoint type for dev.blooym.porxie.getBlobPolicy
113113+pub struct GetBlobPolicyRequest;
114114+impl jacquard_common::xrpc::XrpcEndpoint for GetBlobPolicyRequest {
115115+ const PATH: &'static str = "/xrpc/dev.blooym.porxie.getBlobPolicy";
116116+ const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query;
117117+ type Request<'de> = GetBlobPolicy<'de>;
118118+ type Response = GetBlobPolicyResponse;
119119+}
120120+121121+fn lexicon_doc_dev_blooym_porxie_getBlobPolicy() -> LexiconDoc<'static> {
122122+ #[allow(unused_imports)]
123123+ use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType};
124124+ use jacquard_lexicon::lexicon::*;
125125+ use alloc::collections::BTreeMap;
126126+ LexiconDoc {
127127+ lexicon: Lexicon::Lexicon1,
128128+ id: CowStr::new_static("dev.blooym.porxie.getBlobPolicy"),
129129+ defs: {
130130+ let mut map = BTreeMap::new();
131131+ map.insert(
132132+ SmolStr::new_static("allowed"),
133133+ LexUserType::Object(LexObject {
134134+ description: Some(
135135+ CowStr::new_static("Blob is allowed to be served."),
136136+ ),
137137+ properties: {
138138+ #[allow(unused_mut)]
139139+ let mut map = BTreeMap::new();
140140+ map
141141+ },
142142+ ..Default::default()
143143+ }),
144144+ );
145145+ map.insert(
146146+ SmolStr::new_static("forbidden"),
147147+ LexUserType::Object(LexObject {
148148+ description: Some(
149149+ CowStr::new_static("Blob is not allowed to be served."),
150150+ ),
151151+ properties: {
152152+ #[allow(unused_mut)]
153153+ let mut map = BTreeMap::new();
154154+ map
155155+ },
156156+ ..Default::default()
157157+ }),
158158+ );
159159+ map.insert(
160160+ SmolStr::new_static("main"),
161161+ LexUserType::XrpcQuery(LexXrpcQuery {
162162+ parameters: Some(
163163+ LexXrpcQueryParameter::Params(LexXrpcParameters {
164164+ required: Some(
165165+ vec![SmolStr::new_static("did"), SmolStr::new_static("cid")],
166166+ ),
167167+ properties: {
168168+ #[allow(unused_mut)]
169169+ let mut map = BTreeMap::new();
170170+ map.insert(
171171+ SmolStr::new_static("cid"),
172172+ LexXrpcParametersProperty::String(LexString {
173173+ format: Some(LexStringFormat::Cid),
174174+ ..Default::default()
175175+ }),
176176+ );
177177+ map.insert(
178178+ SmolStr::new_static("did"),
179179+ LexXrpcParametersProperty::String(LexString {
180180+ format: Some(LexStringFormat::Did),
181181+ ..Default::default()
182182+ }),
183183+ );
184184+ map
185185+ },
186186+ ..Default::default()
187187+ }),
188188+ ),
189189+ ..Default::default()
190190+ }),
191191+ );
192192+ map
193193+ },
194194+ ..Default::default()
195195+ }
196196+}
197197+198198+pub mod get_blob_policy_state {
199199+200200+ pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
201201+ #[allow(unused)]
202202+ use ::core::marker::PhantomData;
203203+ mod sealed {
204204+ pub trait Sealed {}
205205+ }
206206+ /// State trait tracking which required fields have been set
207207+ pub trait State: sealed::Sealed {
208208+ type Did;
209209+ type Cid;
210210+ }
211211+ /// Empty state - all required fields are unset
212212+ pub struct Empty(());
213213+ impl sealed::Sealed for Empty {}
214214+ impl State for Empty {
215215+ type Did = Unset;
216216+ type Cid = Unset;
217217+ }
218218+ ///State transition - sets the `did` field to Set
219219+ pub struct SetDid<S: State = Empty>(PhantomData<fn() -> S>);
220220+ impl<S: State> sealed::Sealed for SetDid<S> {}
221221+ impl<S: State> State for SetDid<S> {
222222+ type Did = Set<members::did>;
223223+ type Cid = S::Cid;
224224+ }
225225+ ///State transition - sets the `cid` field to Set
226226+ pub struct SetCid<S: State = Empty>(PhantomData<fn() -> S>);
227227+ impl<S: State> sealed::Sealed for SetCid<S> {}
228228+ impl<S: State> State for SetCid<S> {
229229+ type Did = S::Did;
230230+ type Cid = Set<members::cid>;
231231+ }
232232+ /// Marker types for field names
233233+ #[allow(non_camel_case_types)]
234234+ pub mod members {
235235+ ///Marker type for the `did` field
236236+ pub struct did(());
237237+ ///Marker type for the `cid` field
238238+ pub struct cid(());
239239+ }
240240+}
241241+242242+/// Builder for constructing an instance of this type
243243+pub struct GetBlobPolicyBuilder<'a, S: get_blob_policy_state::State> {
244244+ _state: PhantomData<fn() -> S>,
245245+ _fields: (Option<Cid<'a>>, Option<Did<'a>>),
246246+ _lifetime: PhantomData<&'a ()>,
247247+}
248248+249249+impl<'a> GetBlobPolicy<'a> {
250250+ /// Create a new builder for this type
251251+ pub fn new() -> GetBlobPolicyBuilder<'a, get_blob_policy_state::Empty> {
252252+ GetBlobPolicyBuilder::new()
253253+ }
254254+}
255255+256256+impl<'a> GetBlobPolicyBuilder<'a, get_blob_policy_state::Empty> {
257257+ /// Create a new builder with all fields unset
258258+ pub fn new() -> Self {
259259+ GetBlobPolicyBuilder {
260260+ _state: PhantomData,
261261+ _fields: (None, None),
262262+ _lifetime: PhantomData,
263263+ }
264264+ }
265265+}
266266+267267+impl<'a, S> GetBlobPolicyBuilder<'a, S>
268268+where
269269+ S: get_blob_policy_state::State,
270270+ S::Cid: get_blob_policy_state::IsUnset,
271271+{
272272+ /// Set the `cid` field (required)
273273+ pub fn cid(
274274+ mut self,
275275+ value: impl Into<Cid<'a>>,
276276+ ) -> GetBlobPolicyBuilder<'a, get_blob_policy_state::SetCid<S>> {
277277+ self._fields.0 = Option::Some(value.into());
278278+ GetBlobPolicyBuilder {
279279+ _state: PhantomData,
280280+ _fields: self._fields,
281281+ _lifetime: PhantomData,
282282+ }
283283+ }
284284+}
285285+286286+impl<'a, S> GetBlobPolicyBuilder<'a, S>
287287+where
288288+ S: get_blob_policy_state::State,
289289+ S::Did: get_blob_policy_state::IsUnset,
290290+{
291291+ /// Set the `did` field (required)
292292+ pub fn did(
293293+ mut self,
294294+ value: impl Into<Did<'a>>,
295295+ ) -> GetBlobPolicyBuilder<'a, get_blob_policy_state::SetDid<S>> {
296296+ self._fields.1 = Option::Some(value.into());
297297+ GetBlobPolicyBuilder {
298298+ _state: PhantomData,
299299+ _fields: self._fields,
300300+ _lifetime: PhantomData,
301301+ }
302302+ }
303303+}
304304+305305+impl<'a, S> GetBlobPolicyBuilder<'a, S>
306306+where
307307+ S: get_blob_policy_state::State,
308308+ S::Did: get_blob_policy_state::IsSet,
309309+ S::Cid: get_blob_policy_state::IsSet,
310310+{
311311+ /// Build the final struct
312312+ pub fn build(self) -> GetBlobPolicy<'a> {
313313+ GetBlobPolicy {
314314+ cid: self._fields.0.unwrap(),
315315+ did: self._fields.1.unwrap(),
316316+ }
317317+ }
318318+}
+11
crates/lexgen/src/lib.rs
···11+// @generated by jacquard-lexicon. DO NOT EDIT.
22+//
33+// This file was automatically generated from Lexicon schemas.
44+// Any manual changes will be overwritten on the next regeneration.
55+66+extern crate alloc;
77+pub mod builder_types;
88+99+1010+#[cfg(feature = "dev_blooym")]
1111+pub mod dev_blooym;
+113
crates/porxie/Cargo.toml
···11+[package]
22+name = "porxie"
33+description = "A correct and efficient ATProto blob proxy for secure content delivery."
44+authors = ["Blooym"]
55+repository = "https://codeberg.org/Blooym/porxie"
66+homepage = "https://codeberg.org/Blooym/porxie/src/branch/main/README.md"
77+documentation = "https://codeberg.org/Blooym/porxie/src/branch/main/README.md"
88+license = "AGPL-3.0-or-later"
99+version = "0.1.2"
1010+edition = "2024"
1111+1212+[dependencies]
1313+anyhow = { version = "1.0.102", features = ["std"], default-features = false }
1414+axum = { version = "0.8.8", features = [
1515+ "http1",
1616+ "http2",
1717+ "json",
1818+ "matched-path",
1919+ "tokio",
2020+ "tower-log",
2121+ "tracing",
2222+], default-features = false }
2323+axum-extra = { version = "0.12.5", features = [
2424+ "typed-header",
2525+ "tracing",
2626+], default-features = false }
2727+bytes = { version = "1.11.1", features = ["std"], default-features = false }
2828+bytesize = { version = "2.3.1", features = ["std"], default-features = false }
2929+cid = { version = "0.11.1", features = ["std"], default-features = false }
3030+clap = { version = "4.5.60", features = [
3131+ "color",
3232+ "derive",
3333+ "env",
3434+ "error-context",
3535+ "help",
3636+ "std",
3737+ "suggestions",
3838+ "usage",
3939+], default-features = false }
4040+dotenvy = { version = "0.15.7", default-features = false }
4141+futures-util = { version = "0.3.32", default-features = false }
4242+humantime = { version = "2.3.0", default-features = false }
4343+infer = { version = "0.19.0", default-features = false, features = ["std"] }
4444+jacquard-axum = { version = "0.11.0", default-features = false, features = [
4545+ "tracing",
4646+] }
4747+jacquard-common = { version = "0.11.0", default-features = false }
4848+jacquard-identity = { version = "0.11.0", features = ["tracing"] }
4949+jemallocator = "0.5.4"
5050+json-subscriber = { version = "0.2.8", default-features = false, features = [
5151+ "tracing-log",
5252+ "env-filter",
5353+] }
5454+lexgen = { path = "../lexgen" }
5555+mime = { version = "0.3.17", default-features = false }
5656+moka = { version = "0.12.14", features = [
5757+ "future",
5858+ "logging",
5959+], default-features = false }
6060+multihash-codetable = { version = "0.2.1", features = [
6161+ "sha2",
6262+ # "blake3", # if it ever gets added to the spec.
6363+ "std",
6464+], default-features = false }
6565+reqwest = { version = "0.12.28", default-features = false, features = [
6666+ "http2",
6767+ "system-proxy",
6868+ "stream",
6969+ "socks",
7070+ "rustls-tls",
7171+ "gzip",
7272+ "brotli",
7373+ "zstd",
7474+ "deflate",
7575+] }
7676+serde = { version = "1.0.228", features = [
7777+ "derive",
7878+ "std",
7979+], default-features = false }
8080+serde_json = "1.0.149"
8181+subtle = { version = "2.6", default-features = false, features = ["std"] }
8282+sysinfo = { version = "0.38.4", default-features = false, features = [
8383+ "system",
8484+] }
8585+thiserror = { version = "2.0.18", default-features = false, features = ["std"] }
8686+tokio = { version = "1.50.0", default-features = false, features = [
8787+ "macros",
8888+ "rt-multi-thread",
8989+ "signal",
9090+ "net",
9191+] }
9292+tower-http = { version = "0.6.8", features = [
9393+ "catch-panic",
9494+ "normalize-path",
9595+ "trace",
9696+ "timeout",
9797+ "tracing",
9898+ "cors",
9999+], default-features = false }
100100+tracing = { version = "0.1.44", features = [
101101+ "attributes",
102102+ "std",
103103+], default-features = false }
104104+tracing-subscriber = { version = "0.3.22", features = [
105105+ "ansi",
106106+ "env-filter",
107107+ "fmt",
108108+ "parking_lot",
109109+ "smallvec",
110110+ "std",
111111+ "tracing",
112112+ "tracing-log",
113113+], default-features = false }
···11+mod purge_actor;
22+mod purge_blob;
33+44+pub use purge_actor::xrpc_cache_purge_actor_handler;
55+pub use purge_blob::xrpc_cache_purge_blob_handler;
···66};
77use bytes::Bytes;
88use cid::Cid;
99+use core::{num::NonZeroU64, time::Duration};
910use jacquard_common::types::did::Did;
1011use mime::Mime;
1112use moka::{future::Cache as MokaCache, policy::EvictionPolicy};
1213use multihash_codetable::{Code, MultihashDigest};
1314use reqwest::{StatusCode, header, header::HeaderValue};
1414-use std::{num::NonZeroU64, sync::Arc, time::Duration};
1515+use std::sync::Arc;
1516use thiserror::Error;
1617use tracing::instrument;
1718···172173 }
173174 };
174175175175- let validated_bytes = {
176176+ let bytes = {
176177 let response = self.http_client.get(blob_url).send().await.map_err(|err| {
177178 tracing::warn!("failed to request blob from origin: {err:?}");
178179 BlobDownloadError::FetchFailure
···211212 //
212213 // This operation is done via spawn_blocking as creating the digest will block
213214 // this task's executor from switching to other tasks for as long it runs.
215215+ //
216216+ // Passes the bytes as a return value instead of incrementing the reference count.
214217 tokio::task::spawn_blocking({
215215- let bytes = bytes.clone();
216218 let cid = *cid;
217219 move || {
218220 // Enabled Multihashes are set in the multihash-codetable crate features.
···234236 return Err(BlobDownloadError::CidMismatch);
235237 }
236238237237- Ok(())
239239+ Ok(bytes)
238240 }
239241 })
240242 .await
241241- .expect("CID computing task should not panic")?;
242242-243243- bytes
243243+ .expect("CID computing task should not panic")?
244244 };
245245246246 // Infer MIME type from content bytes rather than headers; this is fallible
···248248 //
249249 // TODO: Merge this with the download stream process to reject bad MIMEs
250250 // early?
251251- let mime_type = sniff_mime(&validated_bytes);
251251+ let mime_type = sniff_mime(&bytes);
252252 if !is_mime_allowed(&mime_type, allowed_mimetypes) {
253253 tracing::debug!("blob was inferred to be a disallowed mime type: {mime_type}");
254254 return Err(BlobDownloadError::ForbiddenMimeType);
···257257 // Mark this DID+CID pair as ownership-verified since we just fetched it from the origin.
258258 self.ownership_cache.insert((*cid, did.clone()), ()).await;
259259260260- Ok(BlobData {
261261- bytes: validated_bytes,
262262- mime_type,
263263- })
260260+ Ok(BlobData { bytes, mime_type })
264261 })
265262 .await
266266- }
267267-268268- pub async fn invalidate_blob(&self, cid: &BlobCid) {
269269- self.data_cache.invalidate(cid).await
270263 }
271264272265 /// Fetch whether the user owns the given blob either from the cache if available or the upstream source.
···337330 .await
338331 }
339332340340- pub fn invalidate_blob_ownership<
333333+ /// Invalid a specific blob cache entry.
334334+ pub async fn invalidate_blob_cache_entry(&self, cid: &BlobCid) {
335335+ self.data_cache.invalidate(cid).await
336336+ }
337337+338338+ /// Invalidate blob ownership cache entries if they match the predicate.
339339+ pub fn invalidate_blob_ownership_cache_entries<
341340 F: Fn(&(BlobCid, Did<'static>), &()) -> bool + Send + Sync + 'static,
342341 >(
343342 &self,