feat: share reviewed website archives and repair companion setup flows
This commit is contained in:
@@ -86,3 +86,17 @@ reboot, integrated website archive acceptance, then reviewed source/mirror parit
|
|||||||
and signed catalogue gates. Selective public asset routes remain separate work;
|
and signed catalogue gates. Selective public asset routes remain separate work;
|
||||||
the authenticated app address must never be advertised as a public Blossom URL.
|
the authenticated app address must never be advertised as a public Blossom URL.
|
||||||
Restore dashboard 2FA with the operator after live testing.
|
Restore dashboard 2FA with the operator after live testing.
|
||||||
|
|
||||||
|
### Companion follow-up — 2026-10-08
|
||||||
|
|
||||||
|
Blossom now requests the canonical identity chooser once when opened, identifies
|
||||||
|
itself explicitly to the tab signer, and disables the provider's unrelated
|
||||||
|
NIP-98 web-app login. Cancelled selection leaves a retry button; uploads still
|
||||||
|
require file review and signer approval. A host signer bug sent a Vue reactive
|
||||||
|
Proxy through postMessage after selection, closing the picker but stranding the
|
||||||
|
app behind an empty signer. The host now copies only public identity fields.
|
||||||
|
The reactive-object regression test and a real direct-app mobile-width browser
|
||||||
|
check pass: automatic chooser, closed signer, visible app, denied upload and
|
||||||
|
approved local upload. Physical companion confirmation remains pending.
|
||||||
|
The corrected image is a private rebuild of the existing candidate tag; assign
|
||||||
|
an updated package/image version before reviewed catalogue publication.
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ impl RpcHandler {
|
|||||||
"grants": grants,
|
"grants": grants,
|
||||||
"nostr_relays": self.config.nostr_relays,
|
"nostr_relays": self.config.nostr_relays,
|
||||||
"publication_enabled": true,
|
"publication_enabled": true,
|
||||||
|
"public_archive_enabled": true,
|
||||||
"listeners": publishing::serving::status().await,
|
"listeners": publishing::serving::status().await,
|
||||||
"onions": publishing::tor::status().await,
|
"onions": publishing::tor::status().await,
|
||||||
"notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Nostr publishing requires an explicit identity, Blossom server and relay selection. Automated gateway setup is not enabled yet. Saving choices does not change app access; external verification is separate.",
|
"notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Nostr publishing requires an explicit identity, Blossom server and relay selection. Automated gateway setup is not enabled yet. Saving choices does not change app access; external verification is separate.",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ pub struct LocalArchive {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Publication {
|
pub struct Publication {
|
||||||
|
/// Exact archived bytes explicitly approved for public hash-addressed reads.
|
||||||
|
#[serde(default)]
|
||||||
|
pub public_archive: Option<String>,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub html: String,
|
pub html: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -122,6 +125,15 @@ pub enum Change {
|
|||||||
domain: Option<Domain>,
|
domain: Option<Domain>,
|
||||||
html: String,
|
html: String,
|
||||||
},
|
},
|
||||||
|
ShareArchive {
|
||||||
|
id: String,
|
||||||
|
route: Route,
|
||||||
|
acknowledge_public: bool,
|
||||||
|
},
|
||||||
|
UnshareArchive {
|
||||||
|
id: String,
|
||||||
|
route: Route,
|
||||||
|
},
|
||||||
PublishFips {
|
PublishFips {
|
||||||
id: String,
|
id: String,
|
||||||
acknowledge_public: bool,
|
acknowledge_public: bool,
|
||||||
@@ -268,6 +280,31 @@ impl State {
|
|||||||
p.local_archive = Some(receipt);
|
p.local_archive = Some(receipt);
|
||||||
Ok(Some(id))
|
Ok(Some(id))
|
||||||
}
|
}
|
||||||
|
Change::ShareArchive { id, route, acknowledge_public } => {
|
||||||
|
if !acknowledge_public { bail!("Confirm public access to the exact archived website bytes"); }
|
||||||
|
let p = self.projects.get_mut(&id).context("Website project not found")?;
|
||||||
|
let archive = p.local_archive.as_ref().context("Store this website in local Blossom first")?;
|
||||||
|
let publication = match route {
|
||||||
|
Route::Fips => p.fips_publication.as_mut(),
|
||||||
|
Route::Tor => p.tor_publication.as_mut(),
|
||||||
|
_ => bail!("Choose the FIPS/public-web or Tor publication"),
|
||||||
|
}.context("Publish this connection before sharing its archived file")?;
|
||||||
|
if archive.sha256 != nsite::hash(publication.html.as_bytes()) || archive.size != publication.html.len() {
|
||||||
|
bail!("The archive differs from this published version. Store and publish the same version first");
|
||||||
|
}
|
||||||
|
publication.public_archive = Some(archive.sha256.clone());
|
||||||
|
Ok(Some(id))
|
||||||
|
}
|
||||||
|
Change::UnshareArchive { id, route } => {
|
||||||
|
let p = self.projects.get_mut(&id).context("Website project not found")?;
|
||||||
|
let publication = match route {
|
||||||
|
Route::Fips => p.fips_publication.as_mut(),
|
||||||
|
Route::Tor => p.tor_publication.as_mut(),
|
||||||
|
_ => bail!("Choose the FIPS/public-web or Tor publication"),
|
||||||
|
}.context("This connection is not published")?;
|
||||||
|
publication.public_archive = None;
|
||||||
|
Ok(Some(id))
|
||||||
|
}
|
||||||
Change::RecordNsite { id, receipt } => {
|
Change::RecordNsite { id, receipt } => {
|
||||||
receipt.validate(&id)?;
|
receipt.validate(&id)?;
|
||||||
let p = self
|
let p = self
|
||||||
@@ -379,6 +416,7 @@ impl State {
|
|||||||
.context("No website ports available")?,
|
.context("No website ports available")?,
|
||||||
};
|
};
|
||||||
p.fips_publication = Some(Publication {
|
p.fips_publication = Some(Publication {
|
||||||
|
public_archive: None,
|
||||||
port,
|
port,
|
||||||
html: p.draft.clone(),
|
html: p.draft.clone(),
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
@@ -411,6 +449,7 @@ impl State {
|
|||||||
.context("No onion website ports available")?,
|
.context("No onion website ports available")?,
|
||||||
};
|
};
|
||||||
p.tor_publication = Some(Publication {
|
p.tor_publication = Some(Publication {
|
||||||
|
public_archive: None,
|
||||||
port,
|
port,
|
||||||
html: p.draft.clone(),
|
html: p.draft.clone(),
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
@@ -481,7 +520,8 @@ pub async fn load(root: &Path) -> Result<State> {
|
|||||||
(&project.tor_publication, 32100..32132),
|
(&project.tor_publication, 32100..32132),
|
||||||
] {
|
] {
|
||||||
if let Some(p) = publication {
|
if let Some(p) = publication {
|
||||||
if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML {
|
if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML
|
||||||
|
|| p.public_archive.as_ref().is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes())) {
|
||||||
bail!("Invalid stored website publication; existing state has been preserved");
|
bail!("Invalid stored website publication; existing state has been preserved");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,13 +53,23 @@ pub(super) fn response_for(
|
|||||||
else {
|
else {
|
||||||
return simple(StatusCode::NOT_FOUND, "Website is not published");
|
return simple(StatusCode::NOT_FOUND, "Website is not published");
|
||||||
};
|
};
|
||||||
|
// Only the selected immutable snapshot is exposed, never the Blossom backend.
|
||||||
|
// No listing, upload, arbitrary hash lookup, filesystem access or credentials.
|
||||||
|
let asset = publication.public_archive.as_ref().is_some_and(|hash| {
|
||||||
|
req.uri().path() == format!("/{hash}") && *hash == super::nsite::hash(publication.html.as_bytes())
|
||||||
|
});
|
||||||
|
if asset && req.method() == Method::OPTIONS {
|
||||||
|
let mut response = simple(StatusCode::NO_CONTENT, "");
|
||||||
|
asset_headers(&mut response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
if req.method() != Method::GET && req.method() != Method::HEAD {
|
if req.method() != Method::GET && req.method() != Method::HEAD {
|
||||||
return simple(
|
return simple(
|
||||||
StatusCode::METHOD_NOT_ALLOWED,
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
"Only GET and HEAD are supported",
|
"Only GET and HEAD are supported",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !matches!(req.uri().path(), "/" | "/index.html") {
|
if !asset && !matches!(req.uri().path(), "/" | "/index.html") {
|
||||||
return simple(StatusCode::NOT_FOUND, "Not found");
|
return simple(StatusCode::NOT_FOUND, "Not found");
|
||||||
}
|
}
|
||||||
let mut response = simple(StatusCode::OK, "");
|
let mut response = simple(StatusCode::OK, "");
|
||||||
@@ -70,11 +80,20 @@ pub(super) fn response_for(
|
|||||||
"content-length",
|
"content-length",
|
||||||
publication.html.len().to_string().parse().unwrap(),
|
publication.html.len().to_string().parse().unwrap(),
|
||||||
);
|
);
|
||||||
|
if asset { asset_headers(&mut response); }
|
||||||
if req.method() == Method::GET {
|
if req.method() == Method::GET {
|
||||||
*response.body_mut() = Body::from(publication.html.clone());
|
*response.body_mut() = Body::from(publication.html.clone());
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
fn asset_headers(response: &mut Response<Body>) {
|
||||||
|
for (name, value) in [
|
||||||
|
("access-control-allow-origin", "*"),
|
||||||
|
("access-control-allow-methods", "GET, HEAD, OPTIONS"),
|
||||||
|
("access-control-expose-headers", "Content-Length, Content-Type"),
|
||||||
|
("content-disposition", "attachment; filename=\"index.html\""),
|
||||||
|
] { response.headers_mut().insert(name, value.parse().unwrap()); }
|
||||||
|
}
|
||||||
fn simple(status: StatusCode, body: &str) -> Response<Body> {
|
fn simple(status: StatusCode, body: &str) -> Response<Body> {
|
||||||
let mut r = Response::new(Body::from(body.to_owned()));
|
let mut r = Response::new(Body::from(body.to_owned()));
|
||||||
*r.status_mut() = status;
|
*r.status_mut() = status;
|
||||||
@@ -241,6 +260,50 @@ pub(super) async fn listen(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_archive_is_exact_explicit_route_scoped_and_revocable() {
|
||||||
|
use crate::publishing::{Change, LocalArchive, Route};
|
||||||
|
let mut state = State::default();
|
||||||
|
let id = state.apply(Change::Create { name: "Archive".into() }).unwrap().unwrap();
|
||||||
|
state.apply(Change::Save { id: id.clone(), name: "Archive".into(), routes: [Route::Fips, Route::Tor].into_iter().collect(), domain: None, html: "public snapshot".into() }).unwrap();
|
||||||
|
state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap();
|
||||||
|
state.apply(Change::PublishTor { id: id.clone(), acknowledge_public: true }).unwrap();
|
||||||
|
let port = state.projects[&id].fips_publication.as_ref().unwrap().port;
|
||||||
|
let tor_port = state.projects[&id].tor_publication.as_ref().unwrap().port;
|
||||||
|
let hash = crate::publishing::nsite::hash(b"public snapshot");
|
||||||
|
let req = Request::builder().uri(format!("/{hash}")).body(Body::empty()).unwrap();
|
||||||
|
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
|
||||||
|
assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).is_err());
|
||||||
|
state.apply(Change::RecordLocalArchive { id: id.clone(), receipt: LocalArchive { sha256: hash.clone(), size: 15, pubkey: "a".repeat(64), created_at: "now".into() } }).unwrap();
|
||||||
|
assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: false }).is_err());
|
||||||
|
state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap();
|
||||||
|
assert_eq!(response_for(&state, &id, tor_port, Route::Tor, &req).status(), StatusCode::NOT_FOUND);
|
||||||
|
let r = response(&state, &id, port, &req);
|
||||||
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
|
assert_eq!(r.headers()["access-control-allow-origin"], "*");
|
||||||
|
assert!(r.headers()["content-disposition"].to_str().unwrap().starts_with("attachment"));
|
||||||
|
assert_eq!(r.headers()["content-security-policy"], CSP);
|
||||||
|
assert_eq!(hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(), b"public snapshot");
|
||||||
|
for path in ["/upload", "/list", "/0000000000000000000000000000000000000000000000000000000000000000", "/../state.json"] {
|
||||||
|
let r = Request::builder().uri(path).body(Body::empty()).unwrap();
|
||||||
|
assert_eq!(response(&state, &id, port, &r).status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
let head = Request::builder().method(Method::HEAD).uri(format!("/{hash}")).body(Body::empty()).unwrap();
|
||||||
|
let r = response(&state, &id, port, &head);
|
||||||
|
assert_eq!(r.headers()["content-length"], "15");
|
||||||
|
assert!(hyper::body::to_bytes(r.into_body()).await.unwrap().is_empty());
|
||||||
|
let post = Request::builder().method(Method::PUT).uri(format!("/{hash}")).body(Body::empty()).unwrap();
|
||||||
|
assert_eq!(response(&state, &id, port, &post).status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
state.projects.get_mut(&id).unwrap().draft = "private later edits".into();
|
||||||
|
assert_eq!(hyper::body::to_bytes(response(&state, &id, port, &req).into_body()).await.unwrap().as_ref(), b"public snapshot");
|
||||||
|
state.apply(Change::UnshareArchive { id: id.clone(), route: Route::Fips }).unwrap();
|
||||||
|
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
|
||||||
|
state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap();
|
||||||
|
state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap();
|
||||||
|
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
|
||||||
|
assert!(state.apply(Change::ShareArchive { id, route: Route::Fips, acknowledge_public: true }).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn draft_changes_never_leak_and_unpublish_revokes() {
|
async fn draft_changes_never_leak_and_unpublish_revokes() {
|
||||||
use crate::publishing::{Change, Route};
|
use crate::publishing::{Change, Route};
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ async function auth(action: string, hash?: string) {
|
|||||||
if (signed.pubkey !== pubkey) throw new Error('Signer returned another identity. Nothing was sent.');
|
if (signed.pubkey !== pubkey) throw new Error('Signer returned another identity. Nothing was sent.');
|
||||||
return 'Nostr ' + btoa(JSON.stringify(signed));
|
return 'Nostr ' + btoa(JSON.stringify(signed));
|
||||||
}
|
}
|
||||||
element('identity').onclick = () => perform(async () => {
|
async function chooseIdentity() { await perform(async () => {
|
||||||
|
pubkey = ''; approve.checked = false; element('files').replaceChildren();
|
||||||
|
element('pubkey').textContent = 'Choose a profile in the Archipelago signer…';
|
||||||
if (!window.nostr) throw new Error('Archipelago signer is unavailable');
|
if (!window.nostr) throw new Error('Archipelago signer is unavailable');
|
||||||
await window.archipelagoNostr?.selectIdentity?.();
|
await window.archipelagoNostr?.selectIdentity?.();
|
||||||
const key = await window.nostr.getPublicKey();
|
const key = await window.nostr.getPublicKey();
|
||||||
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid signer identity');
|
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid signer identity');
|
||||||
pubkey = key; approve.checked = false;
|
pubkey = key; approve.checked = false;
|
||||||
element('pubkey').textContent = key; element('files').replaceChildren();
|
element('pubkey').textContent = key; element('files').replaceChildren();
|
||||||
});
|
}); }
|
||||||
|
element('identity').onclick = chooseIdentity;
|
||||||
fileInput.onchange = () => {
|
fileInput.onchange = () => {
|
||||||
approve.checked = false;
|
approve.checked = false;
|
||||||
const file = fileInput.files?.[0];
|
const file = fileInput.files?.[0];
|
||||||
@@ -78,3 +81,7 @@ refresh.onclick = () => perform(async () => {
|
|||||||
item.append(link); list.append(item);
|
item.append(link); list.append(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request the canonical chooser once on opening. Cancellation leaves the page
|
||||||
|
// usable with a retry button; this never signs an upload or publishes an event.
|
||||||
|
void chooseIdentity();
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src http: https:; base-uri 'none'; form-action 'none'"><title>Blossom · Archipelago</title><script src="/nostr-provider.js?v=tab-signer-v4"></script><script type="module" src="/app.js"></script><style>*{box-sizing:border-box}input{max-width:100%}body{font:16px system-ui;background:#11151c;color:#eee;max-width:760px;margin:auto;padding:32px}h1{font-size:32px}section{padding:24px;border:1px solid #384150;border-radius:16px;margin:20px 0}button,input{font:inherit}button{padding:10px 16px;border:0;border-radius:8px;background:#d9a86c;color:#161616;cursor:pointer}button:disabled{opacity:.45;cursor:default}p{line-height:1.5;color:#c8ccd4;overflow-wrap:anywhere}li{overflow-wrap:anywhere}code{overflow-wrap:anywhere}label{display:block;margin:16px 0}a{color:#e9b983}#status{white-space:pre-wrap}</style></head><body><h1>Blossom on your node</h1><p>Store files with your Archipelago identity. Files stay on this node. Uploading here does not publish a Nostr event or send a copy to another server.</p><section><h2>Your identity</h2><button id="identity">Choose identity</button><p id="pubkey">Choose a profile identity to manage its files.</p><p>After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.</p></section><section><h2>Store a file</h2><input id="file" type="file"><p id="file-review">Choose a file up to 16 MiB. Review it before storing.</p><label><input id="approve" type="checkbox"> I want to store this exact file on this node.</label><button id="upload" disabled>Store locally</button><p>External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.</p></section><section><h2>Your files</h2><button id="refresh" disabled>Load my files</button><ul id="files"></ul></section><p id="status" role="status"></p></body></html>
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src http: https:; base-uri 'none'; form-action 'none'"><title>Blossom · Archipelago</title><script data-app-id="blossom" data-no-nip98 src="/nostr-provider.js?v=tab-signer-v4"></script><script type="module" src="/app.js"></script><style>*{box-sizing:border-box}input{max-width:100%}body{font:16px system-ui;background:#11151c;color:#eee;max-width:760px;margin:auto;padding:32px}h1{font-size:32px}section{padding:24px;border:1px solid #384150;border-radius:16px;margin:20px 0}button,input{font:inherit}button{padding:10px 16px;border:0;border-radius:8px;background:#d9a86c;color:#161616;cursor:pointer}button:disabled{opacity:.45;cursor:default}p{line-height:1.5;color:#c8ccd4;overflow-wrap:anywhere}li{overflow-wrap:anywhere}code{overflow-wrap:anywhere}label{display:block;margin:16px 0}a{color:#e9b983}#status{white-space:pre-wrap}</style></head><body><h1>Blossom on your node</h1><p>Store files with your Archipelago identity. Files stay on this node. Uploading here does not publish a Nostr event or send a copy to another server.</p><section><h2>Your identity</h2><button id="identity">Choose identity</button><p id="pubkey">Choose a profile identity to manage its files.</p><p>After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.</p></section><section><h2>Store a file</h2><input id="file" type="file"><p id="file-review">Choose a file up to 16 MiB. Review it before storing.</p><label><input id="approve" type="checkbox"> I want to store this exact file on this node.</label><button id="upload" disabled>Store locally</button><p>External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.</p></section><section><h2>Your files</h2><button id="refresh" disabled>Load my files</button><ul id="files"></ul></section><p id="status" role="status"></p></body></html>
|
||||||
|
|||||||
@@ -304,3 +304,66 @@ Immich, rejection for dashboard login, and revocation of both bearer and cookie
|
|||||||
access. One-hour expiry metadata was checked; elapsed expiry remains covered by
|
access. One-hour expiry metadata was checked; elapsed expiry remains covered by
|
||||||
unit tests, not a one-hour live wait. The temporary grant was removed. No Nostr
|
unit tests, not a one-hour live wait. The temporary grant was removed. No Nostr
|
||||||
events were posted and no other app data was changed.
|
events were posted and no other app data was changed.
|
||||||
|
|
||||||
|
### Controlled Framework reboot — 2026-10-08
|
||||||
|
|
||||||
|
The operator confirmed physical recovery access and authorized remaining
|
||||||
|
qualification. A fresh native LND snapshot and static channel backup were retained
|
||||||
|
privately on the node before reboot; no pending HTLCs were present. A changed boot
|
||||||
|
ID confirms the full reboot. Native wallet identity, channel set, on-chain and
|
||||||
|
channel balances matched exactly afterward, and LND reported chain sync without
|
||||||
|
manual unlock/restart. Backend and signed-catalogue hashes matched. All app
|
||||||
|
running/stopped states, exact publishing/project/archive state, FIPS address, onion
|
||||||
|
address and guest eligibility survived. Public HTTPS and Tor returned the exact
|
||||||
|
synthetic page. Guest scope, dashboard denial and revocation passed again.
|
||||||
|
|
||||||
|
Physical companion acceptance remains OPEN: the operator found the native
|
||||||
|
`datalist` app picker invisible in the companion, and Blossom blank after choosing
|
||||||
|
an identity. These are tracked as current regressions, not successful companion
|
||||||
|
acceptance. The picker replacement uses an in-page glass menu; the tab signer
|
||||||
|
must copy public identity fields instead of passing a Vue reactive Proxy through
|
||||||
|
postMessage. Blossom also requests the canonical chooser once on opening and
|
||||||
|
disables the unrelated generic NIP-98 web-app login. Deployment and actual-device
|
||||||
|
retest are required before closing these reports. Operator will restore 2FA after
|
||||||
|
the remaining installer/signer tests.
|
||||||
|
|
||||||
|
### Selective public archive implementation — 2026-10-08
|
||||||
|
|
||||||
|
Each FIPS/public-web or Tor publication can separately expose its exact archived
|
||||||
|
HTML snapshot at `/<sha256>`, only after an acknowledged action verifies the
|
||||||
|
local archive receipt matches the published bytes. This is a read-only
|
||||||
|
hash-addressed snapshot route, not a publicly opened Blossom app or upload API.
|
||||||
|
GET/HEAD and CORS reads serve only the selected immutable bytes with sandbox and
|
||||||
|
attachment headers. Unknown hashes, listings and uploads remain unavailable.
|
||||||
|
Later drafts cannot change the served bytes; publishing an update resets archive
|
||||||
|
sharing, and removing sharing does not unpublish the page or remove private files.
|
||||||
|
|
||||||
|
The focused harness and isolated platform suite each passed 12 publishing tests.
|
||||||
|
The candidate backend is deployed on Framework with its preceding executable and
|
||||||
|
publishing state retained under `~/external-access-uat/`. Live trusted HTTPS
|
||||||
|
readback matched the exact snapshot; unknown hashes/list/upload returned 404.
|
||||||
|
Revocation returned the selected hash to 404 while the website still served.
|
||||||
|
The synthetic archive was unshared after the test. No external replica or Nostr
|
||||||
|
announcement was made. UI deployment and live UI acceptance are still pending.
|
||||||
|
|
||||||
|
Stored Publication now has an optional `public_archive` field. Before rolling back
|
||||||
|
to the preceding binary, account for its deny-unknown-fields parser: retain the
|
||||||
|
latest state and migrate only this field away, or restore the pre-test state only
|
||||||
|
if no user changes would be lost. Do not blindly restore an older project file.
|
||||||
|
|
||||||
|
### Companion corrections deployed — 2026-10-08
|
||||||
|
|
||||||
|
The final dashboard build includes the in-page searchable glass app picker and
|
||||||
|
the tab signer's explicit cloneable identity fields. Sixteen UI tests passed,
|
||||||
|
including a structuredClone regression test using a reactive picker identity.
|
||||||
|
Live touch-browser checks at 390px and 1440px opened all six choices, filtered to
|
||||||
|
Immich, selected it and exposed the grant controls without horizontal overflow.
|
||||||
|
The normal Blossom lifecycle rebuilt/restarted the private candidate with
|
||||||
|
`data-app-id="blossom"`, `data-no-nip98` and one automatic chooser request. Its
|
||||||
|
previous image and build context are retained for rollback. The live direct app
|
||||||
|
window reproduced the blank frame before the signer correction; after deployment,
|
||||||
|
automatic selection returned to the visible file page, the signer iframe was
|
||||||
|
hidden, no generic login request occurred, refusal prevented upload and explicit
|
||||||
|
approval stored the synthetic file. Actual phone confirmation is still pending.
|
||||||
|
The archive UI is deployed with backend capability gating; UI tests cover fresh
|
||||||
|
consent on snapshot changes and independent revocation. No public release made.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||||
|
const props = defineProps<{ options: { id: string; name: string }[]; disabled?: boolean }>()
|
||||||
|
const selected = defineModel<string>({ required: true })
|
||||||
|
const root = ref<HTMLElement>()
|
||||||
|
const trigger = ref<HTMLButtonElement>()
|
||||||
|
const search = ref<HTMLInputElement>()
|
||||||
|
const open = ref(false)
|
||||||
|
const query = ref('')
|
||||||
|
const active = ref(0)
|
||||||
|
const uid = useId()
|
||||||
|
const filtered = computed(() => props.options.filter(option => `${option.name} ${option.id}`.toLowerCase().includes(query.value.trim().toLowerCase())))
|
||||||
|
const label = computed(() => props.options.find(option => option.id === selected.value)?.name || 'Choose an app')
|
||||||
|
watch(query, () => { active.value = 0 })
|
||||||
|
watch(() => props.disabled, value => { if (value) open.value = false })
|
||||||
|
watch(() => props.options, options => { if (selected.value && !options.some(option => option.id === selected.value)) selected.value = '' })
|
||||||
|
function toggle() { open.value = !open.value; query.value = ''; active.value = 0 }
|
||||||
|
function choose(id: string) { selected.value = id; open.value = false; trigger.value?.focus() }
|
||||||
|
function outside(event: Event) { if (!root.value?.contains(event.target as Node)) open.value = false }
|
||||||
|
async function keyboard(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); open.value = false; trigger.value?.focus(); return }
|
||||||
|
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return
|
||||||
|
if (!open.value) {
|
||||||
|
if (event.key === 'Enter') return // native button click opens it
|
||||||
|
event.preventDefault(); toggle(); await nextTick(); search.value?.focus(); return
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && event.target === search.value) { event.preventDefault(); if (filtered.value[active.value]) choose(filtered.value[active.value]!.id); return }
|
||||||
|
if (event.key === 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
active.value = Math.max(0, Math.min(filtered.value.length - 1, active.value + (event.key === 'ArrowDown' ? 1 : -1)))
|
||||||
|
search.value?.focus()
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById(`${uid}-option-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
onMounted(() => { document.addEventListener('pointerdown', outside); document.addEventListener('focusin', outside) })
|
||||||
|
onBeforeUnmount(() => { document.removeEventListener('pointerdown', outside); document.removeEventListener('focusin', outside) })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="min-w-0" @keydown="keyboard">
|
||||||
|
<span :id="`${uid}-label`" class="block mb-2">Application</span>
|
||||||
|
<button ref="trigger" type="button" class="w-full flex items-center justify-between gap-3 rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-left text-sm text-white/90" :disabled="disabled" :aria-labelledby="`${uid}-label ${uid}-value`" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="`${uid}-list`" @click="toggle">
|
||||||
|
<span :id="`${uid}-value`" class="truncate">{{ label }}</span>
|
||||||
|
<svg class="w-4 h-4 shrink-0 text-white/55" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
<!-- In normal flow so a walkthrough card, keyboard or WebView cannot clip it. -->
|
||||||
|
<div v-if="open" class="glass-card rounded-lg mt-2 p-2 border border-white/10 shadow-xl">
|
||||||
|
<input ref="search" v-model="query" type="search" role="combobox" aria-label="Search apps" aria-autocomplete="list" aria-expanded="true" :aria-controls="`${uid}-list`" :aria-activedescendant="filtered.length ? `${uid}-option-${active}` : undefined" autocomplete="off" class="w-full rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-sm text-white mb-2" placeholder="Search apps…" />
|
||||||
|
<div :id="`${uid}-list`" role="listbox" aria-label="Supported applications" class="max-h-56 overflow-y-auto overscroll-contain">
|
||||||
|
<button v-for="(option, index) in filtered" :id="`${uid}-option-${index}`" :key="option.id" type="button" role="option" :aria-selected="selected === option.id" class="w-full flex items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-sm text-white/80 hover:bg-white/10 focus-visible:bg-white/10" :class="{ 'bg-white/10 text-white': active === index || selected === option.id }" @click="choose(option.id)">
|
||||||
|
<span class="truncate">{{ option.name }}</span><span v-if="selected === option.id" aria-hidden="true" class="text-orange-300">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!filtered.length" role="status" class="px-3 py-4 text-sm text-white/60">No matching apps. Try another name.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { websitePreview } from '@/services/publishing'
|
||||||
|
const props = defineProps<{
|
||||||
|
publication: { html: string; public_archive?: string | null }
|
||||||
|
archive?: { sha256: string; size: number } | null
|
||||||
|
address?: string | null
|
||||||
|
busy?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ change: [enabled: boolean] }>()
|
||||||
|
const approved = ref(false)
|
||||||
|
watch(() => [props.publication, props.archive], () => { approved.value = false })
|
||||||
|
const fileAddress = computed(() => props.address && props.publication.public_archive
|
||||||
|
? `${props.address.replace(/\/$/, '')}/${props.publication.public_archive}` : null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details class="rounded-xl border border-white/10 p-4">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-white/80">Share the archived website file</summary>
|
||||||
|
<div class="space-y-4 pt-4">
|
||||||
|
<p class="text-sm text-white/60">Let visitors download this exact website snapshot by its Blossom content hash. Other files and the Blossom app stay private. No upload or Nostr announcement is made.</p>
|
||||||
|
<template v-if="publication.public_archive">
|
||||||
|
<p class="text-sm">This snapshot is shared on this connection.</p>
|
||||||
|
<p v-if="fileAddress" class="font-mono text-xs break-all select-all">{{ fileAddress }}</p>
|
||||||
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="emit('change', false)">Stop sharing this file</button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="archive">
|
||||||
|
<p class="text-sm text-white/60">The archived file must match the published preview below. Store and publish the same version before sharing.</p>
|
||||||
|
<p class="font-mono text-xs break-all">Archived SHA-256: {{ archive.sha256 }} · {{ archive.size }} bytes</p>
|
||||||
|
<iframe :srcdoc="websitePreview(publication.html)" sandbox="" referrerpolicy="no-referrer" title="Archived file publication preview" class="w-full h-64 rounded-xl bg-white" />
|
||||||
|
<label class="flex items-start gap-3 text-sm"><input v-model="approved" type="checkbox" class="mt-1" /><span>I approve public downloads of this exact snapshot on this connection. Copies may remain after I stop sharing.</span></label>
|
||||||
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !approved" @click="emit('change', true)">Share this archived file</button>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-sm text-white/60">In “Create your website”, save a signed copy in local Blossom first.</p>
|
||||||
|
<p class="text-xs text-white/50">Publishing an update turns file sharing off until you review that version again.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import SearchableAppSelect from '../SearchableAppSelect.vue'
|
||||||
|
const options = [{ id: 'homeassistant', name: 'Home Assistant' }, { id: 'immich', name: 'Immich' }]
|
||||||
|
describe('searchable app picker', () => {
|
||||||
|
it('renders actual touchable options, filters them and selects only an offered app', async () => {
|
||||||
|
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.findAll('[role="option"]')).toHaveLength(2)
|
||||||
|
await wrapper.get('input').setValue('imm')
|
||||||
|
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
|
||||||
|
await wrapper.get('[role="option"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toEqual([['immich']])
|
||||||
|
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
it('supports keyboard choice and escape without selecting arbitrary search text', async () => {
|
||||||
|
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
|
||||||
|
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
|
||||||
|
await wrapper.get('input').setValue('home')
|
||||||
|
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toEqual([['homeassistant']])
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await wrapper.get('input').setValue('unknown')
|
||||||
|
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||||
|
await wrapper.get('input').trigger('keydown', { key: 'Escape' })
|
||||||
|
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import WebsiteArchiveSharing from '../WebsiteArchiveSharing.vue'
|
||||||
|
|
||||||
|
describe('archived website sharing consent', () => {
|
||||||
|
it('requires fresh approval after the reviewed snapshot changes', async () => {
|
||||||
|
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: '<h1>Reviewed</h1>' }, archive: { sha256: 'a'.repeat(64), size: 17 } } })
|
||||||
|
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
|
||||||
|
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<h1>Reviewed</h1>')
|
||||||
|
await wrapper.get('input').setValue(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('change')).toEqual([[true]])
|
||||||
|
await wrapper.setProps({ publication: { html: '<h1>Different</h1>' } })
|
||||||
|
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.emitted('change')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('keeps removal available without new approval and exposes only the selected hash', async () => {
|
||||||
|
const hash = 'a'.repeat(64)
|
||||||
|
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: 'public', public_archive: hash }, address: 'https://example.com/' } })
|
||||||
|
expect(wrapper.text()).toContain('https://example.com/'+hash)
|
||||||
|
expect(wrapper.find('input').exists()).toBe(false)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('change')).toEqual([[false]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,8 +7,8 @@ export interface WebsiteRevision { id: string; created_at: string; html: string
|
|||||||
export interface WebsiteProject {
|
export interface WebsiteProject {
|
||||||
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
|
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
|
||||||
draft: string; revisions: WebsiteRevision[]
|
draft: string; revisions: WebsiteRevision[]
|
||||||
fips_publication?: { port: number; html: string; created_at: string } | null
|
fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
|
||||||
tor_publication?: { port: number; html: string; created_at: string } | null
|
tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
|
||||||
nsite_receipt?: NsiteReceipt | null
|
nsite_receipt?: NsiteReceipt | null
|
||||||
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
|
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ export interface PublishingState {
|
|||||||
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
|
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
|
||||||
}
|
}
|
||||||
export interface PublishingStatus {
|
export interface PublishingStatus {
|
||||||
state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string
|
state: PublishingState; fips_address: string | null; publication_enabled: boolean; public_archive_enabled?: boolean; notice: string
|
||||||
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
|
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
|
||||||
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
|
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
|
||||||
nostr_relays?: string[]
|
nostr_relays?: string[]
|
||||||
|
|||||||
@@ -74,7 +74,14 @@ function hideSigner(delay = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendIdentity(identity: SelectedIdentity) {
|
function sendIdentity(identity: SelectedIdentity) {
|
||||||
parentPost({ type: 'archipelago:signer-identity', identity })
|
// Picker entries are Vue reactive objects. postMessage cannot clone a Proxy;
|
||||||
|
// sending it directly closes the picker and then throws, stranding the app
|
||||||
|
// behind the otherwise empty signer frame. Copy only the public fields.
|
||||||
|
parentPost({ type: 'archipelago:signer-identity', identity: {
|
||||||
|
id: identity.id, name: identity.name, did: identity.did,
|
||||||
|
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey,
|
||||||
|
nostr_npub: identity.nostr_npub,
|
||||||
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge = useNostrBridge(getStoredIdentity, {
|
const bridge = useNostrBridge(getStoredIdentity, {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
import { shallowMount } from '@vue/test-utils'
|
import { shallowMount } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import NostrTabSigner from '@/views/NostrTabSigner.vue'
|
import NostrTabSigner from '@/views/NostrTabSigner.vue'
|
||||||
@@ -16,6 +17,23 @@ describe('NostrTabSigner visibility', () => {
|
|||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('sends cloneable public identity fields and hides the frame after picker selection', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(message => {
|
||||||
|
structuredClone(message) // Browser postMessage rejects Vue reactive proxies.
|
||||||
|
})
|
||||||
|
const wrapper = shallowMount(NostrTabSigner)
|
||||||
|
try {
|
||||||
|
parentMessage({ type: 'archipelago:signer-init', appId: 'blossom', appName: 'Blossom' })
|
||||||
|
const identity = reactive({ id: 'profile', name: 'Alice', did: 'did:example:alice', pubkey: 'public', nostr_pubkey: 'a'.repeat(64) })
|
||||||
|
wrapper.findComponent({ name: 'NostrIdentityPicker' }).vm.$emit('select', identity)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-identity', identity: expect.objectContaining({ id: 'profile' }) }), window.location.origin)
|
||||||
|
await vi.advanceTimersByTimeAsync(450)
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:signer-hide' }, window.location.origin)
|
||||||
|
} finally { wrapper.unmount(); vi.useRealTimers() }
|
||||||
|
})
|
||||||
|
|
||||||
it('does not reveal the full-screen frame for a silent remembered request', async () => {
|
it('does not reveal the full-screen frame for a silent remembered request', async () => {
|
||||||
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
|
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
|
||||||
id: 'identity-a',
|
id: 'identity-a',
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
|
|||||||
|
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import SetupWalkthrough from '@/components/SetupWalkthrough.vue'
|
import SetupWalkthrough from '@/components/SetupWalkthrough.vue'
|
||||||
|
import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue'
|
||||||
|
import SearchableAppSelect from '@/components/SearchableAppSelect.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -41,16 +43,12 @@ const relays = ref('')
|
|||||||
const gateway = ref('')
|
const gateway = ref('')
|
||||||
const nsiteUrl = ref('')
|
const nsiteUrl = ref('')
|
||||||
const guestApp = ref('')
|
const guestApp = ref('')
|
||||||
const guestSearch = ref('')
|
|
||||||
const guestLabel = ref('Guest')
|
const guestLabel = ref('Guest')
|
||||||
const guestHours = ref(24)
|
const guestHours = ref(24)
|
||||||
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
|
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
|
||||||
onDeactivated(() => { issuedAccess.value = null })
|
onDeactivated(() => { issuedAccess.value = null })
|
||||||
onBeforeUnmount(() => { issuedAccess.value = null })
|
onBeforeUnmount(() => { issuedAccess.value = null })
|
||||||
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
|
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
|
||||||
watch(guestSearch, value => {
|
|
||||||
guestApp.value = shareableApps.value.find(app => `${app.name} (${app.id})` === value)?.id ?? ''
|
|
||||||
})
|
|
||||||
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
|
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
|
||||||
const acknowledgeNostr = ref(false)
|
const acknowledgeNostr = ref(false)
|
||||||
const acknowledgeUpload = ref(false)
|
const acknowledgeUpload = ref(false)
|
||||||
@@ -195,6 +193,16 @@ async function setFipsPublication(enable: boolean) {
|
|||||||
message.value = enable ? 'Saved version selected for FIPS publication. Check listener status, firewall and access from another FIPS device.' : 'FIPS website unpublished. Your draft and revisions are retained.'
|
message.value = enable ? 'Saved version selected for FIPS publication. Check listener status, firewall and access from another FIPS device.' : 'FIPS website unpublished. Your draft and revisions are retained.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
async function setArchiveSharing(route: 'fips' | 'tor', enable: boolean) {
|
||||||
|
await perform(async () => {
|
||||||
|
if (!status.value || !current.value) return
|
||||||
|
const result = await publishing.update(status.value.state.version, enable
|
||||||
|
? { action: 'share-archive', id: projectId.value, route, acknowledge_public: true }
|
||||||
|
: { action: 'unshare-archive', id: projectId.value, route })
|
||||||
|
status.value.state = result.state
|
||||||
|
message.value = enable ? 'This archived snapshot is available on the selected website connection. Other Blossom files remain private.' : 'File sharing stopped on this connection. Copies already downloaded may remain.'
|
||||||
|
})
|
||||||
|
}
|
||||||
async function generate() {
|
async function generate() {
|
||||||
await perform(async () => {
|
await perform(async () => {
|
||||||
const result = await publishing.generate(prompt.value, model.value.trim())
|
const result = await publishing.generate(prompt.value, model.value.trim())
|
||||||
@@ -471,9 +479,7 @@ onMounted(refresh)
|
|||||||
<p v-if="!shareableApps.length" class="rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white/70">No installed apps currently support guest sharing. <RouterLink to="/dashboard/marketplace" class="underline">Browse apps</RouterLink></p>
|
<p v-if="!shareableApps.length" class="rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white/70">No installed apps currently support guest sharing. <RouterLink to="/dashboard/marketplace" class="underline">Browse apps</RouterLink></p>
|
||||||
<p v-if="shareableApps.length" class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
|
<p v-if="shareableApps.length" class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
|
||||||
<template v-if="shareableApps.length">
|
<template v-if="shareableApps.length">
|
||||||
<label class="block">Application<input v-model="guestSearch" list="guest-sharing-apps" autocomplete="off" class="field mt-2" placeholder="Search or choose an app" aria-describedby="guest-app-help" /></label>
|
<SearchableAppSelect v-model="guestApp" :options="shareableApps" :disabled="busy" />
|
||||||
<datalist id="guest-sharing-apps"><option v-for="app in shareableApps" :key="app.id" :value="`${app.name} (${app.id})`" /></datalist>
|
|
||||||
<p id="guest-app-help" class="text-sm text-white/60">Choose a supported app from the suggestions to continue.</p>
|
|
||||||
<template v-if="guestTarget">
|
<template v-if="guestTarget">
|
||||||
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
|
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
|
||||||
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
|
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
|
||||||
@@ -519,6 +525,7 @@ onMounted(refresh)
|
|||||||
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
|
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
|
||||||
<p class="text-white/60">Open this address from another FIPS device to check visitor access.</p>
|
<p class="text-white/60">Open this address from another FIPS device to check visitor access.</p>
|
||||||
<button v-if="selected.includes('public-web')" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="walkthroughStep = 'domain'">Connect or check my HTTPS domain</button>
|
<button v-if="selected.includes('public-web')" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="walkthroughStep = 'domain'">Connect or check my HTTPS domain</button>
|
||||||
|
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.fips_publication" :archive="current.local_archive" :address="selected.includes('public-web') && current.domain?.hostname ? `https://${current.domain.hostname}/` : listener?.address" :busy="busy" @change="setArchiveSharing('fips', $event)" />
|
||||||
<details class="text-xs text-white/50"><summary>Connection details</summary><p class="mt-2">Website port {{ current.fips_publication.port }}. A local listener does not confirm access from another device.</p></details>
|
<details class="text-xs text-white/50"><summary>Connection details</summary><p class="mt-2">Website port {{ current.fips_publication.port }}. A local listener does not confirm access from another device.</p></details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -534,6 +541,7 @@ onMounted(refresh)
|
|||||||
<p v-if="onion?.error" role="alert">{{ onion.error }}</p>
|
<p v-if="onion?.error" role="alert">{{ onion.error }}</p>
|
||||||
<p v-if="onion?.onion_address" class="font-mono select-all break-all">http://{{ onion.onion_address }}/</p>
|
<p v-if="onion?.onion_address" class="font-mono select-all break-all">http://{{ onion.onion_address }}/</p>
|
||||||
<p>{{ onion?.listening ? 'Local website listener is ready. Open the address in Tor Browser to check external access.' : 'Waiting for the website listener. Reload to check.' }}</p>
|
<p>{{ onion?.listening ? 'Local website listener is ready. Open the address in Tor Browser to check external access.' : 'Waiting for the website listener. Reload to check.' }}</p>
|
||||||
|
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.tor_publication" :archive="current.local_archive" :address="onion?.onion_address ? `http://${onion.onion_address}/` : null" :busy="busy" @change="setArchiveSharing('tor', $event)" />
|
||||||
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
|
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -82,9 +82,12 @@ describe('publishing setup', () => {
|
|||||||
const wrapper = mount(PublishingSetup); await flushPromises()
|
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||||
await wrapper.get('[aria-controls="setup-step-sharing"]').trigger('click')
|
await wrapper.get('[aria-controls="setup-step-sharing"]').trigger('click')
|
||||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||||
await wrapper.get('input[list="guest-sharing-apps"]').setValue('not a supported app')
|
await wrapper.get('button[aria-haspopup="listbox"]').trigger('click')
|
||||||
|
await wrapper.get('input[role="combobox"]').setValue('not a supported app')
|
||||||
expect(wrapper.text()).not.toContain('Create app-only access')
|
expect(wrapper.text()).not.toContain('Create app-only access')
|
||||||
await wrapper.get('input[list="guest-sharing-apps"]').setValue('Nextcloud (nextcloud)')
|
expect(wrapper.text()).toContain('No matching apps')
|
||||||
|
await wrapper.get('input[role="combobox"]').setValue('Nextcloud')
|
||||||
|
await wrapper.get('[role="option"]').trigger('click')
|
||||||
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
|
||||||
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
|
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
|
||||||
expect(wrapper.text()).toContain('synthetic-test-token')
|
expect(wrapper.text()).toContain('synthetic-test-token')
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const { createHash } = require('node:crypto');
|
|||||||
await page.goto('http://127.0.0.1:48191/');
|
await page.goto('http://127.0.0.1:48191/');
|
||||||
await page.waitForFunction(()=>typeof window.nostr==='object');
|
await page.waitForFunction(()=>typeof window.nostr==='object');
|
||||||
if(!await page.locator('#upload').isDisabled())throw Error('Upload enabled before consent');
|
if(!await page.locator('#upload').isDisabled())throw Error('Upload enabled before consent');
|
||||||
await page.locator('#identity').click();
|
await page.waitForFunction(()=>window.chooseCalls===1);
|
||||||
await page.waitForFunction(()=>document.querySelector('#pubkey').textContent==='a'.repeat(64));
|
await page.waitForFunction(()=>document.querySelector('#pubkey').textContent==='a'.repeat(64));
|
||||||
await page.locator('#file').setInputFiles({name:'local-fixture.txt',mimeType:'text/plain',buffer:Buffer.from('Synthetic local file')});
|
await page.locator('#file').setInputFiles({name:'local-fixture.txt',mimeType:'text/plain',buffer:Buffer.from('Synthetic local file')});
|
||||||
await page.locator('#approve').check(); await page.locator('#upload').click();
|
await page.locator('#approve').check(); await page.locator('#upload').click();
|
||||||
@@ -35,6 +35,6 @@ const { createHash } = require('node:crypto');
|
|||||||
if(uploads!==1 || outgoing.length)throw Error('Unexpected upload or external request');
|
if(uploads!==1 || outgoing.length)throw Error('Unexpected upload or external request');
|
||||||
if(await page.locator('#approve').isChecked())throw Error('Approval was retained after upload');
|
if(await page.locator('#approve').isChecked())throw Error('Approval was retained after upload');
|
||||||
if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Mobile horizontal overflow');
|
if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Mobile horizontal overflow');
|
||||||
console.log('PASS packaged local UI: identity chooser, explicit consent, signer denial, scoped upload, consent reset, mobile width, no external requests (mock signer/transport; real signer still pending)');
|
console.log('PASS packaged local UI: automatic identity chooser, explicit consent, signer denial, scoped upload, consent reset, mobile width, no external requests (mock signer/transport; real signer still pending)');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
})().catch(e=>{console.error(e);process.exit(1)});
|
})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|||||||
Reference in New Issue
Block a user