Support for Public / Shareable Collections (#1038)

* collections: support toggling collections public/private, viewable via RWP
- backend: add 'public' to collection model, support patching to update
- backend: add .../collections/<id>/public/replay.json for public access
- backend: add CORS handling for public endpoint
- frontend: support 'make shareable / make private' dropdown actions on collection detail + collection list views
- frontend: show shareable / private icons by collection name on detail + list views
- frontend: link to replayweb.page for standalone browsing
- frontend: add embed code popup when a collection is shareable
- refer to public collections as 'shareable' for now

---------
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
This commit is contained in:
Ilya Kreymer 2023-08-03 19:11:01 -07:00 committed by GitHub
parent 62d3399223
commit 362afa47bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 294 additions and 15 deletions

View File

@ -7,7 +7,7 @@ import uuid
from typing import Optional, List from typing import Optional, List
import pymongo import pymongo
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from .basecrawls import SUCCESSFUL_STATES from .basecrawls import SUCCESSFUL_STATES
@ -148,10 +148,14 @@ class CollectionOps:
return await self.get_collection(coll_id, org) return await self.get_collection(coll_id, org)
async def get_collection( async def get_collection(
self, coll_id: uuid.UUID, org: Organization, resources=False self, coll_id: uuid.UUID, org: Organization, resources=False, public_only=False
): ):
"""Get collection by id""" """Get collection by id"""
result = await self.collections.find_one({"_id": coll_id}) query = {"_id": coll_id}
if public_only:
query["isPublic"] = True
result = await self.collections.find_one(query)
if resources: if resources:
result["resources"] = await self.get_collection_crawl_resources( result["resources"] = await self.get_collection_crawl_resources(
coll_id, org coll_id, org
@ -348,6 +352,7 @@ def init_collections_api(app, mdb, crawls, orgs, crawl_manager):
org_crawl_dep = orgs.org_crawl_dep org_crawl_dep = orgs.org_crawl_dep
org_viewer_dep = orgs.org_viewer_dep org_viewer_dep = orgs.org_viewer_dep
org_public = orgs.org_public
@app.post("/orgs/{oid}/collections", tags=["collections"]) @app.post("/orgs/{oid}/collections", tags=["collections"])
async def add_collection( async def add_collection(
@ -428,8 +433,36 @@ def init_collections_api(app, mdb, crawls, orgs, crawl_manager):
coll = await colls.get_collection(coll_id, org, resources=True) coll = await colls.get_collection(coll_id, org, resources=True)
if not coll: if not coll:
raise HTTPException(status_code=404, detail="collection_not_found") raise HTTPException(status_code=404, detail="collection_not_found")
return coll return coll
@app.get(
"/orgs/{oid}/collections/{coll_id}/public/replay.json", tags=["collections"]
)
async def get_collection_public_replay(
response: Response,
coll_id: uuid.UUID,
org: Organization = Depends(org_public),
):
coll = await colls.get_collection(
coll_id, org, resources=True, public_only=True
)
if not coll:
raise HTTPException(status_code=404, detail="collection_not_found")
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "*"
return coll
@app.options(
"/orgs/{oid}/collections/{coll_id}/public/replay.json", tags=["collections"]
)
async def get_replay_preflight(response: Response):
response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "*"
return {}
@app.patch("/orgs/{oid}/collections/{coll_id}", tags=["collections"]) @app.patch("/orgs/{oid}/collections/{coll_id}", tags=["collections"])
async def update_collection( async def update_collection(
coll_id: uuid.UUID, coll_id: uuid.UUID,

View File

@ -465,6 +465,8 @@ class Collection(BaseMongoModel):
# Sorted by count, descending # Sorted by count, descending
tags: Optional[List[str]] = [] tags: Optional[List[str]] = []
isPublic: Optional[bool] = False
# ============================================================================ # ============================================================================
class CollIn(BaseModel): class CollIn(BaseModel):
@ -474,6 +476,8 @@ class CollIn(BaseModel):
description: Optional[str] description: Optional[str]
crawlIds: Optional[List[str]] = [] crawlIds: Optional[List[str]] = []
isPublic: Optional[bool] = False
# ============================================================================ # ============================================================================
class CollOut(Collection): class CollOut(Collection):
@ -488,6 +492,7 @@ class UpdateColl(BaseModel):
name: Optional[str] name: Optional[str]
description: Optional[str] description: Optional[str]
isPublic: Optional[bool]
# ============================================================================ # ============================================================================

View File

@ -45,6 +45,7 @@ class OrgOps:
self.org_viewer_dep = None self.org_viewer_dep = None
self.org_crawl_dep = None self.org_crawl_dep = None
self.org_owner_dep = None self.org_owner_dep = None
self.org_public = None
self.invites = invites self.invites = invites
@ -300,6 +301,13 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep: User):
return org return org
async def org_public(oid: str):
org = await ops.get_org_by_id(uuid.UUID(oid))
if not org:
raise HTTPException(status_code=404, detail="org_not_found")
return org
router = APIRouter( router = APIRouter(
prefix="/orgs/{oid}", prefix="/orgs/{oid}",
dependencies=[Depends(org_dep)], dependencies=[Depends(org_dep)],
@ -310,6 +318,7 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep: User):
ops.org_viewer_dep = org_dep ops.org_viewer_dep = org_dep
ops.org_crawl_dep = org_crawl_dep ops.org_crawl_dep = org_crawl_dep
ops.org_owner_dep = org_owner_dep ops.org_owner_dep = org_owner_dep
ops.org_public = org_public
@app.get("/orgs", tags=["organizations"], response_model=PaginatedResponse) @app.get("/orgs", tags=["organizations"], response_model=PaginatedResponse)
async def get_orgs( async def get_orgs(

View File

@ -402,7 +402,6 @@ def init_users_api(app, user_manager):
} }
for org in user_orgs for org in user_orgs
] ]
print(f"user info with orgs: {user_info}", flush=True)
return user_info return user_info
@users_router.get("/invite/{token}", tags=["invites"]) @users_router.get("/invite/{token}", tags=["invites"])

View File

@ -51,6 +51,9 @@ export class CollectionDetail extends LiteElement {
@state() @state()
private isDescriptionExpanded = false; private isDescriptionExpanded = false;
@state()
private showEmbedInfo = false;
// Use to cancel requests // Use to cancel requests
private getArchivedItemsController: AbortController | null = null; private getArchivedItemsController: AbortController | null = null;
@ -89,12 +92,32 @@ export class CollectionDetail extends LiteElement {
render() { render() {
return html`${this.renderHeader()} return html`${this.renderHeader()}
<header class="md:flex items-center gap-2 pb-3"> <header class="md:flex items-center gap-2 pb-3">
<h1 <div class="flex items-center gap-2 w-full mb-2 md:mb-0">
class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate mb-2 md:mb-0" ${this.collection?.isPublic
> ? html`
<sl-tooltip content=${msg("Shareable")}>
<sl-icon class="text-lg" name="people-fill"></sl-icon>
</sl-tooltip>
`
: html`
<sl-tooltip content=${msg("Private")}>
<sl-icon class="text-lg" name="eye-slash-fill"></sl-icon>
</sl-tooltip>
`}
<h1 class="flex-1 min-w-0 text-xl font-semibold leading-7 truncate">
${this.collection?.name || ${this.collection?.name ||
html`<sl-skeleton class="w-96"></sl-skeleton>`} html`<sl-skeleton class="w-96"></sl-skeleton>`}
</h1> </h1>
</div>
${when(
this.collection?.isPublic,
() => html`
<sl-button size="small" @click=${() => (this.showEmbedInfo = true)}>
<sl-icon name="code-slash"></sl-icon>
View Embed Code
</sl-button>
`
)}
${when(this.isCrawler, this.renderActions)} ${when(this.isCrawler, this.renderActions)}
</header> </header>
<div class="border rounded-lg py-2 mb-3">${this.renderInfoBar()}</div> <div class="border rounded-lg py-2 mb-3">${this.renderInfoBar()}</div>
@ -135,9 +158,85 @@ export class CollectionDetail extends LiteElement {
>Delete Collection</sl-button >Delete Collection</sl-button
> >
</div> </div>
</btrix-dialog>`; </btrix-dialog>
${this.renderShareInfo()}`;
} }
private getPublicReplayURL() {
return new URL(
`/api/orgs/${this.orgId}/collections/${this.collectionId}/public/replay.json`,
window.location.href
).href;
}
private renderShareInfo = () => {
if (!this.collection?.isPublic) {
return;
}
const embedCode = `<replay-web-page source="${this.getPublicReplayURL()}"></replay-web-page>`;
const importCode = `importScripts("https://replayweb.page/sw.js");`;
return html` <btrix-dialog
label=${msg(str`Embed Code for “${this.collection?.name}`)}
?open=${this.showEmbedInfo}
@sl-request-close=${() => (this.showEmbedInfo = false)}
>
<div class="text-left">
<p class="mb-5">
${msg(
html`Embed this collection in another site using these
<strong class="font-medium">ReplayWeb.page</strong> code snippets.`
)}
</p>
<p class="mb-3">
${msg(html`Add the following embed code to your HTML page:`)}
</p>
<div class="relative">
<pre
class="whitespace-pre-wrap mb-5 rounded p-4 bg-slate-50 text-slate-600 text-[0.9em]"
><code>${embedCode}</code></pre>
<div class="absolute top-0 right-0">
<btrix-copy-button
.getValue=${() => embedCode}
content=${msg("Copy Embed Code")}
></btrix-copy-button>
</div>
</div>
<p class="mb-3">
${msg(
html`Add the following JavaScript to
<code class="text-[0.9em]">./replay/sw.js</code>:`
)}
</p>
<div class="relative">
<pre
class="whitespace-pre-wrap mb-5 rounded p-4 bg-slate-50 text-slate-600 text-[0.9em]"
><code>${importCode}</code></pre>
<div class="absolute top-0 right-0">
<btrix-copy-button
.getValue=${() => importCode}
content=${msg("Copy JS")}
></btrix-copy-button>
</div>
</div>
<p>
${msg(
html`See
<a
class="text-primary"
href="https://replayweb.page/docs/embedding"
target="_blank"
>
our embedding guide</a
>
for more details.`
)}
</p>
</div>
</btrix-dialog>`;
};
private renderHeader = () => html` private renderHeader = () => html`
<nav class="mb-7"> <nav class="mb-7">
<a <a
@ -197,6 +296,35 @@ export class CollectionDetail extends LiteElement {
${msg("Edit Collection")} ${msg("Edit Collection")}
</sl-menu-item> </sl-menu-item>
<sl-divider></sl-divider> <sl-divider></sl-divider>
${!this.collection?.isPublic
? html`
<sl-menu-item
style="--sl-color-neutral-700: var(--success)"
@click=${() => this.onTogglePublic(true)}
>
<sl-icon name="people-fill" slot="prefix"></sl-icon>
${msg("Make Shareable")}
</sl-menu-item>
`
: html`
<sl-menu-item style="--sl-color-neutral-700: var(--success)">
<sl-icon name="box-arrow-up-right" slot="prefix"></sl-icon>
<a
target="_blank"
slot="prefix"
href="https://replayweb.page?source=${this.getPublicReplayURL()}"
>
Go to Public View
</a>
</sl-menu-item>
<sl-menu-item
style="--sl-color-neutral-700: var(--warning)"
@click=${() => this.onTogglePublic(false)}
>
<sl-icon name="eye-slash" slot="prefix"></sl-icon>
${msg("Make Private")}
</sl-menu-item>
`}
<!-- Shoelace doesn't allow "href" on menu items, <!-- Shoelace doesn't allow "href" on menu items,
see https://github.com/shoelace-style/shoelace/issues/1351 --> see https://github.com/shoelace-style/shoelace/issues/1351 -->
<a <a
@ -480,6 +608,21 @@ export class CollectionDetail extends LiteElement {
} }
}; };
private async onTogglePublic(isPublic: boolean) {
const res = await this.apiFetch(
`/orgs/${this.orgId}/collections/${this.collectionId}`,
this.authState!,
{
method: "PATCH",
body: JSON.stringify({ isPublic }),
}
);
if (res.updated && this.collection) {
this.collection = { ...this.collection, isPublic };
}
}
private confirmDelete = () => { private confirmDelete = () => {
this.openDialogName = "delete"; this.openDialogName = "delete";
}; };

View File

@ -83,7 +83,8 @@ export class CollectionEdit extends LiteElement {
private async onSubmit(e: CollectionSubmitEvent) { private async onSubmit(e: CollectionSubmitEvent) {
this.isSubmitting = true; this.isSubmitting = true;
const { name, description, crawlIds, oldCrawlIds } = e.detail.values; const { name, description, crawlIds, oldCrawlIds, isPublic } =
e.detail.values;
try { try {
if (oldCrawlIds && oldCrawlIds) { if (oldCrawlIds && oldCrawlIds) {
@ -92,7 +93,11 @@ export class CollectionEdit extends LiteElement {
oldCrawlIds, oldCrawlIds,
}); });
} else { } else {
await this.saveMetadata({ name, description }); await this.saveMetadata({
name,
description,
isPublic: isPublic === "on",
});
} }
this.navTo(`/orgs/${this.orgId}/collections/view/${this.collectionId}`); this.navTo(`/orgs/${this.orgId}/collections/view/${this.collectionId}`);
@ -117,7 +122,11 @@ export class CollectionEdit extends LiteElement {
this.isSubmitting = false; this.isSubmitting = false;
} }
private saveMetadata(values: { name: string; description: string | null }) { private saveMetadata(values: {
name: string;
description: string | null;
isPublic: boolean;
}) {
return this.apiFetch( return this.apiFetch(
`/orgs/${this.orgId}/collections/${this.collectionId}`, `/orgs/${this.orgId}/collections/${this.collectionId}`,
this.authState!, this.authState!,

View File

@ -84,6 +84,7 @@ export type CollectionSubmitEvent = CustomEvent<{
description: string | null; description: string | null;
crawlIds: string[]; crawlIds: string[];
oldCrawlIds?: string[]; oldCrawlIds?: string[];
isPublic: string | null;
}; };
}>; }>;
@ -512,6 +513,11 @@ export class CollectionEditor extends LiteElement {
maxlength=${4000} maxlength=${4000}
></btrix-markdown-editor> ></btrix-markdown-editor>
</fieldset> </fieldset>
<label>
<sl-switch name="isPublic" ?checked=${this.metadataValues?.isPublic}
>Publicly Accessible</sl-switch
>
</label>
</div> </div>
<footer class="border-t px-6 py-4 flex justify-between"> <footer class="border-t px-6 py-4 flex justify-between">
${when( ${when(

View File

@ -454,6 +454,23 @@ export class CollectionsList extends LiteElement {
class="block text-primary hover:text-indigo-500" class="block text-primary hover:text-indigo-500"
@click=${this.navLink} @click=${this.navLink}
> >
${col?.isPublic
? html`
<sl-tooltip content=${msg("Shareable")}>
<sl-icon
style="margin-right: 4px; vertical-align: bottom; font-size: 14px;"
name="people-fill"
></sl-icon>
</sl-tooltip>
`
: html`
<sl-tooltip content=${msg("Private")}>
<sl-icon
style="margin-right: 4px; vertical-align: bottom; font-size: 14px;"
name="eye-slash-fill"
></sl-icon>
</sl-tooltip>
`}
${col.name} ${col.name}
</a> </a>
</div> </div>
@ -523,6 +540,37 @@ export class CollectionsList extends LiteElement {
${msg("Edit Collection")} ${msg("Edit Collection")}
</sl-menu-item> </sl-menu-item>
<sl-divider></sl-divider> <sl-divider></sl-divider>
${!col?.isPublic
? html`
<sl-menu-item
style="--sl-color-neutral-700: var(--success)"
@click=${() => this.onTogglePublic(col, true)}
>
<sl-icon name="people-fill" slot="prefix"></sl-icon>
${msg("Make Shareable")}
</sl-menu-item>
`
: html`
<sl-menu-item style="--sl-color-neutral-700: var(--success)">
<sl-icon name="box-arrow-up-right" slot="prefix"></sl-icon>
<a
target="_blank"
slot="prefix"
href="https://replayweb.page?source=${this.getPublicReplayURL(
col
)}"
>
Go to Shared View
</a>
</sl-menu-item>
<sl-menu-item
style="--sl-color-neutral-700: var(--warning)"
@click=${() => this.onTogglePublic(col, false)}
>
<sl-icon name="eye-slash" slot="prefix"></sl-icon>
${msg("Make Private")}
</sl-menu-item>
`}
<!-- Shoelace doesn't allow "href" on menu items, <!-- Shoelace doesn't allow "href" on menu items,
see https://github.com/shoelace-style/shoelace/issues/1351 --> see https://github.com/shoelace-style/shoelace/issues/1351 -->
<a <a
@ -572,6 +620,26 @@ export class CollectionsList extends LiteElement {
} }
}) as any; }) as any;
private async onTogglePublic(coll: Collection, isPublic: boolean) {
const res = await this.apiFetch(
`/orgs/${this.orgId}/collections/${coll.id}`,
this.authState!,
{
method: "PATCH",
body: JSON.stringify({ isPublic }),
}
);
this.fetchCollections();
}
private getPublicReplayURL(col: Collection) {
return new URL(
`/api/orgs/${this.orgId}/collections/${col.id}/public/replay.json`,
window.location.href
).href;
}
private confirmDelete = (collection: Collection) => { private confirmDelete = (collection: Collection) => {
this.collectionToDelete = collection; this.collectionToDelete = collection;
this.openDialogName = "delete"; this.openDialogName = "delete";

View File

@ -59,12 +59,18 @@ export class CollectionsNew extends LiteElement {
console.log("submit", e.detail.values); console.log("submit", e.detail.values);
try { try {
const { name, description, crawlIds, isPublic } = e.detail.values;
const data = await this.apiFetch( const data = await this.apiFetch(
`/orgs/${this.orgId}/collections`, `/orgs/${this.orgId}/collections`,
this.authState!, this.authState!,
{ {
method: "POST", method: "POST",
body: JSON.stringify(e.detail.values), body: JSON.stringify({
name,
description,
crawlIds,
public: isPublic === "on",
}),
} }
); );

View File

@ -9,6 +9,7 @@ export type Collection = {
totalSize: number; totalSize: number;
tags: string[]; tags: string[];
resources: string[]; resources: string[];
isPublic: boolean;
}; };
export type CollectionList = Collection[]; export type CollectionList = Collection[];