Skip to content

Commit

Permalink
load project assets from root (#5760)
Browse files Browse the repository at this point in the history
  • Loading branch information
ruggi authored May 27, 2024
1 parent 281b595 commit 25e90ed
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 39 deletions.
4 changes: 2 additions & 2 deletions utopia-remix/app/handlers/splatLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ensure } from '../util/api.server'
import { proxy } from '../util/proxy.server'
import { Status } from '../util/statusCodes'

const allowedExtensions = [
export const allowedAssetExtensions = [
// images
'.bmp',
'.gif',
Expand All @@ -30,7 +30,7 @@ export async function handleSplatLoad(req: Request) {
const url = new URL(req.url)

const extension = path.extname(url.pathname).toLowerCase()
ensure(allowedExtensions.includes(extension), 'invalid extension', Status.BAD_REQUEST)
ensure(allowedAssetExtensions.includes(extension), 'invalid extension', Status.BAD_REQUEST)

return proxy(req, { rawOutput: true })
}
96 changes: 59 additions & 37 deletions utopia-remix/app/handlers/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,51 +26,73 @@ export function validateProjectAccess(
return validationError(new ApiError('Invalid project id', Status.BAD_REQUEST))
}

const ownership = await getProjectOwnership(
{ id: projectId },
{ includeDeleted: includeDeleted },
)
if (ownership == null) {
return validationError(new ApiError('Project not found', Status.NOT_FOUND))
}
return canAccessProject({
projectId: projectId,
includeDeleted: includeDeleted,
canRequestAccess: canRequestAccess,
request: req,
permission: permission,
})
}
}

const user = await getUser(req)
const userId = user?.user_id ?? null
export async function canAccessProject({
projectId,
includeDeleted,
request,
permission,
canRequestAccess,
}: {
projectId: string
request: Request
permission: UserProjectPermission
includeDeleted?: boolean
canRequestAccess?: boolean
}) {
const ownership = await getProjectOwnership(
{ id: projectId },
{ includeDeleted: includeDeleted ?? false },
)
if (ownership == null) {
return validationError(new ApiError('Project not found', Status.NOT_FOUND))
}

const user = await getUser(request)
const userId = user?.user_id ?? null

// the user owns the project, go on
if (userId === ownership.ownerId) {
// the user owns the project, go on
if (userId === ownership.ownerId) {
return validationOk()
}

// if the project is collaborative (or public)…
if (
ownership.accessLevel === AccessLevel.COLLABORATIVE ||
ownership.accessLevel === AccessLevel.PUBLIC
) {
// the user can access the project, go on
const hasProjectPermissions = await hasUserProjectPermission(projectId, userId, permission)
if (hasProjectPermissions) {
return validationOk()
}

// if the project is collaborative (or public)…
if (
ownership.accessLevel === AccessLevel.COLLABORATIVE ||
ownership.accessLevel === AccessLevel.PUBLIC
) {
// the user can access the project, go on
const hasProjectPermissions = await hasUserProjectPermission(projectId, userId, permission)
if (hasProjectPermissions) {
return validationOk()
}

// …and access can be requested…
if (canRequestAccess === true) {
// …and the user can request permissions…
const hasRequestAccessPermission = await hasUserProjectPermission(
projectId,
userId,
UserProjectPermission.CAN_REQUEST_ACCESS,
)
if (hasRequestAccessPermission) {
// …return a 403!
return validationError(new ApiError('Forbidden', Status.FORBIDDEN))
}
// …and access can be requested…
if (canRequestAccess === true) {
// …and the user can request permissions…
const hasRequestAccessPermission = await hasUserProjectPermission(
projectId,
userId,
UserProjectPermission.CAN_REQUEST_ACCESS,
)
if (hasRequestAccessPermission) {
// …return a 403!
return validationError(new ApiError('Forbidden', Status.FORBIDDEN))
}
}

// the project is not available, it's conceptually a 401 but just return a 404 so we don't leak
return validationError(new ApiError('Project not found', Status.NOT_FOUND))
}

// the project is not available, it's conceptually a 401 but just return a 404 so we don't leak
return validationError(new ApiError('Project not found', Status.NOT_FOUND))
}

export const ALLOW: AccessValidator = async () => validationOk()
34 changes: 34 additions & 0 deletions utopia-remix/app/routes/$.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { ALLOW } from '../handlers/validators'
import { proxy } from '../util/proxy.server'
import { handle } from '../util/api.server'
import { getProxyAssetPath } from '../util/assets.server'

export async function loader(args: LoaderFunctionArgs) {
return handle(args, {
GET: {
validator: ALLOW,
handler: handleLoadAssetFromRoot,
},
})
}

/**
* Some editor routes can reference assets as if they are on the root path.
* This function will look at the incoming request and if the requested resource
* has an allowed extension it will try to derive the project ID
* from the request's referer header.
* If a match is found, and the project can be accessed, the request will be proxied to
* /p/<id>/<resource>.
*/
async function handleLoadAssetFromRoot(req: Request) {
const proxyProjectAssetPath = await getProxyAssetPath(req)
if (proxyProjectAssetPath == null) {
return {}
}

return proxy(req, {
path: proxyProjectAssetPath,
rawOutput: true,
})
}
93 changes: 93 additions & 0 deletions utopia-remix/app/util/assets.server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { newTestRequest } from '../test-util'
import { getProjectIdFromReferer, getProxyAssetPath } from './assets.server'
import * as validators from '../handlers/validators'

describe('assets', () => {
let validatorsMock: jest.SpyInstance

afterEach(() => {
validatorsMock.mockRestore()
})
beforeEach(() => {
validatorsMock = jest.spyOn(validators, 'canAccessProject')
})

describe('getProjectIdFromReferer', () => {
it("returns null if there's no referer", async () => {
const got = getProjectIdFromReferer(newTestRequest())
expect(got).toBe(null)
})
it('returns null if the origin does not match', async () => {
const got = getProjectIdFromReferer(
newTestRequest({
headers: {
referer: 'http://foo.bar',
},
}),
)
expect(got).toBe(null)
})
it("returns null if the path is not a project's one", async () => {
const got = getProjectIdFromReferer(
newTestRequest({
headers: {
referer: 'http://localhost:8000/wrong',
},
}),
)
expect(got).toBe(null)
})
it('returns null if the tokenization is not correct', async () => {
const got = getProjectIdFromReferer(
newTestRequest({
headers: {
referer: 'http://localhost:8000/p/',
},
}),
)
expect(got).toBe(null)
})
it('returns the project id', async () => {
const got = getProjectIdFromReferer(
newTestRequest({
headers: {
referer: 'http://localhost:8000/p/foo-1-2-3/bar/baz',
},
}),
)
expect(got).toBe('foo')
})
})
describe('getProxyAssetPath', () => {
it('returns null if the extension is not allowed', async () => {
const got = await getProxyAssetPath(newTestRequest({ path: '/foo.WRONG' }))
expect(got).toBe(null)
})
it('returns null if the project id cannot be derived', async () => {
const got = await getProxyAssetPath(
newTestRequest({ path: '/foo.png', headers: { referer: 'http://localhost:8000/p' } }),
)
expect(got).toBe(null)
})
it('returns null if the project cannot be accessed', async () => {
validatorsMock.mockResolvedValue({ ok: false })
const got = await getProxyAssetPath(
newTestRequest({
path: '/foo.png',
headers: { referer: 'http://localhost:8000/p/one' },
}),
)
expect(got).toBe(null)
})
it('returns the proxied path', async () => {
validatorsMock.mockResolvedValue({ ok: true })
const got = await getProxyAssetPath(
newTestRequest({
path: '/foo.png',
headers: { referer: 'http://localhost:8000/p/one' },
}),
)
expect(got).toBe(`/p/one/foo.png`)
})
})
})
57 changes: 57 additions & 0 deletions utopia-remix/app/util/assets.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import urlJoin from 'url-join'
import { ServerEnvironment } from '../env.server'
import { allowedAssetExtensions } from '../handlers/splatLoad'
import { canAccessProject } from '../handlers/validators'
import { UserProjectPermission } from '../types'

export async function getProxyAssetPath(req: Request): Promise<string | null> {
const url = new URL(req.url)

// the extension must be allowed
if (!allowedAssetExtensions.some((extension) => url.pathname.endsWith(extension))) {
return null
}

// the request referer must contain the project id
const maybeProjectId = getProjectIdFromReferer(req)
if (maybeProjectId == null) {
return null
}

// validate access to the project
const { ok } = await canAccessProject({
projectId: maybeProjectId,
permission: UserProjectPermission.CAN_VIEW_PROJECT,
request: req,
})
if (!ok) {
return null
}

return '/' + urlJoin('p', maybeProjectId, url.pathname)
}

export function getProjectIdFromReferer(req: Request): string | null {
const referer = req.headers.get('referer')
if (referer == null) {
return null
}

const refererURL = new URL(referer)
const isMaybeProjectReferer =
refererURL.origin === ServerEnvironment.CORS_ORIGIN &&
(refererURL.pathname.startsWith('/p/') || refererURL.pathname.startsWith('/project/'))
if (!isMaybeProjectReferer) {
return null
}

const maybeProjectId = refererURL.pathname
.replace(/^\/p(roject)?\//, '')
.split('/')[0]
.split('-')[0]
if (maybeProjectId.length === 0) {
return null
}

return maybeProjectId
}

0 comments on commit 25e90ed

Please sign in to comment.