diff --git a/changelog/unreleased/enhancement-app-store b/changelog/unreleased/enhancement-app-store new file mode 100644 index 00000000000..2619376c1fe --- /dev/null +++ b/changelog/unreleased/enhancement-app-store @@ -0,0 +1,9 @@ +Enhancement: App Store app + +We've added a new App Store app to the platform. This app allows you to explore available apps and download them from +our `awesome-ocis` github repository. + +In order to use this app, you currently need to adjust your `csp.yaml` file (only if you have it customized). +Please add the URL `'https://raw.githubusercontent.com/owncloud/awesome-ocis/'` to the sections `connect-src` and `img-src`. + +https://github.com/owncloud/web/pull/11302 diff --git a/deployments/examples/ocis_web/config/ocis/csp.yaml b/deployments/examples/ocis_web/config/ocis/csp.yaml index c63031f66a5..aea4ec41805 100644 --- a/deployments/examples/ocis_web/config/ocis/csp.yaml +++ b/deployments/examples/ocis_web/config/ocis/csp.yaml @@ -3,6 +3,7 @@ directives: - '''self''' connect-src: - '''self''' + - 'https://raw.githubusercontent.com/owncloud/awesome-ocis/' - 'https://${COMPANION_DOMAIN|companion.owncloud.test}/' - 'wss://${COMPANION_DOMAIN|companion.owncloud.test}/' default-src: @@ -20,6 +21,7 @@ directives: - '''self''' - 'data:' - 'blob:' + - 'https://raw.githubusercontent.com/owncloud/awesome-ocis/' # In contrary to bash and docker the default is given after the | character - 'https://${COLLABORA_DOMAIN|collabora.owncloud.test}/' manifest-src: diff --git a/dev/docker/ocis.web.config.json b/dev/docker/ocis.web.config.json index 1bb559ba936..cfcc6cb58e0 100644 --- a/dev/docker/ocis.web.config.json +++ b/dev/docker/ocis.web.config.json @@ -20,7 +20,8 @@ "admin-settings", "ocm", "webfinger", - "epub-reader" + "epub-reader", + "app-store" ], "external_apps": [ { diff --git a/dev/docker/ocis/csp.yaml b/dev/docker/ocis/csp.yaml index e9a6ce3b097..6a428a7011f 100644 --- a/dev/docker/ocis/csp.yaml +++ b/dev/docker/ocis/csp.yaml @@ -3,6 +3,7 @@ directives: - '''self''' connect-src: - '''self''' + - 'https://raw.githubusercontent.com/owncloud/awesome-ocis/' default-src: - '''none''' font-src: @@ -20,6 +21,7 @@ directives: - '''self''' - 'data:' - 'blob:' + - 'https://raw.githubusercontent.com/owncloud/awesome-ocis/' # In contrast to bash and docker the default is given after the | character - 'https://${ONLYOFFICE_DOMAIN|host.docker.internal:9981}/' - 'https://${COLLABORA_DOMAIN|host.docker.internal:9980}/' diff --git a/packages/design-system/src/components/OcImage/OcImage.vue b/packages/design-system/src/components/OcImage/OcImage.vue index 922f28a1553..2d87b771a20 100644 --- a/packages/design-system/src/components/OcImage/OcImage.vue +++ b/packages/design-system/src/components/OcImage/OcImage.vue @@ -1,5 +1,5 @@ - + + + diff --git a/packages/web-app-app-store/src/appid.ts b/packages/web-app-app-store/src/appid.ts new file mode 100644 index 00000000000..7931422ff9e --- /dev/null +++ b/packages/web-app-app-store/src/appid.ts @@ -0,0 +1 @@ +export const APPID = 'app-store' diff --git a/packages/web-app-app-store/src/components/AppActions.vue b/packages/web-app-app-store/src/components/AppActions.vue new file mode 100644 index 00000000000..0a0b4304af7 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppActions.vue @@ -0,0 +1,53 @@ + + + + + + + + diff --git a/packages/web-app-app-store/src/components/AppAuthors.vue b/packages/web-app-app-store/src/components/AppAuthors.vue new file mode 100644 index 00000000000..f927d6cc390 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppAuthors.vue @@ -0,0 +1,29 @@ + + + + {{ author.name }} + {{ author.name }} + + + + + + + diff --git a/packages/web-app-app-store/src/components/AppContextualHelper.vue b/packages/web-app-app-store/src/components/AppContextualHelper.vue new file mode 100644 index 00000000000..5e0768c4e4f --- /dev/null +++ b/packages/web-app-app-store/src/components/AppContextualHelper.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/web-app-app-store/src/components/AppImageGallery.vue b/packages/web-app-app-store/src/components/AppImageGallery.vue new file mode 100644 index 00000000000..16046367da9 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppImageGallery.vue @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web-app-app-store/src/components/AppResources.vue b/packages/web-app-app-store/src/components/AppResources.vue new file mode 100644 index 00000000000..0638f243873 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppResources.vue @@ -0,0 +1,31 @@ + + + + + + {{ resource.label }} + + + + + + + diff --git a/packages/web-app-app-store/src/components/AppTags.vue b/packages/web-app-app-store/src/components/AppTags.vue new file mode 100644 index 00000000000..db53a38adae --- /dev/null +++ b/packages/web-app-app-store/src/components/AppTags.vue @@ -0,0 +1,46 @@ + + + + {{ tag }} + + + + + + diff --git a/packages/web-app-app-store/src/components/AppTile.vue b/packages/web-app-app-store/src/components/AppTile.vue new file mode 100644 index 00000000000..7a218007006 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppTile.vue @@ -0,0 +1,84 @@ + + + + + + + + + + + {{ app.name }} + + + + v{{ app.mostRecentVersion.version }} + + + {{ app.subtitle }} + + + + + + + + + + diff --git a/packages/web-app-app-store/src/components/AppVersions.vue b/packages/web-app-app-store/src/components/AppVersions.vue new file mode 100644 index 00000000000..78cd341d383 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppVersions.vue @@ -0,0 +1,72 @@ + + + + v{{ item.version }} + + {{ $gettext('most recent') }} + + + + + + + + + diff --git a/packages/web-app-app-store/src/composables/actions/index.ts b/packages/web-app-app-store/src/composables/actions/index.ts new file mode 100644 index 00000000000..9158614212a --- /dev/null +++ b/packages/web-app-app-store/src/composables/actions/index.ts @@ -0,0 +1 @@ +export * from './useAppActionsDownload' diff --git a/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts new file mode 100644 index 00000000000..760cd777c4d --- /dev/null +++ b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts @@ -0,0 +1,34 @@ +import { Action, triggerDownloadWithFilename } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' +import { App, AppVersion } from '../../types' + +export type AppActionOptions = { + app: App + version?: AppVersion +} + +export const useAppActionsDownload = () => { + const { $gettext } = useGettext() + + const downloadAppAction: Action = { + name: 'download-app', + icon: 'download', + label: () => { + return $gettext('Download') + }, + handler: (options?) => { + const version = options.version || options.app.mostRecentVersion + const filename = version.filename || version.url.split('/').pop() + triggerDownloadWithFilename(version.url, filename) + }, + isVisible: () => { + return true + }, + componentType: 'button', + appearance: 'outline' + } + + return { + downloadAppAction + } +} diff --git a/packages/web-app-app-store/src/composables/index.ts b/packages/web-app-app-store/src/composables/index.ts new file mode 100644 index 00000000000..9fef387da76 --- /dev/null +++ b/packages/web-app-app-store/src/composables/index.ts @@ -0,0 +1 @@ +export * from './actions' diff --git a/packages/web-app-app-store/src/index.ts b/packages/web-app-app-store/src/index.ts new file mode 100644 index 00000000000..3738c91e7ca --- /dev/null +++ b/packages/web-app-app-store/src/index.ts @@ -0,0 +1,113 @@ +import translations from '../l10n/translations.json' +import { useGettext } from 'vue3-gettext' +import { computed, unref } from 'vue' +import { + AppMenuItemExtension, + defineWebApplication, + Extension, + useAbility, + useUserStore +} from '@ownclouders/web-pkg' +import { urlJoin } from '@ownclouders/web-client' +import { RouteRecordRaw } from 'vue-router' +import { useRepositoriesStore } from './piniaStores' +import { AppStoreConfigSchema, AppStoreRepository } from './types' +import { APPID } from './appid' + +export default defineWebApplication({ + setup({ applicationConfig }) { + const { $gettext } = useGettext() + const { can } = useAbility() + const userStore = useUserStore() + const repositoryStore = useRepositoriesStore() + + const defaultRepositories: AppStoreRepository[] = [ + { + name: 'awesome-ocis', + url: 'https://raw.githubusercontent.com/owncloud/awesome-ocis/main/webApps/apps.json' + } + ] + if (applicationConfig?.repositories) { + const { repositories } = AppStoreConfigSchema.parse(applicationConfig) + repositoryStore.setRepositories(repositories || defaultRepositories) + } else { + repositoryStore.setRepositories(defaultRepositories) + } + + const appInfo = { + name: $gettext('App Store'), + id: APPID, + icon: 'store', + color: '#ff6961' + } + + const hasPermission = computed(() => { + // TODO: which permission(s) do we need to check here? + return userStore.user && can('read-all', 'Setting') + }) + + const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'root', + component: () => import('./LayoutContainer.vue'), + redirect: urlJoin(appInfo.id, 'list'), + beforeEnter: (to, from, next) => { + if (!unref(hasPermission)) { + return next({ path: '/' }) + } + next() + }, + meta: { + authContext: 'user' + }, + children: [ + { + path: 'list', + name: 'list', + component: () => import('./views/AppList.vue'), + meta: { + authContext: 'user', + title: $gettext('App Store') + } + }, + { + path: 'app/:appId', + name: 'details', + component: () => import('./views/AppDetails.vue'), + meta: { + authContext: 'user', + title: $gettext('App Details') + } + } + ] + } + ] + + const menuItemExtension: AppMenuItemExtension = { + id: `app.${appInfo.id}.menuItem`, + type: 'appMenuItem', + label: () => appInfo.name, + color: appInfo.color, + icon: appInfo.icon, + priority: 30, + path: urlJoin(appInfo.id) + } + const extensions = computed(() => { + const result: Extension[] = [] + + if (unref(hasPermission)) { + result.push(menuItemExtension) + } + + return result + }) + + return { + appInfo, + routes, + translations, + extensions + } + } +}) diff --git a/packages/web-app-app-store/src/piniaStores/apps.ts b/packages/web-app-app-store/src/piniaStores/apps.ts new file mode 100644 index 00000000000..befc224db1d --- /dev/null +++ b/packages/web-app-app-store/src/piniaStores/apps.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia' +import { App, AppStoreRepository, RawAppListSchema } from '../types' +import { ref, unref } from 'vue' +import { APPID } from '../appid' +import { useRepositoriesStore } from './repositories' + +export const useAppsStore = () => { + const repositoriesStore = useRepositoriesStore() + + return defineStore(`${APPID}-apps`, () => { + const apps = ref([]) + + const getById = (id: string) => { + return unref(apps).find((app) => app.id === id) + } + + const loadApps = async () => { + const loadAppsByRepo = async (repo: AppStoreRepository): Promise => { + try { + const data = await fetch(repo.url) + const appsListData = await data.json() + const appsList = RawAppListSchema.parse(appsListData) + return appsList.apps.map((app) => { + return { + ...app, + repository: repo, + mostRecentVersion: app.versions[0] + } + }) + } catch (e) { + console.error(e) + return [] + } + } + + const loadAppsPromises: Promise[] = [] + for (const repo of repositoriesStore.repositories) { + loadAppsPromises.push(loadAppsByRepo(repo)) + } + apps.value = (await Promise.all(loadAppsPromises)).flat() + } + + return { + apps, + getById, + loadApps + } + })() +} diff --git a/packages/web-app-app-store/src/piniaStores/index.ts b/packages/web-app-app-store/src/piniaStores/index.ts new file mode 100644 index 00000000000..996943c305d --- /dev/null +++ b/packages/web-app-app-store/src/piniaStores/index.ts @@ -0,0 +1,2 @@ +export * from './apps' +export * from './repositories' diff --git a/packages/web-app-app-store/src/piniaStores/repositories.ts b/packages/web-app-app-store/src/piniaStores/repositories.ts new file mode 100644 index 00000000000..71c259be80d --- /dev/null +++ b/packages/web-app-app-store/src/piniaStores/repositories.ts @@ -0,0 +1,17 @@ +import { defineStore } from 'pinia' +import { AppStoreRepository } from '../types' +import { ref } from 'vue' +import { APPID } from '../appid' + +export const useRepositoriesStore = defineStore(`${APPID}-repositories`, () => { + const repositories = ref([]) + + const setRepositories = (repos: AppStoreRepository[]) => { + repositories.value = repos + } + + return { + repositories, + setRepositories + } +}) diff --git a/packages/web-app-app-store/src/types.ts b/packages/web-app-app-store/src/types.ts new file mode 100644 index 00000000000..2bcc982f9c3 --- /dev/null +++ b/packages/web-app-app-store/src/types.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +export const AppStoreRepositorySchema = z.object({ + name: z.string(), + url: z.string() +}) +export type AppStoreRepository = z.infer + +export const AppStoreConfigSchema = z.object({ + repositories: z.array(AppStoreRepositorySchema) +}) + +export const AppVersionSchema = z.object({ + version: z.string(), + url: z.string(), + filename: z.string().optional() +}) +export type AppVersion = z.infer + +export const AppAuthorSchema = z.object({ + name: z.string(), + email: z.string().optional(), + url: z.string().optional() +}) + +export const AppImageSchema = z.object({ + url: z.string(), + caption: z.string().optional() +}) +export type AppImage = z.infer + +export const AppResourceSchema = z.object({ + url: z.string(), + label: z.string(), + icon: z.string().optional() +}) + +export const RawAppSchema = z.object({ + id: z.string(), + name: z.string(), + subtitle: z.string(), + description: z.string().optional(), + license: z.string(), + versions: z.array(AppVersionSchema), // versions are expected to be sorted from newest to oldest + authors: z.array(AppAuthorSchema), + tags: z.array(z.string()), + coverImage: AppImageSchema.optional(), + screenshots: z.array(AppImageSchema).optional().default([]), + resources: z.array(AppResourceSchema).optional().default([]) // e.g. documentation, github, etc. +}) + +export const AppSchema = RawAppSchema.extend({ + repository: AppStoreRepositorySchema, + mostRecentVersion: AppVersionSchema +}) +export type App = z.infer + +export const RawAppListSchema = z.object({ + apps: z.array(RawAppSchema) +}) diff --git a/packages/web-app-app-store/src/views/AppDetails.vue b/packages/web-app-app-store/src/views/AppDetails.vue new file mode 100644 index 00000000000..dc9d5be0298 --- /dev/null +++ b/packages/web-app-app-store/src/views/AppDetails.vue @@ -0,0 +1,106 @@ + + + + + + + + + + + + {{ app.name }} + + v{{ app.mostRecentVersion.version }} + + + {{ app.subtitle }} + + {{ $gettext('Details') }} + {{ app.description }} + + + {{ $gettext('Tags') }} + + + + {{ $gettext('Author') }} + + + + {{ $gettext('Resources') }} + + + + + {{ $gettext('Releases') }} + + + + + + + + + + + diff --git a/packages/web-app-app-store/src/views/AppList.vue b/packages/web-app-app-store/src/views/AppList.vue new file mode 100644 index 00000000000..6067801a43d --- /dev/null +++ b/packages/web-app-app-store/src/views/AppList.vue @@ -0,0 +1,117 @@ + + + + {{ $gettext('App Store') }} + + + + + + + + + + + + + + diff --git a/packages/web-pkg/src/components/BatchActions.vue b/packages/web-pkg/src/components/BatchActions.vue index 835c86f1a00..1fcdf6af6ff 100644 --- a/packages/web-pkg/src/components/BatchActions.vue +++ b/packages/web-pkg/src/components/BatchActions.vue @@ -21,7 +21,7 @@
{{ app.subtitle }}
{{ app.description }}