Skip to content

Commit

Permalink
fix: download unapproved apps (#578)
Browse files Browse the repository at this point in the history
* fix: append token to download-url

* fix: add decoration to request to get user

* feat: add v2 api for downloading app

* feat: add getClientBaseUrl helper

* feat: add downloadUrl to appVersions api/v2

* fix: fix download-link

* fix: return same structure from executeQuery

* fix: new executeQuery api

* refactor: getDownloadUrl-hook

* test: add tests for version download

* refactor: add helper for creating version form

* fix: fix app-version mock order

* fix: add test for downloadUrl in v2-api

* fix: cleanup

* fix: dont delete getServerUrl
  • Loading branch information
Birkbjo authored Nov 12, 2021
1 parent 802207a commit b49597e
Show file tree
Hide file tree
Showing 13 changed files with 557 additions and 77 deletions.
115 changes: 72 additions & 43 deletions client/src/components/Versions/VersionsTable/VersionsTable.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAuth0 } from '@auth0/auth0-react'
import {
Button,
Table,
Expand All @@ -9,56 +10,84 @@ import {
TableCell,
} from '@dhis2/ui'
import PropTypes from 'prop-types'
import { useCallback, useEffect, useState } from 'react'
import styles from './VersionsTable.module.css'
import config from 'config'
import { renderDhisVersionsCompatibility } from 'src/lib/render-dhis-versions-compatibility'

const { appChannelToDisplayName } = config.ui

const VersionsTable = ({ versions, renderDeleteVersionButton }) => (
<Table className={styles.table}>
<TableHead>
<TableRowHead>
<TableCellHead>Version</TableCellHead>
<TableCellHead>Channel</TableCellHead>
<TableCellHead>DHIS2 version compatibility</TableCellHead>
<TableCellHead>Upload date</TableCellHead>
<TableCellHead></TableCellHead>
</TableRowHead>
</TableHead>
<TableBody>
{versions.map(version => (
<TableRow key={version.id}>
<TableCell>{version.version}</TableCell>
<TableCell className={styles.channelNameCell}>
{appChannelToDisplayName[version.channel]}
</TableCell>
<TableCell>
{renderDhisVersionsCompatibility(
version.minDhisVersion,
version.maxDhisVersion
)}
</TableCell>
<TableCell>
<span title={new Date(version.createdAt)}>
{new Date(version.createdAt).toLocaleDateString()}
</span>
</TableCell>
<TableCell>
<a download href={version.downloadUrl} tabIndex="-1">
<Button small secondary>
Download
</Button>
</a>
{renderDeleteVersionButton &&
renderDeleteVersionButton(version)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
const useCreateGetDownloadUrl = url => {
const [token, setToken] = useState()
const { getAccessTokenSilently } = useAuth0()

useEffect(() => {
const getToken = async () => {
const token = await getAccessTokenSilently()
setToken(token)
}
getToken()
}, [url, getAccessTokenSilently])

return useCallback(
url => (token ? url.concat(`?token=${token}`) : url),
[token]
)
}

const VersionsTable = ({ versions, renderDeleteVersionButton }) => {
const getDownloadUrl = useCreateGetDownloadUrl()

return (
<Table className={styles.table}>
<TableHead>
<TableRowHead>
<TableCellHead>Version</TableCellHead>
<TableCellHead>Channel</TableCellHead>
<TableCellHead>DHIS2 version compatibility</TableCellHead>
<TableCellHead>Upload date</TableCellHead>
<TableCellHead></TableCellHead>
</TableRowHead>
</TableHead>
<TableBody>
{versions.map(version => (
<TableRow key={version.id}>
<TableCell>{version.version}</TableCell>
<TableCell className={styles.channelNameCell}>
{appChannelToDisplayName[version.channel]}
</TableCell>
<TableCell>
{renderDhisVersionsCompatibility(
version.minDhisVersion,
version.maxDhisVersion
)}
</TableCell>
<TableCell>
<span title={new Date(version.createdAt)}>
{new Date(
version.createdAt
).toLocaleDateString()}
</span>
</TableCell>
<TableCell>
<a
download
href={getDownloadUrl(version.downloadUrl)}
tabIndex="-1"
>
<Button small secondary>
Download
</Button>
</a>
{renderDeleteVersionButton &&
renderDeleteVersionButton(version)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
VersionsTable.propTypes = {
versions: PropTypes.array.isRequired,
renderDeleteVersionButton: PropTypes.func,
Expand Down
2 changes: 1 addition & 1 deletion server/seeds/mock/appversions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ const apps = require('./apps')
const [
dhis2App,
whoApp,
pendingApp,
rejectedApp,
pendingApp,
betaOnlyTrackerWidget,
canaryOnlyDashboardWidget,
] = apps
Expand Down
2 changes: 2 additions & 0 deletions server/src/models/v2/AppVersion.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ const definition = defaultDefinition
downloadUrl: Joi.string().uri().allow(''),
minDhisVersion: Joi.string().required(),
maxDhisVersion: Joi.string().allow(null, ''),
slug: Joi.string(),
})
.alter({
db: s =>
s
.rename('minDhisVersion', 'min_dhis2_version')
.rename('maxDhisVersion', 'max_dhis2_version')
.rename('demoUrl', 'demo_url'),
external: s => s.strip('slug'),
})
.label('AppVersion')

Expand Down
5 changes: 5 additions & 0 deletions server/src/routes/v2/appVersions.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,17 @@ module.exports = [
const pager = request.plugins.pagination
const { appVersionService } = request.services(true)

const setDownloadUrl =
appVersionService.createSetDownloadUrl(request)

const versions = await appVersionService.findByAppId(
appId,
{ pager, filters },
db
)

versions.result.map(setDownloadUrl)

return h.response(versions)
},
},
Expand Down
82 changes: 81 additions & 1 deletion server/src/routes/v2/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ const {
} = require('../../security')
const App = require('../../services/app')
const Organisation = require('../../services/organisation')
const { getFile } = require('../../utils')
const Joi = require('../../utils/CustomJoi')
const { Filters } = require('../../utils/Filter')
const { filterAppsBySpecificDhis2Version } = require('../../utils/filters')
const { convertAppsToApiV1Format } = require('../v1/apps/formatting')

const CHANNELS = ['stable', 'development', 'canary']
const APPTYPES = ['APP', 'DASHBOARD_WIDGET', 'TRACKER_DASHBOARD_WIDGET']

Expand Down Expand Up @@ -180,4 +181,83 @@ module.exports = [
return appVersionService.getAvailableChannels(appId, db)
},
},
{
method: 'GET',
path: '/v2/apps/{appId}/download/{appSlug}_{version}.zip',
config: {
auth: { strategy: 'token', mode: 'try' },
tags: ['api', 'v2'],
validate: {
params: Joi.object({
appId: Joi.string().required(),
appSlug: Joi.string().required(),
version: Joi.string().required(),
}),
},
plugins: {
queryFilter: {
enabled: true,
},
},
},
handler: async (request, h) => {
const { db } = h.context
const { appVersionService } = request.services(true)

const { appId, appSlug, version } = request.params
const user = request.getUser()

const filterObject = {
slug: appSlug,
version: version,
}

if (!user) {
// only approved apps should be downloadable if not logged in
filterObject.status = 'APPROVED'
} else {
const canEditApp =
currentUserIsManager(request) ||
(await App.canEditApp(user.id, appId, db))

if (!canEditApp) {
return Boom.forbidden()
}
}

const appVersionFilter =
Filters.createFromQueryFilters(filterObject)

const { result } = await appVersionService.findByAppId(
appId,
{ filters: appVersionFilter },
db
)

if (result.length < 1) {
throw Boom.notFound()
}

const [appVersion] = result

const file = await getFile(
`${appVersion.appId}/${appVersion.id}`,
'app.zip'
)

request.log(
'getFile',
`Fetching file for ${appVersion.appId} / ${appVersion.id}`
)

return h
.response(file.Body)
.type('application/zip')
.header(
'Content-Disposition',
`attachment; filename=${appVersion.slug}_${appVersion.version}.zip;`
)
.header('Content-length', file.ContentLength)
},
},
]
20 changes: 20 additions & 0 deletions server/src/security/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ const getCurrentUserFromRequest = request => {
})
}

// used with server.decorate to make request.getUser() available
// this-context is the request-object
const getUserDecoration = function () {
try {
const { userId, name } = this.auth.credentials

if (userId == null) {
return null
}

return {
id: userId,
name,
}
} catch (e) {
return null
}
}

module.exports = {
canDeleteApp,
canChangeAppStatus,
Expand All @@ -96,6 +115,7 @@ module.exports = {
createUserValidationFunc: require('./createUserValidationFunc'),
createApiKeyValidationFunc: require('./createApiKeyValidationFunc'),
getCurrentUserFromRequest,
getUserDecoration,
currentUserIsManager,
ROLES,
verifyBundle: require('./verifyBundle'),
Expand Down
19 changes: 9 additions & 10 deletions server/src/server/init-server.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
const debug = require('debug')('apphub:server:boot:api')

const Blipp = require('blipp')
const Hapi = require('@hapi/hapi')
const Inert = require('@hapi/inert')
const Vision = require('@hapi/vision')

const HapiSwagger = require('hapi-swagger')
const Pino = require('hapi-pino')
const Schmervice = require('@hapipal/schmervice')
const Blipp = require('blipp')
const debug = require('debug')('apphub:server:boot:api')
const Pino = require('hapi-pino')
const HapiSwagger = require('hapi-swagger')
const options = require('../options/index.js')

const staticFrontendRoutes = require('../plugins/staticFrontendRoutes')
const apiRoutes = require('../plugins/apiRoutes')
const errorMapper = require('../plugins/errorMapper')
const queryFilter = require('../plugins/queryFilter')
const pagination = require('../plugins/pagination')
const { createEmailService } = require('../services/EmailService')
const queryFilter = require('../plugins/queryFilter')
const staticFrontendRoutes = require('../plugins/staticFrontendRoutes')
const { getUserDecoration } = require('../security')
const { createAppVersionService } = require('../services/appVersion')
const { createEmailService } = require('../services/EmailService')

exports.init = async (knex, config) => {
debug('Starting server...')
Expand Down Expand Up @@ -128,6 +126,7 @@ exports.init = async (knex, config) => {
plugin: pagination,
})

server.decorate('request', 'getUser', getUserDecoration)
await server.start()

debug(`Server running at: ${server.info.uri}`)
Expand Down
7 changes: 7 additions & 0 deletions server/src/services/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
createLocalizedAppVersion,
addAppVersionToChannel,
addAppMedia,
getOrganisationAppsByUserId,
} = require('../data')

exports.create = async (
Expand Down Expand Up @@ -110,3 +111,9 @@ exports.createMediaForApp = (
},
db
)

exports.canEditApp = async (userId, appId, knex) => {
const appsUserCanEdit = await getOrganisationAppsByUserId(userId, knex)

return appsUserCanEdit.find(app => app.app_id === appId) != null
}
Loading

0 comments on commit b49597e

Please sign in to comment.