-
Notifications
You must be signed in to change notification settings - Fork 13
Add private Sites and owner access #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
453fbe4
932650d
d2af3d1
4343694
7e74d45
5a95f69
edf5f19
92de61c
0af126b
735f498
39c2cdd
656683d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { NextRequest } from 'next/server'; | ||
| import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; | ||
| import type { Principal } from 'server/lib/principal'; | ||
| import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { readSiteRevision, readSiteVisibility, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; | ||
| import SitesService, { SitesServiceError } from 'server/services/sites'; | ||
|
|
||
| /** | ||
| * @openapi | ||
| * /api/v2/sites/{siteId}/access: | ||
| * patch: | ||
| * summary: Change Site visibility | ||
| * operationId: setSiteVisibility | ||
| * tags: [Sites] | ||
| * security: | ||
| * - BearerAuth: [] | ||
| * - LifecycleApiKey: [] | ||
| * parameters: | ||
| * - in: path | ||
| * name: siteId | ||
| * required: true | ||
| * schema: { type: string } | ||
| * requestBody: | ||
| * required: true | ||
| * content: | ||
| * application/json: | ||
| * schema: | ||
| * type: object | ||
| * additionalProperties: false | ||
| * properties: | ||
| * visibility: { type: string, enum: [private, public] } | ||
| * expectedAccessRevision: { type: integer, minimum: 1, maximum: 2147483647 } | ||
| * required: [visibility, expectedAccessRevision] | ||
| * responses: | ||
| * '200': | ||
| * description: Visibility changed; the Site ID and content URL stay the same. | ||
| * content: | ||
| * application/json: | ||
| * schema: | ||
| * $ref: '#/components/schemas/SiteSuccessResponse' | ||
| * '409': | ||
| * description: Access revision changed; refetch before retrying. | ||
| */ | ||
| export const PATCH = createPrincipalApiHandler( | ||
| { scope: 'sites:write' }, | ||
| async (req: NextRequest, principal: Principal, { params }: { params: Promise<{ siteId: string }> }) => { | ||
| try { | ||
| let body: unknown; | ||
| try { | ||
| body = await req.json(); | ||
| } catch { | ||
| throw new SitesServiceError('Invalid JSON.', 400); | ||
| } | ||
| if ( | ||
| !body || | ||
| typeof body !== 'object' || | ||
| Array.isArray(body) || | ||
| Object.keys(body).some((key) => !['visibility', 'expectedAccessRevision'].includes(key)) | ||
| ) { | ||
| throw new SitesServiceError('Invalid access request.', 400); | ||
| } | ||
| const input = body as Record<string, unknown>; | ||
| const visibility = readSiteVisibility(input.visibility); | ||
| const revision = readSiteRevision(input.expectedAccessRevision, true)!; | ||
| const site = await new SitesService().setVisibility((await params).siteId, visibility, principal, revision); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Visibility has its own owner-authorized mutation. Requiring the access revision prevents an old page from overwriting a newer publish or privacy decision. |
||
| return successResponse({ site }, { status: 200 }, req); | ||
| } catch (error) { | ||
| return sitesErrorResponse(error, req); | ||
| } | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,8 +17,9 @@ | |
| import { NextRequest } from 'next/server'; | ||
| import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; | ||
| import type { Principal } from 'server/lib/principal'; | ||
| import { successResponse } from 'server/lib/response'; | ||
| import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { readUploadFile, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { SitesServiceError } from 'server/services/sites'; | ||
| import SitesService from 'server/services/sites'; | ||
|
|
||
| export const runtime = 'nodejs'; | ||
|
|
@@ -76,11 +77,16 @@ type RouteContext = { | |
| const putHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { | ||
| const routeParams = await params; | ||
| try { | ||
| const upload = await readUploadFile(req); | ||
| const service = new SitesService(); | ||
| const existing = await service.getSite(routeParams.siteId, principal); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check access before reading the upload body, then let the service recheck ownership and revisions when it commits the replacement. |
||
| if (!existing.permissions.canEdit) throw new SitesServiceError('Site content editing is not permitted.', 403); | ||
| const capabilities = await service.getCapabilities(principal); | ||
| const upload = await readUploadFile(req, capabilities.upload.maxUploadBytes); | ||
| if (upload.visibility !== undefined) | ||
| throw new SitesServiceError('Use the access endpoint to change visibility.', 400); | ||
| const site = await service.replaceSiteContent(routeParams.siteId, { | ||
| ...upload, | ||
| user: principal.identity, | ||
| principal, | ||
| }); | ||
| return successResponse({ site }, { status: 200 }, req); | ||
| } catch (error) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,8 +17,8 @@ | |
| import { NextRequest } from 'next/server'; | ||
| import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; | ||
| import type { Principal } from 'server/lib/principal'; | ||
| import { successResponse } from 'server/lib/response'; | ||
| import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; | ||
| import SitesService from 'server/services/sites'; | ||
|
|
||
| type RouteContext = { | ||
|
|
@@ -44,6 +44,10 @@ type RouteContext = { | |
| * required: true | ||
| * schema: | ||
| * type: string | ||
| * - in: query | ||
| * name: expectedAccessRevision | ||
| * required: false | ||
| * schema: { type: integer, minimum: 1, maximum: 2147483647 } | ||
| * responses: | ||
| * '200': | ||
| * description: Hosted static site expiration extended. | ||
|
|
@@ -64,11 +68,15 @@ type RouteContext = { | |
| * schema: | ||
| * $ref: '#/components/schemas/ApiErrorResponse' | ||
| */ | ||
| const postHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { | ||
| const postHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { | ||
| const routeParams = await params; | ||
| try { | ||
| const service = new SitesService(); | ||
| const site = await service.extendSite(routeParams.siteId); | ||
| const site = await service.extendSite( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extension now uses the caller identity and optional access revision, so only the owner can extend the current Site state. |
||
| routeParams.siteId, | ||
| principal, | ||
| readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision')) | ||
| ); | ||
| return successResponse({ site }, { status: 200 }, req); | ||
| } catch (error) { | ||
| return sitesErrorResponse(error, req); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,8 +17,8 @@ | |
| import { NextRequest } from 'next/server'; | ||
| import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; | ||
| import type { Principal } from 'server/lib/principal'; | ||
| import { successResponse } from 'server/lib/response'; | ||
| import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; | ||
| import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; | ||
| import SitesService from 'server/services/sites'; | ||
|
|
||
| type RouteContext = { | ||
|
|
@@ -71,6 +71,10 @@ type RouteContext = { | |
| * required: true | ||
| * schema: | ||
| * type: string | ||
| * - in: query | ||
| * name: expectedAccessRevision | ||
| * required: false | ||
| * schema: { type: integer, minimum: 1, maximum: 2147483647 } | ||
| * responses: | ||
| * '200': | ||
| * description: Hosted static site deleted. | ||
|
|
@@ -85,22 +89,26 @@ type RouteContext = { | |
| * schema: | ||
| * $ref: '#/components/schemas/ApiErrorResponse' | ||
| */ | ||
| const getHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { | ||
| const getHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { | ||
| const routeParams = await params; | ||
| try { | ||
| const service = new SitesService(); | ||
| const site = await service.getSite(routeParams.siteId); | ||
| const site = await service.getSite(routeParams.siteId, principal); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The single-Site endpoint now applies the same private metadata boundary as listing: only the owner can retrieve a private Site. |
||
| return successResponse({ site }, { status: 200 }, req); | ||
| } catch (error) { | ||
| return sitesErrorResponse(error, req); | ||
| } | ||
| }; | ||
|
|
||
| const deleteHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { | ||
| const deleteHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { | ||
| const routeParams = await params; | ||
| try { | ||
| const service = new SitesService(); | ||
| const site = await service.deleteSite(routeParams.siteId); | ||
| const site = await service.deleteSite( | ||
| routeParams.siteId, | ||
| principal, | ||
| readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision')) | ||
| ); | ||
| return successResponse({ site }, { status: 200 }, req); | ||
| } catch (error) { | ||
| return sitesErrorResponse(error, req); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New personal keys retain the verified issuer with the subject. Site ownership must not depend on a mutable email or username.