Skip to content

Commit 90ea55d

Browse files
tw4lSuaYoo
authored andcommitted
Add public API endpoint for public collections (#2174)
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.
1 parent 55e719a commit 90ea55d

File tree

5 files changed

+207
-34
lines changed

5 files changed

+207
-34
lines changed

backend/btrixcloud/colls.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
UpdatedResponse,
3131
SuccessResponse,
3232
CollectionSearchValuesResponse,
33+
OrgPublicCollections,
34+
PublicOrgDetails,
35+
CollAccessType,
3336
)
3437
from .utils import dt_now
3538

@@ -395,6 +398,30 @@ async def add_successful_crawl_to_collections(self, crawl_id: str, cid: UUID):
395398
)
396399
await self.update_crawl_collections(crawl_id)
397400

401+
async def get_org_public_collections(self, org_slug: str):
402+
"""List public collections for org"""
403+
try:
404+
org = await self.orgs.get_org_by_slug(org_slug)
405+
# pylint: disable=broad-exception-caught
406+
except Exception:
407+
# pylint: disable=raise-missing-from
408+
raise HTTPException(status_code=404, detail="public_profile_not_found")
409+
410+
if not org.enablePublicProfile:
411+
raise HTTPException(status_code=404, detail="public_profile_not_found")
412+
413+
collections, _ = await self.list_collections(
414+
org.id, access=CollAccessType.PUBLIC
415+
)
416+
417+
public_org_details = PublicOrgDetails(
418+
name=org.name,
419+
description=org.publicDescription or "",
420+
url=org.publicUrl or "",
421+
)
422+
423+
return OrgPublicCollections(org=public_org_details, collections=collections)
424+
398425

399426
# ============================================================================
400427
# pylint: disable=too-many-locals
@@ -582,4 +609,12 @@ async def download_collection(
582609
):
583610
return await colls.download_collection(coll_id, org)
584611

612+
@app.get(
613+
"/public-collections/{org_slug}",
614+
tags=["collections"],
615+
response_model=OrgPublicCollections,
616+
)
617+
async def get_org_public_collections(org_slug: str):
618+
return await colls.get_org_public_collections(org_slug)
619+
585620
return colls

backend/btrixcloud/models.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,24 @@ class RenameOrg(BaseModel):
11521152
slug: Optional[str] = None
11531153

11541154

1155+
# ============================================================================
1156+
class PublicOrgDetails(BaseModel):
1157+
"""Model for org details that are available in public profile"""
1158+
1159+
name: str
1160+
description: str = ""
1161+
url: str = ""
1162+
1163+
1164+
# ============================================================================
1165+
class OrgPublicCollections(BaseModel):
1166+
"""Model for listing public collections in org"""
1167+
1168+
org: PublicOrgDetails
1169+
1170+
collections: List[CollOut] = []
1171+
1172+
11551173
# ============================================================================
11561174
class OrgStorageRefs(BaseModel):
11571175
"""Input model for setting primary storage + optional replicas"""
@@ -1381,10 +1399,12 @@ class OrgReadOnlyUpdate(BaseModel):
13811399

13821400

13831401
# ============================================================================
1384-
class OrgListPublicCollectionsUpdate(BaseModel):
1385-
"""Organization listPublicCollections update"""
1402+
class OrgPublicProfileUpdate(BaseModel):
1403+
"""Organization enablePublicProfile update"""
13861404

1387-
listPublicCollections: bool
1405+
enablePublicProfile: Optional[bool] = None
1406+
publicDescription: Optional[str] = None
1407+
publicUrl: Optional[str] = None
13881408

13891409

13901410
# ============================================================================
@@ -1455,7 +1475,9 @@ class OrgOut(BaseMongoModel):
14551475
allowedProxies: list[str] = []
14561476
crawlingDefaults: Optional[CrawlConfigDefaults] = None
14571477

1458-
listPublicCollections: bool = False
1478+
enablePublicProfile: bool = False
1479+
publicDescription: str = ""
1480+
publicUrl: str = ""
14591481

14601482

14611483
# ============================================================================
@@ -1512,7 +1534,9 @@ class Organization(BaseMongoModel):
15121534
allowedProxies: list[str] = []
15131535
crawlingDefaults: Optional[CrawlConfigDefaults] = None
15141536

1515-
listPublicCollections: bool = False
1537+
enablePublicProfile: bool = False
1538+
publicDescription: Optional[str] = None
1539+
publicUrl: Optional[str] = None
15161540

15171541
def is_owner(self, user):
15181542
"""Check if user is owner"""

backend/btrixcloud/orgs.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
RemovedResponse,
7979
OrgSlugsResponse,
8080
OrgImportResponse,
81-
OrgListPublicCollectionsUpdate,
81+
OrgPublicProfileUpdate,
8282
)
8383
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
8484
from .utils import (
@@ -295,6 +295,14 @@ async def get_org_by_id(self, oid: UUID) -> Organization:
295295

296296
return Organization.from_dict(res)
297297

298+
async def get_org_by_slug(self, slug: str) -> Organization:
299+
"""Get an org by id"""
300+
res = await self.orgs.find_one({"slug": slug})
301+
if not res:
302+
raise HTTPException(status_code=400, detail="invalid_org_slug")
303+
304+
return Organization.from_dict(res)
305+
298306
async def get_default_org(self) -> Organization:
299307
"""Get default organization"""
300308
res = await self.orgs.find_one({"default": True})
@@ -998,13 +1006,18 @@ async def update_read_only_on_cancel(
9981006
)
9991007
return res is not None
10001008

1001-
async def update_list_public_collections(
1002-
self, org: Organization, list_public_collections: bool
1009+
async def update_public_profile(
1010+
self, org: Organization, update: OrgPublicProfileUpdate
10031011
):
1004-
"""Update listPublicCollections field on organization"""
1012+
"""Update or enable/disable organization's public profile"""
1013+
query = update.dict(exclude_unset=True)
1014+
1015+
if len(query) == 0:
1016+
raise HTTPException(status_code=400, detail="no_update_data")
1017+
10051018
res = await self.orgs.find_one_and_update(
10061019
{"_id": org.id},
1007-
{"$set": {"listPublicCollections": list_public_collections}},
1020+
{"$set": query},
10081021
)
10091022
return res is not None
10101023

@@ -1565,15 +1578,15 @@ async def update_read_only_on_cancel(
15651578
return {"updated": True}
15661579

15671580
@router.post(
1568-
"/list-public-collections",
1581+
"/public-profile",
15691582
tags=["organizations", "collections"],
15701583
response_model=UpdatedResponse,
15711584
)
1572-
async def update_list_public_collections(
1573-
update: OrgListPublicCollectionsUpdate,
1585+
async def update_public_profile(
1586+
update: OrgPublicProfileUpdate,
15741587
org: Organization = Depends(org_owner_dep),
15751588
):
1576-
await ops.update_list_public_collections(org, update.listPublicCollections)
1589+
await ops.update_public_profile(org, update)
15771590

15781591
return {"updated": True}
15791592

backend/test/test_collections.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from zipfile import ZipFile, ZIP_STORED
55
from tempfile import TemporaryFile
66

7-
from .conftest import API_PREFIX
7+
from .conftest import API_PREFIX, NON_DEFAULT_ORG_NAME, NON_DEFAULT_ORG_SLUG
88
from .utils import read_in_chunks
99

1010
COLLECTION_NAME = "Test collection"
@@ -15,6 +15,7 @@
1515

1616
_coll_id = None
1717
_second_coll_id = None
18+
_public_coll_id = None
1819
upload_id = None
1920
modified = None
2021

@@ -66,6 +67,7 @@ def test_create_public_collection(
6667
assert data["added"]
6768
assert data["name"] == PUBLIC_COLLECTION_NAME
6869

70+
global _public_coll_id
6971
_public_coll_id = data["id"]
7072

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

727729

730+
def test_list_public_collections(
731+
crawler_auth_headers,
732+
admin_auth_headers,
733+
default_org_id,
734+
non_default_org_id,
735+
crawler_crawl_id,
736+
admin_crawl_id,
737+
):
738+
# Create new public collection
739+
r = requests.post(
740+
f"{API_PREFIX}/orgs/{default_org_id}/collections",
741+
headers=crawler_auth_headers,
742+
json={
743+
"crawlIds": [crawler_crawl_id],
744+
"name": "Second public collection",
745+
"access": "public",
746+
},
747+
)
748+
assert r.status_code == 200
749+
second_public_coll_id = r.json()["id"]
750+
751+
# Get default org slug
752+
r = requests.get(
753+
f"{API_PREFIX}/orgs/{default_org_id}",
754+
headers=crawler_auth_headers,
755+
)
756+
assert r.status_code == 200
757+
data = r.json()
758+
org_slug = data["slug"]
759+
org_name = data["name"]
760+
761+
# Verify that public profile isn't enabled
762+
assert data["enablePublicProfile"] is False
763+
assert data["publicDescription"] == ""
764+
assert data["publicUrl"] == ""
765+
766+
# Try listing public collections without org public profile enabled
767+
r = requests.get(f"{API_PREFIX}/public-collections/{org_slug}")
768+
assert r.status_code == 404
769+
assert r.json()["detail"] == "public_profile_not_found"
770+
771+
# Enable public profile on org
772+
public_description = "This is a test public org!"
773+
public_url = "https://example.com"
774+
775+
r = requests.post(
776+
f"{API_PREFIX}/orgs/{default_org_id}/public-profile",
777+
headers=admin_auth_headers,
778+
json={
779+
"enablePublicProfile": True,
780+
"publicDescription": public_description,
781+
"publicUrl": public_url,
782+
},
783+
)
784+
assert r.status_code == 200
785+
assert r.json()["updated"]
786+
787+
r = requests.get(
788+
f"{API_PREFIX}/orgs/{default_org_id}",
789+
headers=admin_auth_headers,
790+
)
791+
assert r.status_code == 200
792+
data = r.json()
793+
assert data["enablePublicProfile"]
794+
assert data["publicDescription"] == public_description
795+
assert data["publicUrl"] == public_url
796+
797+
# List public collections with no auth (no public profile)
798+
r = requests.get(f"{API_PREFIX}/public-collections/{org_slug}")
799+
assert r.status_code == 200
800+
data = r.json()
801+
802+
org_data = data["org"]
803+
assert org_data["name"] == org_name
804+
assert org_data["description"] == public_description
805+
assert org_data["url"] == public_url
806+
807+
collections = data["collections"]
808+
assert len(collections) == 2
809+
for collection in collections:
810+
assert collection["id"] in (_public_coll_id, second_public_coll_id)
811+
assert collection["access"] == "public"
812+
813+
# Test non-existing slug - it should return a 404 but not reveal
814+
# whether or not an org exists with that slug
815+
r = requests.get(f"{API_PREFIX}/public-collections/nonexistentslug")
816+
assert r.status_code == 404
817+
assert r.json()["detail"] == "public_profile_not_found"
818+
819+
820+
def test_list_public_collections_no_colls(non_default_org_id, admin_auth_headers):
821+
# Test existing org that's not public - should return same 404 as
822+
# if org doesn't exist
823+
r = requests.get(f"{API_PREFIX}/public-collections/{NON_DEFAULT_ORG_SLUG}")
824+
assert r.status_code == 404
825+
assert r.json()["detail"] == "public_profile_not_found"
826+
827+
# Enable public profile on org with zero public collections
828+
r = requests.post(
829+
f"{API_PREFIX}/orgs/{non_default_org_id}/public-profile",
830+
headers=admin_auth_headers,
831+
json={
832+
"enablePublicProfile": True,
833+
},
834+
)
835+
assert r.status_code == 200
836+
assert r.json()["updated"]
837+
838+
# List public collections with no auth - should still get profile even
839+
# with no public collections
840+
r = requests.get(f"{API_PREFIX}/public-collections/{NON_DEFAULT_ORG_SLUG}")
841+
assert r.status_code == 200
842+
data = r.json()
843+
assert data["org"]["name"] == NON_DEFAULT_ORG_NAME
844+
assert data["collections"] == []
845+
846+
728847
def test_delete_collection(crawler_auth_headers, default_org_id, crawler_crawl_id):
729848
# Delete second collection
730849
r = requests.delete(

backend/test/test_org.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
def test_ensure_only_one_default_org(admin_auth_headers):
1818
r = requests.get(f"{API_PREFIX}/orgs", headers=admin_auth_headers)
1919
data = r.json()
20-
assert data["total"] == 1
20+
assert data["total"] == 2
2121

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

699699

700-
def test_update_list_public_collections(admin_auth_headers, default_org_id):
701-
# Test that default is false
702-
r = requests.get(f"{API_PREFIX}/orgs/{default_org_id}", headers=admin_auth_headers)
703-
assert r.json()["listPublicCollections"] is False
704-
705-
# Update
706-
r = requests.post(
707-
f"{API_PREFIX}/orgs/{default_org_id}/list-public-collections",
708-
headers=admin_auth_headers,
709-
json={"listPublicCollections": True},
710-
)
711-
assert r.json()["updated"]
712-
713-
# Test update is reflected in GET response
714-
r = requests.get(f"{API_PREFIX}/orgs/{default_org_id}", headers=admin_auth_headers)
715-
assert r.json()["listPublicCollections"]
716-
717-
718700
def test_sort_orgs(admin_auth_headers):
719701
# Create a few new orgs for testing
720702
r = requests.post(

0 commit comments

Comments
 (0)