Skip to content

Commit

Permalink
Add public API endpoint for public collections (#2174)
Browse files Browse the repository at this point in the history
Fixes #1051 

If org with provided slug doesn't exist or no public collections exist
for that org, return same 404 response with a detail of
"public_profile_not_found" to prevent people from using public endpoint
to determine whether an org exists.

Endpoint is `GET /api/public-collections/<org-slug>` (no auth needed) to
avoid collisions with existing org and collection endpoints.
  • Loading branch information
tw4l authored and SuaYoo committed Nov 28, 2024
1 parent cc2baf1 commit 25ad642
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 34 deletions.
35 changes: 35 additions & 0 deletions backend/btrixcloud/colls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
UpdatedResponse,
SuccessResponse,
CollectionSearchValuesResponse,
OrgPublicCollections,
PublicOrgDetails,
CollAccessType,
)
from .utils import dt_now

Expand Down Expand Up @@ -395,6 +398,30 @@ async def add_successful_crawl_to_collections(self, crawl_id: str, cid: UUID):
)
await self.update_crawl_collections(crawl_id)

async def get_org_public_collections(self, org_slug: str):
"""List public collections for org"""
try:
org = await self.orgs.get_org_by_slug(org_slug)
# pylint: disable=broad-exception-caught
except Exception:
# pylint: disable=raise-missing-from
raise HTTPException(status_code=404, detail="public_profile_not_found")

if not org.enablePublicProfile:
raise HTTPException(status_code=404, detail="public_profile_not_found")

collections, _ = await self.list_collections(
org.id, access=CollAccessType.PUBLIC
)

public_org_details = PublicOrgDetails(
name=org.name,
description=org.publicDescription or "",
url=org.publicUrl or "",
)

return OrgPublicCollections(org=public_org_details, collections=collections)


# ============================================================================
# pylint: disable=too-many-locals
Expand Down Expand Up @@ -582,4 +609,12 @@ async def download_collection(
):
return await colls.download_collection(coll_id, org)

@app.get(
"/public-collections/{org_slug}",
tags=["collections"],
response_model=OrgPublicCollections,
)
async def get_org_public_collections(org_slug: str):
return await colls.get_org_public_collections(org_slug)

return colls
34 changes: 29 additions & 5 deletions backend/btrixcloud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,24 @@ class RenameOrg(BaseModel):
slug: Optional[str] = None


# ============================================================================
class PublicOrgDetails(BaseModel):
"""Model for org details that are available in public profile"""

name: str
description: str = ""
url: str = ""


# ============================================================================
class OrgPublicCollections(BaseModel):
"""Model for listing public collections in org"""

org: PublicOrgDetails

collections: List[CollOut] = []


# ============================================================================
class OrgStorageRefs(BaseModel):
"""Input model for setting primary storage + optional replicas"""
Expand Down Expand Up @@ -1374,10 +1392,12 @@ class OrgReadOnlyUpdate(BaseModel):


# ============================================================================
class OrgListPublicCollectionsUpdate(BaseModel):
"""Organization listPublicCollections update"""
class OrgPublicProfileUpdate(BaseModel):
"""Organization enablePublicProfile update"""

listPublicCollections: bool
enablePublicProfile: Optional[bool] = None
publicDescription: Optional[str] = None
publicUrl: Optional[str] = None


# ============================================================================
Expand Down Expand Up @@ -1448,7 +1468,9 @@ class OrgOut(BaseMongoModel):
allowedProxies: list[str] = []
crawlingDefaults: Optional[CrawlConfigDefaults] = None

listPublicCollections: bool = False
enablePublicProfile: bool = False
publicDescription: str = ""
publicUrl: str = ""


# ============================================================================
Expand Down Expand Up @@ -1505,7 +1527,9 @@ class Organization(BaseMongoModel):
allowedProxies: list[str] = []
crawlingDefaults: Optional[CrawlConfigDefaults] = None

listPublicCollections: bool = False
enablePublicProfile: bool = False
publicDescription: Optional[str] = None
publicUrl: Optional[str] = None

def is_owner(self, user):
"""Check if user is owner"""
Expand Down
31 changes: 22 additions & 9 deletions backend/btrixcloud/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
RemovedResponse,
OrgSlugsResponse,
OrgImportResponse,
OrgListPublicCollectionsUpdate,
OrgPublicProfileUpdate,
)
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
from .utils import (
Expand Down Expand Up @@ -285,6 +285,14 @@ async def get_org_by_id(self, oid: UUID) -> Organization:

return Organization.from_dict(res)

async def get_org_by_slug(self, slug: str) -> Organization:
"""Get an org by id"""
res = await self.orgs.find_one({"slug": slug})
if not res:
raise HTTPException(status_code=400, detail="invalid_org_slug")

return Organization.from_dict(res)

async def get_default_org(self) -> Organization:
"""Get default organization"""
res = await self.orgs.find_one({"default": True})
Expand Down Expand Up @@ -988,13 +996,18 @@ async def update_read_only_on_cancel(
)
return res is not None

async def update_list_public_collections(
self, org: Organization, list_public_collections: bool
async def update_public_profile(
self, org: Organization, update: OrgPublicProfileUpdate
):
"""Update listPublicCollections field on organization"""
"""Update or enable/disable organization's public profile"""
query = update.dict(exclude_unset=True)

if len(query) == 0:
raise HTTPException(status_code=400, detail="no_update_data")

res = await self.orgs.find_one_and_update(
{"_id": org.id},
{"$set": {"listPublicCollections": list_public_collections}},
{"$set": query},
)
return res is not None

Expand Down Expand Up @@ -1555,15 +1568,15 @@ async def update_read_only_on_cancel(
return {"updated": True}

@router.post(
"/list-public-collections",
"/public-profile",
tags=["organizations", "collections"],
response_model=UpdatedResponse,
)
async def update_list_public_collections(
update: OrgListPublicCollectionsUpdate,
async def update_public_profile(
update: OrgPublicProfileUpdate,
org: Organization = Depends(org_owner_dep),
):
await ops.update_list_public_collections(org, update.listPublicCollections)
await ops.update_public_profile(org, update)

return {"updated": True}

Expand Down
121 changes: 120 additions & 1 deletion backend/test/test_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from zipfile import ZipFile, ZIP_STORED
from tempfile import TemporaryFile

from .conftest import API_PREFIX
from .conftest import API_PREFIX, NON_DEFAULT_ORG_NAME, NON_DEFAULT_ORG_SLUG
from .utils import read_in_chunks

COLLECTION_NAME = "Test collection"
Expand All @@ -15,6 +15,7 @@

_coll_id = None
_second_coll_id = None
_public_coll_id = None
upload_id = None
modified = None

Expand Down Expand Up @@ -66,6 +67,7 @@ def test_create_public_collection(
assert data["added"]
assert data["name"] == PUBLIC_COLLECTION_NAME

global _public_coll_id
_public_coll_id = data["id"]

# Verify that it is public
Expand Down Expand Up @@ -725,6 +727,123 @@ def test_filter_sort_collections(
assert r.json()["detail"] == "invalid_sort_direction"


def test_list_public_collections(
crawler_auth_headers,
admin_auth_headers,
default_org_id,
non_default_org_id,
crawler_crawl_id,
admin_crawl_id,
):
# Create new public collection
r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/collections",
headers=crawler_auth_headers,
json={
"crawlIds": [crawler_crawl_id],
"name": "Second public collection",
"access": "public",
},
)
assert r.status_code == 200
second_public_coll_id = r.json()["id"]

# Get default org slug
r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}",
headers=crawler_auth_headers,
)
assert r.status_code == 200
data = r.json()
org_slug = data["slug"]
org_name = data["name"]

# Verify that public profile isn't enabled
assert data["enablePublicProfile"] is False
assert data["publicDescription"] == ""
assert data["publicUrl"] == ""

# Try listing public collections without org public profile enabled
r = requests.get(f"{API_PREFIX}/public-collections/{org_slug}")
assert r.status_code == 404
assert r.json()["detail"] == "public_profile_not_found"

# Enable public profile on org
public_description = "This is a test public org!"
public_url = "https://example.com"

r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/public-profile",
headers=admin_auth_headers,
json={
"enablePublicProfile": True,
"publicDescription": public_description,
"publicUrl": public_url,
},
)
assert r.status_code == 200
assert r.json()["updated"]

r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}",
headers=admin_auth_headers,
)
assert r.status_code == 200
data = r.json()
assert data["enablePublicProfile"]
assert data["publicDescription"] == public_description
assert data["publicUrl"] == public_url

# List public collections with no auth (no public profile)
r = requests.get(f"{API_PREFIX}/public-collections/{org_slug}")
assert r.status_code == 200
data = r.json()

org_data = data["org"]
assert org_data["name"] == org_name
assert org_data["description"] == public_description
assert org_data["url"] == public_url

collections = data["collections"]
assert len(collections) == 2
for collection in collections:
assert collection["id"] in (_public_coll_id, second_public_coll_id)
assert collection["access"] == "public"

# Test non-existing slug - it should return a 404 but not reveal
# whether or not an org exists with that slug
r = requests.get(f"{API_PREFIX}/public-collections/nonexistentslug")
assert r.status_code == 404
assert r.json()["detail"] == "public_profile_not_found"


def test_list_public_collections_no_colls(non_default_org_id, admin_auth_headers):
# Test existing org that's not public - should return same 404 as
# if org doesn't exist
r = requests.get(f"{API_PREFIX}/public-collections/{NON_DEFAULT_ORG_SLUG}")
assert r.status_code == 404
assert r.json()["detail"] == "public_profile_not_found"

# Enable public profile on org with zero public collections
r = requests.post(
f"{API_PREFIX}/orgs/{non_default_org_id}/public-profile",
headers=admin_auth_headers,
json={
"enablePublicProfile": True,
},
)
assert r.status_code == 200
assert r.json()["updated"]

# List public collections with no auth - should still get profile even
# with no public collections
r = requests.get(f"{API_PREFIX}/public-collections/{NON_DEFAULT_ORG_SLUG}")
assert r.status_code == 200
data = r.json()
assert data["org"]["name"] == NON_DEFAULT_ORG_NAME
assert data["collections"] == []


def test_delete_collection(crawler_auth_headers, default_org_id, crawler_crawl_id):
# Delete second collection
r = requests.delete(
Expand Down
20 changes: 1 addition & 19 deletions backend/test/test_org.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
def test_ensure_only_one_default_org(admin_auth_headers):
r = requests.get(f"{API_PREFIX}/orgs", headers=admin_auth_headers)
data = r.json()
assert data["total"] == 1
assert data["total"] == 2

orgs = data["items"]
default_orgs = [org for org in orgs if org["default"]]
Expand Down Expand Up @@ -697,24 +697,6 @@ def test_update_read_only(admin_auth_headers, default_org_id):
assert data["readOnlyReason"] == ""


def test_update_list_public_collections(admin_auth_headers, default_org_id):
# Test that default is false
r = requests.get(f"{API_PREFIX}/orgs/{default_org_id}", headers=admin_auth_headers)
assert r.json()["listPublicCollections"] is False

# Update
r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/list-public-collections",
headers=admin_auth_headers,
json={"listPublicCollections": True},
)
assert r.json()["updated"]

# Test update is reflected in GET response
r = requests.get(f"{API_PREFIX}/orgs/{default_org_id}", headers=admin_auth_headers)
assert r.json()["listPublicCollections"]


def test_sort_orgs(admin_auth_headers):
# Create a few new orgs for testing
r = requests.post(
Expand Down

0 comments on commit 25ad642

Please sign in to comment.