diff --git a/.dockerignore b/.dockerignore index 0fc6ac8..132a131 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,8 @@ tests .github .env public/s +backend/public/s +frontend public-docker temp tmp @@ -14,4 +16,5 @@ tmp settings.json docker/runtime docker/log +old archiv \ No newline at end of file diff --git a/.github/workflows/cypresss.yml b/.github/workflows/cypresss.yml new file mode 100644 index 0000000..85bf0b9 --- /dev/null +++ b/.github/workflows/cypresss.yml @@ -0,0 +1,24 @@ +name: End-to-end tests +on: + push: + branches: [ "refactor/separate-backend" ] + workflow_dispatch: +jobs: + cypress-run: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Run backend + uses: actions/setup-node@v4 + with: + node-version: 18 + - run: | + yarn && + yarn build && + chmod a+x startbe.sh && + ./startbe.sh + - name: Run E2E tests + uses: cypress-io/github-action@v6 + with: + start: yarn preview \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0cfe791..e1bb37f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,41 @@ -.idea/ -.vscode/ -node_modules/ -build/ -tmp/ -temp/ -.tmp/ -.nuxt -dist +# Nuxt dev/build outputs .output -*.log* +.data +.nuxt .nitro .cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files .env -*.lock -upload/ -coverage -secret +.env.* +!.env.example + +cypress/e2e/screenshots +cypress/e2e/logs +cypress/screenshots +cypress/logs + public/s -public-docker -org.* -settings.json -docker-compose.yml -docker/runtime -archiv +backend/node_modules +backend/public/s +backend/dist +backend/data/*.sqlite +backend/yarn.lock +backend/.env + +frontend + +yarn.lock diff --git a/.kanbn/index.md b/.kanbn/index.md deleted file mode 100644 index efcea39..0000000 --- a/.kanbn/index.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -startedColumns: - - 'In Progress Mon-Mon' -completedColumns: - - Done -sprints: - - - start: 2022-05-09T07:26:47.305Z - name: 'Fish Create Podcast' ---- - -# Podkashde - -## Backlog - -- [podcast-slug-uniqueness-prüfen](tasks/podcast-slug-uniqueness-prüfen.md) -- [move-episode-to-other-podcast](tasks/move-episode-to-other-podcast.md) -- [uat-test-suite](tasks/uat-test-suite.md) -- [autor-aus-enum-liste-oder-eingeben](tasks/autor-aus-enum-liste-oder-eingeben.md) -- [drag-n-drop-image](tasks/drag-n-drop-image.md) - -## Todo - -- [generate-short-info-from-author-video-and-reference](tasks/generate-short-info-from-author-video-and-reference.md) - -## In Progress - -## Done - -- [login-passwort-change](tasks/login-passwort-change.md) -- [injecting-menu-for-integration-in-website](tasks/injecting-menu-for-integration-in-website.md) -- [analytics](tasks/analytics.md) -- [refresh-bug](tasks/refresh-bug.md) -- [admin-als-super-admin-delete-podcast-und-import](tasks/admin-als-super-admin-delete-podcast-und-import.md) -- [bibelstelle-video-editierbar](tasks/bibelstelle-video-editierbar.md) diff --git a/.kanbn/tasks/admin-als-super-admin-delete-podcast-und-import.md b/.kanbn/tasks/admin-als-super-admin-delete-podcast-und-import.md deleted file mode 100644 index 9108985..0000000 --- a/.kanbn/tasks/admin-als-super-admin-delete-podcast-und-import.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -created: 2022-12-02T17:11:19.646Z -updated: 2022-12-03T14:54:54.320Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-12-03T14:54:54.320Z ---- - -# admin als super admin - Delete Podcast und Import diff --git a/.kanbn/tasks/analytics.md b/.kanbn/tasks/analytics.md deleted file mode 100644 index 86cfcde..0000000 --- a/.kanbn/tasks/analytics.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -created: 2022-11-29T10:53:22.296Z -updated: 2022-12-12T09:35:30.491Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-12-12T09:35:30.491Z ---- - -# Analytics - -https://github.com/ijkml/nuxt-umami - -https://umami.is/docs/collect-data diff --git a/.kanbn/tasks/autor-aus-enum-liste-oder-eingeben.md b/.kanbn/tasks/autor-aus-enum-liste-oder-eingeben.md deleted file mode 100644 index 21ea6f3..0000000 --- a/.kanbn/tasks/autor-aus-enum-liste-oder-eingeben.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -created: 2022-11-10T09:58:36.908Z -updated: 2022-11-10T09:58:44.641Z -assigned: "" -progress: 0 -tags: [] ---- - -# Autor aus Enum Liste oder eingeben diff --git a/.kanbn/tasks/bibelstelle-video-editierbar.md b/.kanbn/tasks/bibelstelle-video-editierbar.md deleted file mode 100644 index 482ea98..0000000 --- a/.kanbn/tasks/bibelstelle-video-editierbar.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -created: 2022-12-02T17:13:07.640Z -updated: 2022-12-03T14:45:20.810Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-12-03T14:45:20.810Z ---- - -# Bibelstelle + Video editierbar diff --git a/.kanbn/tasks/drag-n-drop-image.md b/.kanbn/tasks/drag-n-drop-image.md deleted file mode 100644 index ab20bf7..0000000 --- a/.kanbn/tasks/drag-n-drop-image.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -created: 2022-05-10T07:13:03.869Z -updated: 2022-05-10T07:13:03.867Z -assigned: "" -progress: 0 -tags: [] ---- - -# Drag'n Drop image diff --git a/.kanbn/tasks/generate-short-info-from-author-video-and-reference.md b/.kanbn/tasks/generate-short-info-from-author-video-and-reference.md deleted file mode 100644 index 7e6fbf3..0000000 --- a/.kanbn/tasks/generate-short-info-from-author-video-and-reference.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -created: 2022-12-27T18:37:56.830Z -updated: 2022-12-27T18:40:22.475Z -assigned: "" -progress: 0 -tags: [] ---- - -# Generate ShortInfo from Author, Video and Reference diff --git a/.kanbn/tasks/injecting-menu-for-integration-in-website.md b/.kanbn/tasks/injecting-menu-for-integration-in-website.md deleted file mode 100644 index 1d662e2..0000000 --- a/.kanbn/tasks/injecting-menu-for-integration-in-website.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -created: 2022-12-12T09:35:44.246Z -updated: 2022-12-27T18:19:06.034Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-12-27T18:19:06.035Z ---- - -# Injecting Menu for integration in website diff --git a/.kanbn/tasks/login-passwort-change.md b/.kanbn/tasks/login-passwort-change.md deleted file mode 100644 index 9e6517c..0000000 --- a/.kanbn/tasks/login-passwort-change.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -created: 2022-12-02T17:11:51.309Z -updated: 2022-12-03T14:36:39.641Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-12-03T14:36:39.641Z ---- - -# Login / Passwort change diff --git a/.kanbn/tasks/move-episode-to-other-podcast.md b/.kanbn/tasks/move-episode-to-other-podcast.md deleted file mode 100644 index afab358..0000000 --- a/.kanbn/tasks/move-episode-to-other-podcast.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -created: 2022-12-01T13:11:12.280Z -updated: 2022-12-03T10:33:33.285Z -assigned: "" -progress: 0 -tags: [] ---- - -# Move Episode to other Podcast diff --git a/.kanbn/tasks/paging-for-episodes-client-server.md b/.kanbn/tasks/paging-for-episodes-client-server.md deleted file mode 100644 index 77137f2..0000000 --- a/.kanbn/tasks/paging-for-episodes-client-server.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -created: 2022-11-26T06:53:28.840Z -updated: 2022-11-28T22:36:26.008Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-11-28T22:36:26.008Z ---- - -# Paging for episodes (client/server) - -Ting wiht composables lets me think it should be client for now diff --git "a/.kanbn/tasks/podcast-slug-uniqueness-pr\303\274fen.md" "b/.kanbn/tasks/podcast-slug-uniqueness-pr\303\274fen.md" deleted file mode 100644 index 9295008..0000000 --- "a/.kanbn/tasks/podcast-slug-uniqueness-pr\303\274fen.md" +++ /dev/null @@ -1,9 +0,0 @@ ---- -created: 2022-11-29T07:44:34.498Z -updated: 2022-12-27T18:19:14.357Z -assigned: "" -progress: 0 -tags: [] ---- - -# Podcast Slug uniqueness prüfen diff --git a/.kanbn/tasks/refresh-bug.md b/.kanbn/tasks/refresh-bug.md deleted file mode 100644 index e35f11f..0000000 --- a/.kanbn/tasks/refresh-bug.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -created: 2022-12-03T10:33:07.016Z -updated: 2022-12-03T15:01:26.965Z -assigned: "" -progress: 0 -tags: [] -completed: 2022-12-03T15:01:26.965Z ---- - -# Refresh bug diff --git a/.kanbn/tasks/uat-test-suite.md b/.kanbn/tasks/uat-test-suite.md deleted file mode 100644 index 68e9ec8..0000000 --- a/.kanbn/tasks/uat-test-suite.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -created: 2022-11-10T09:57:43.530Z -updated: 2022-12-03T10:33:35.110Z -assigned: "" -progress: 0 -tags: [] ---- - -# UAT - Test Suite diff --git a/.nuxtignore b/.nuxtignore index 474a3ae..3de839b 100644 --- a/.nuxtignore +++ b/.nuxtignore @@ -8,4 +8,5 @@ tmp temp data docker -archiv +old +archiv \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8b29617..673dc25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,59 @@ -FROM node:16 as builder +FROM node:18 LABEL authors="Alex Roehm" -# update dependencies and install curl -ARG BUILDTIME ARG VERSION +ARG BUILDTIME ARG REVISION +ARG NUXT_PUBLIC_URL +ARG NUXT_PUBLIC_UMAMI_HOST +ARG NUXT_PUBLIC_UMAMI_ID +ARG NUXT_PUBLIC_SKIN +ARG NUXT_PUBLIC_ENABLE_DARK_MODE +ARG NUXT_PUBLIC_LOGO +ARG NUXT_PUBLIC_LOGO_DARK +ARG NUXT_PUBLIC_EXT_MENU_DE -# Create app directory -WORKDIR /build -COPY . . -RUN echo export const VERSION=\"${VERSION}\" > version.ts -RUN echo export const BUILDTIME=\"${BUILDTIME}\" >> version.ts -RUN echo export const REVISION=\"${REVISION}\" >> version.ts - -# update each dependency in package.json to the latest version -RUN yarn +ENV DATABASE_PATH=/data +ENV DATABASE_FILE=podcasts.sqlite +ENV DATA_PATH=/var/www -# If you are building your code for production -RUN yarn build - -FROM node:16 -LABEL authors="Alex Roehm" RUN apt-get update && apt-get install -y \ - curl dumb-init logrotate nginx\ + curl dumb-init logrotate nginx vim \ && rm -rf /var/lib/apt/lists/* -COPY ./docker/nginx/startup.sh /startup.sh -RUN chmod u+x /startup.sh - +# prepare nginx COPY ./docker/nginx/default /etc/nginx/sites-available/default COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf COPY ./docker/nginx/logrotate /etc/logrotate/nginx -COPY --from=builder --chown=node:node /build/.output /var/www +RUN chown -R node:node /etc/nginx /var/log/nginx /var/lib/nginx RUN chmod go-w /etc/logrotate/nginx -RUN chown -R node:node /var/www /etc/nginx /var/log/nginx /var/lib/nginx RUN chmod a+w /run -WORKDIR /var/www +WORKDIR /data +RUN chown node:node . + +WORKDIR /app +RUN chown node:node . +COPY --chown=node:node . . USER node +# install startup script +RUN cp docker/nginx/startup.sh . +RUN chmod a+x startup.sh + +# prepare backend +RUN cd ./backend && yarn + +# Prepare Versioning +RUN printf 'export const BUILDTIME= "'$BUILDTIME'"\n' > version.ts +RUN printf 'export const REVISION= "'$REVISION'"\n' >> version.ts +RUN printf 'export const VERSION = "'$VERSION'"\n' >> version.ts + +# update each dependency in package.json to the latest version +RUN yarn + EXPOSE 80 ENTRYPOINT ["/usr/bin/dumb-init", "--"] -CMD [ "/startup.sh" ] \ No newline at end of file +CMD [ "./startup.sh" ] \ No newline at end of file diff --git a/README.md b/README.md index 8cbdc64..f5db2a2 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,75 @@ -# Podkashde +# Nuxt 3 Minimal Starter -Podkashde is a jamstack podcast management system. It is based on [nuxt 3](https://v3.nuxtjs.org). It is suposed to be a replacement for a podcast hostet on wordpress with seriously simple podcast and allows to migrate the podsts and metadata. +Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. -It uses a sqlite database. That way backup is easy, just backup the files in the directory public/s -All assets (mp3s, cover images) can be served statically from the public/s directoy +## Setup -## Development - -You need node version 16. -Clone the repo. Install the dependencies: +Make sure to install the dependencies: ```bash -# yarn -yarn install - # npm npm install # pnpm -pnpm install --shamefully-hoist +pnpm install + +# yarn +yarn install + +# bun +bun install ``` -Start the development server on http://localhost:3000 +## Development Server + +Start the development server on `http://localhost:3000`: ```bash +# npm npm run dev + +# pnpm +pnpm run dev + +# yarn +yarn dev + +# bun +bun run dev ``` -## Standalone Production +## Production Build the application for production: ```bash +# npm npm run build -``` \ No newline at end of file + +# pnpm +pnpm run build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm run preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/app.vue b/app.vue index c887fb3..2bcbca5 100644 --- a/app.vue +++ b/app.vue @@ -1,74 +1,11 @@ - - - - - - - - + + + \ No newline at end of file diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 656199d..9a43deb 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -9,7 +9,7 @@ --color-text-muted: 80, 80, 80; --color-text-muted-dark: 175, 155, 175; --color-text-inverted: 235, 250, 204; - --color-text-accent: 25, 0, 24; + --color-text-accent: 99, 102, 241; --color-bg-light: 255, 245, 255; --color-bg-dark: 10, 3, 13; --color-bg-muted: 220, 215, 235; @@ -338,4 +338,19 @@ .progressbar::-moz-range-progress { height: 4px; background: rgb(216, 216, 216); +} + +.page-enter-active, +.page-enter-leave { + transition: all 0.5s; +} + +.page-enter-from { + opacity: 0; + transform: translateY(100%) scale(0.9); +} + +.page-leave-to { + opacity: 0; + transform: translateY(-100%) scale(0.9); } \ No newline at end of file diff --git a/assets/favicon.ico b/assets/favicon.ico deleted file mode 100644 index e08b257..0000000 Binary files a/assets/favicon.ico and /dev/null differ diff --git a/public/img/ccf-logo-w.png b/assets/img/ccf-logo-w.png similarity index 100% rename from public/img/ccf-logo-w.png rename to assets/img/ccf-logo-w.png diff --git a/public/img/ccf-logo.png b/assets/img/ccf-logo.png similarity index 100% rename from public/img/ccf-logo.png rename to assets/img/ccf-logo.png diff --git a/public/img/logo-w.png b/assets/img/logo-w.png similarity index 100% rename from public/img/logo-w.png rename to assets/img/logo-w.png diff --git a/public/img/logo.png b/assets/img/logo.png similarity index 100% rename from public/img/logo.png rename to assets/img/logo.png diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..0efe086 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,13 @@ +# Example how to use Express and TypeORM with TypeScript + +1. clone repository +2. run `npm i` +3. edit `ormconfig.json` and change your database configuration (you can also change a database type, but don't forget to install specific database drivers) +4. run `npm start` +5. open `http://localhost:3000/posts` and you'll empty array +6. use curl, postman or other tools to send http requests to test your typeorm-based API + +## How to use CLI? + +1. install `typeorm` globally: `npm i -g typeorm` +2. run `typeorm -h` to show list of available commands \ No newline at end of file diff --git a/backend/data/files/menu-ext-de.json b/backend/data/files/menu-ext-de.json new file mode 100644 index 0000000..d148975 --- /dev/null +++ b/backend/data/files/menu-ext-de.json @@ -0,0 +1,66 @@ +{ + "menu": [ + { + "Description": "Höre unsere Predigen, Inputs und Biblestudies oder lies Neues und Andachten", + "Name": "Hör rein", + "Replaces": "Hör rein", + "link": "/recent", + "menu_items": [ + { + "Name": "Aktuelle Predigten", + "locale": "de", + "link": "/recent", + "local": true + }, + { + "Name": "Aktuelle Serien", + "locale": "de", + "link": "/series", + "local": true + }, + { + "Name": "Podcasts", + "locale": "de", + "link": "/podcasts", + "local": true + } + ] + }, + { + "Description": "Podcasts, Serien und Folgen verwalten ", + "Name": "Administrator", + "admin": true, + "link": "/admin", + "menu_items": [ + { + "Name": "Andministrator Login", + "locale": "de", + "loggedin": false, + "link": "/admin/login", + "local": true + }, + { + "Name": "Passwort ändern", + "loggedin": true, + "locale": "de", + "link": "/admin/setpassword", + "local": true + }, + { + "Name": "Benutzer einladen", + "locale": "de", + "loggedin": true, + "link": "/admin/invitation", + "local": true + }, + { + "Name": "Abmelden", + "locale": "de", + "loggedin": true, + "link": "#logout", + "local": true + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/data/files/meta-de.json b/backend/data/files/meta-de.json new file mode 100644 index 0000000..c6d0222 --- /dev/null +++ b/backend/data/files/meta-de.json @@ -0,0 +1,65 @@ +{ + "menu": [{ + "Description": "Höre aktuelle und zuletzt erschienene Folgen", + "Name": "Hör rein", + "Replaces": "Hör rein", + "link": "/recent", + "menu_items": [ + { + "Name": "Aktuelle Folgen", + "locale": "de", + "link": "/recent", + "local": true + }, + { + "Name": "Aktuelle Serien", + "locale": "de", + "link": "/series", + "local": true + }, + { + "Name": "Podcasts", + "locale": "de", + "link": "/podcasts", + "local": true + } + ] + }, + { + "Description": "Podcasts, Serien und Folgen verwalten ", + "Name": "Administrator", + "admin": true, + "link": "/admin", + "menu_items": [ + { + "Name": "Andministrator Login", + "locale": "de", + "loggedin": false, + "link": "/admin/login", + "local": true + }, + { + "Name": "Passwort ändern", + "loggedin": true, + "locale": "de", + "link": "/admin/setpassword", + "local": true + }, + { + "Name": "Benutzer einladen", + "locale": "de", + "loggedin": true, + "link": "/admin/invitation", + "local": true + }, + { + "Name": "Abmelden", + "locale": "de", + "loggedin": true, + "link": "#logout", + "local": true + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/data/files/meta-en.json b/backend/data/files/meta-en.json new file mode 100644 index 0000000..fa24ad7 --- /dev/null +++ b/backend/data/files/meta-en.json @@ -0,0 +1,66 @@ +{ + "menu": [ + { + "Description": "Listen to our content", + "Name": "Listen", + "Replaces": "Listen", + "link": "/recent", + "menu_items": [ + { + "Name": "Recent Episodes", + "locale": "en", + "link": "/recent", + "local": true + }, + { + "Name": "Current Series", + "locale": "en", + "link": "/series", + "local": true + }, + { + "Name": "Podcasts", + "locale": "en", + "link": "/podcasts", + "local": true + } + ] + }, + { + "Description": "Manage podcasts, series and episodes", + "Name": "Administration", + "admin": true, + "link": "/admin", + "menu_items": [ + { + "Name": "Andmin Login", + "locale": "en", + "loggedin": false, + "link": "/admin/login", + "local": true + }, + { + "Name": "Change Password", + "loggedin": true, + "locale": "en", + "link": "/admin/setpassword", + "local": true + }, + { + "Name": "Invite User", + "locale": "en", + "loggedin": true, + "link": "/admin/invitation", + "local": true + }, + { + "Name": "Logout", + "locale": "en", + "loggedin": true, + "link": "#logout", + "local": true + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..85c664c --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + transform: {'^.+\\.ts?$': 'ts-jest'}, + testEnvironment: 'node', + testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + }; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7d8b59e --- /dev/null +++ b/backend/package.json @@ -0,0 +1,47 @@ +{ + "name": "pk-backend", + "version": "0.0.1", + "description": "Backend for CCF Podkashde Podkast tool", + "license": "MIT", + "readmeFilename": "README.md", + "author": { + "name": "Alex Roehm" + }, + "repository": { + "type": "git" + }, + "devDependencies": { + "@types/body-parser": "^1.19.2", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.11", + "@types/node": "^18.15.11", + "jest": "^29.7.0", + "mocha": "^10.2.0", + "nodemon": "^3.0.3", + "typescript": "^5.0.4" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "body-parser": "^1.20.2", + "busboy": "^1.6.0", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-http-proxy": "^2.0.0", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^2.6.6", + "node-id3": "^0.2.6", + "podcast": "^2.0.1", + "sqlite3": "^5.0.2", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "typeorm": "^0.3.14" + }, + "scripts": { + "start": "ts-node src/index.ts", + "typeorm": "typeorm-ts-node-commonjs", + "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts", + "test": "jest", + "testcont": "npx jest --watch" + } +} diff --git a/backend/public/img/ccf-logo-w.png b/backend/public/img/ccf-logo-w.png new file mode 100644 index 0000000..3929c2e Binary files /dev/null and b/backend/public/img/ccf-logo-w.png differ diff --git a/backend/public/img/ccf-logo.png b/backend/public/img/ccf-logo.png new file mode 100644 index 0000000..273946d Binary files /dev/null and b/backend/public/img/ccf-logo.png differ diff --git a/backend/public/img/logo-w.png b/backend/public/img/logo-w.png new file mode 100644 index 0000000..e0c7cdd Binary files /dev/null and b/backend/public/img/logo-w.png differ diff --git a/backend/public/img/logo.png b/backend/public/img/logo.png new file mode 100644 index 0000000..b7486c1 Binary files /dev/null and b/backend/public/img/logo.png differ diff --git a/backend/public/test/img/cover.jpg b/backend/public/test/img/cover.jpg new file mode 100644 index 0000000..78063ed Binary files /dev/null and b/backend/public/test/img/cover.jpg differ diff --git a/backend/src/authMiddleware.ts b/backend/src/authMiddleware.ts new file mode 100644 index 0000000..b4b3ba3 --- /dev/null +++ b/backend/src/authMiddleware.ts @@ -0,0 +1,75 @@ +import { Express, Request, Response, NextFunction } from "express"; +import { + decodeAccessToken, + decodeRefreshToken, + decodeUrlToken, + generateAccessToken, + sendAuthToken, +} from "./jwt"; +import { getUserById } from "./services/userService"; +import { logger } from "./services/loggerService"; +import { respond } from "./tools/Controller"; + +export default async function authMiddleware(req: Request, res: Response, next: NextFunction) { + const dbg = 4 + var token = "" + if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ") && req.headers.authorization.length > 10 ) { + logger( dbg, "Auth Middleware authorization " + JSON.stringify(req.headers.authorization).substring(0,30)); + token = req.headers["authorization"]?.split(" ")[1]; + logger( dbg, "Auth Middleware token " + JSON.stringify(token).substring(0,50) + ' ...') + } + var decoded = decodeAccessToken(token); + logger( dbg, "Auth Middleware decoded token " + JSON.stringify(decoded?decoded:"decoding failed!").substring(0,30)); + if (!decoded && req.cookies) { + logger( dbg, "Auth Middleware cookies " + JSON.stringify(req.cookies).substring(0,50)) + token = req.cookies?.auth_token + if (token) + decoded = decodeAccessToken(token); + logger( dbg, "Auth Middleware auth cookie decoded " + JSON.stringify(decoded?decoded:"decoding failed").substring(0,50)); + } + if (!decoded && req.body) { + token = req.body?.refresh_token + if (token) + decoded = decodeRefreshToken(token); + logger( dbg, "Auth Middleware refresh token decoded " + JSON.stringify(decoded?decoded:"decoding failed").substring(0,50)); + } + if (!decoded && req.query ) { + logger( dbg, "Auth Middleware params " + JSON.stringify(req.query).substring(0,50)); + token = req.query.token as string + if (token) + decoded = decodeUrlToken(token); + } + if (!decoded && req.body ) { + logger( dbg, "Auth Middleware params " + JSON.stringify(req.body).substring(0,50)); + token = req.body.token as string + if (token) + decoded = decodeUrlToken(token); + } + if (!decoded) { + respond(res, 401, { + statusCode: 401, + message: "Unauthorized" + }) + } else + try { + if (decoded.userId) { + logger( dbg, "Auth Middleware try access " + JSON.stringify(decoded).substring(0,50)); + + const userId = decoded.userId; + logger( dbg, "Auth Middleware try get UserId " + JSON.stringify(userId).substring(0,30)); + + const user = await getUserById(userId); + logger( dbg, "Auth Middleware user " + JSON.stringify(user).substring(0,50)); + + sendAuthToken(res, await generateAccessToken(user)); + } + logger(4, "Auth... going on") + next() + return; + } catch (error) { + return respond(res, 401, { + statusCode: 401, + statusMessage: error.message, + }); + } +} diff --git a/backend/src/controller/All.ts b/backend/src/controller/All.ts new file mode 100644 index 0000000..0797dbf --- /dev/null +++ b/backend/src/controller/All.ts @@ -0,0 +1,86 @@ +import {Request, Response} from "express"; +import { deleteGen, deleteIdGen, getAllGen, getExtManyGen, getExtQueryAllGen, getExtQueryGen, isUpdate, saveGen, updateGen } from "../services/genericService"; +import { respond, sendResponse } from "../tools/Controller"; + +/** + * Loads all posts from the database. + */ + +export async function getExtMany(T: any, relations: Array, request: Request, response: Response) { + var tmpQuery = { + where: request.query, + relations: relations, + }; + sendResponse( response, await getExtManyGen(T, tmpQuery)); +} + +export async function getAll(T: any, request: Request, response: Response) { + sendResponse( response, await getAllGen(T)); +} + +export async function getExtQuery(T: any, extQuery: any, request: Request, response: Response) { + sendResponse( response, await getExtQueryGen(T, extQuery)); +} + +export async function getExtQueryAll(T: any, extQuery: any, request: Request, response: Response) { + sendResponse( response, await getExtQueryAllGen(T, extQuery)); +} + + +export async function getQuery(T: any, relations: Array, request: Request, response: Response) { + + var tmpQuery = { + where: request.query, + relations: relations, + }; + sendResponse( response, await getExtQueryGen(T, tmpQuery)); +} + +export async function deleteQuery(T: any,request: Request, response: Response) { + + if (await deleteGen(T, request.body)) + respond(response, 201, {statusCode: 201, message: JSON.stringify(request.body) +" deleted successfully"}) + else + respond(response, 205, {statusCode: 205, message: "Delete did not work"}); +} + +export async function saveOne(T: any, data: any ) : Promise { + var id: number | undefined = undefined + if( isUpdate(data) ) + id = await updateGen(T, data) + else + id = await saveGen(T, data) + return id +} + +export async function simpleSave(T: any, request: Request, response: Response) { + try { + var data = {... request.body} as T; + const id = await saveOne(T,data) + if (id) + respond(response, 201, {statusCode: 201, message: "id="+id+" saved successfully", id}) + else + respond(response, 500, {statusCode: 500, message: "id="+id+" problem", id}) + } catch(err) { + respond(response, 500, {statusCode: 500, message: err.message}); + } +} + +export async function simpleSaveMultiple(T: any, request: Request, response: Response) { + try { + var data = request.body as Array; + const len = data.length + var ids = new Array() + for await (const item of data) { + const id = await saveOne(T,item) + if (id) + ids.push(id) + } + if (ids.length==len) + respond(response, 201, {statusCode: 201, message: JSON.stringify(ids) + " ids saved successfully"}) + else + respond(response, 500, {statusCode: 500, message: "Not all ids saved"}) + } catch(err) { + respond(response, 500, {statusCode: 500, message: err.message}); + } +} \ No newline at end of file diff --git a/backend/src/controller/Auth.ts b/backend/src/controller/Auth.ts new file mode 100644 index 0000000..9088455 --- /dev/null +++ b/backend/src/controller/Auth.ts @@ -0,0 +1,203 @@ +import { Request, Response } from "express"; +import * as bcrypt from "bcrypt"; +import { + createUserWithToken, + getUserById, + getUserByUserName, + sanitizeUserForFrontend, + updateUser, +} from "../services/userService"; +import { + decodeRefreshToken, + decodeUrlToken, + deleteRefreshToken, + generateAccessToken, + generateRefreshToken, + sendAuthToken, + sendRefreshToken, +} from "../jwt"; +import { createSession, getSessionByToken, removeOldSessions, removeSession } from "../services/sessionService"; +import User from "../entities/User"; +import { respond, sendResponse } from "../tools/Controller"; + +export async function getUserToken(request: Request, response: Response) { + const user = await createUserWithToken({ username: request.query.username } as User, request.query.type as string); + sendResponse( response, user.token) +} + +export async function checkRefreshToken(request: Request, response: Response) { + const refreshToken = request.body.refresh_token; + if (!refreshToken) + return respond(response, 400, { + statusCode: 400, + statusMessage: "No Refresh token", + }); + + const session = await getSessionByToken(refreshToken); + + if (!session) { + return respond(response, 400, { + statusCode: 400, + statusMessage: "Refresh token is invalid", + }); + } + + const token = decodeRefreshToken(refreshToken); + if (!token) { + const days = 14; + const priorByDays = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + await removeOldSessions(priorByDays); + deleteRefreshToken(response); + + return respond(response, 400, { + statusCode: 403, + statusMessage: "Session expired", + }); + } + + try { + const accessToken = generateAccessToken(token.userId); + const user = sanitizeUserForFrontend(await getUserById(token.userId)); + user.token = accessToken; + sendResponse( response, { refresh_token: refreshToken, access_token: accessToken, user }); + } catch (error) { + return respond(response, 400, { + statusCode: 500, + statusMessage: "Something went wrong", + }); + } +} + +export async function setPassword(request: Request, response: Response) { + var username: string = request.body.username; + const password: string = request.body.password; + const oldpassword: string = request.body.oldpassword; + const token: string = request.body.token; + var credentialValid = false; + + if (!((token && password) || (password && username && oldpassword))) { + return respond(response, 400, { + statusCode: 400, + statusMessage: "Ivalid params", + }); + } + + if (token) { + const data = decodeUrlToken(token); + username = data?.username; + credentialValid = true; + } + + const user = await getUserByUserName(username); + if (!user) { + return respond(response, 400, { + statusCode: 400, + statusMessage: "Username is invalid", + }); + } + + if (!credentialValid && oldpassword) + credentialValid = await bcrypt.compare(oldpassword, user.password); + + if (!credentialValid) { + return respond(response, 400, { + statusCode: 400, + statusMessage: "Username or password is invalid", + }); + } + + const salt = bcrypt.genSaltSync(5); + user.password = bcrypt.hashSync(password, salt); + await updateUser(user); + + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + await createSession(refreshToken, user.id); + sendRefreshToken(response, refreshToken); + + sendResponse( response, { + access_token: accessToken, + refresh_token: refreshToken, + user: sanitizeUserForFrontend(user), + }); +} + +export async function checkToken(request: Request, response: Response) { + const token = request.query.token as string; + + if (!token) { + return respond(response, 400, { + statusCode: 400, + statusMessage: "Ivalid params", + }); + } + + const data = decodeUrlToken(token); + const user = await getUserByUserName(data?.username); + + if (!user) { + return respond(response, 400, { + statusCode: 400, + statusMessage: "Username or password is invalid", + }); + } + + return sendResponse( response, { + access: data.purpose, + user: sanitizeUserForFrontend(user), + }); +} + +export async function login(request: Request, response: Response) : Promise { + const username: string = request.body.username; + const password: string = request.body.password; + + if (!username || !password) { + respond(response, 400, { + statusCode: 400, + statusMessage: "Ivalid params", + }); + return false + } + + const user = await getUserByUserName(username); + + if (!user) { + respond(response, 400, { + statusCode: 400, + statusMessage: "Username or password is invalid", + }); + return false; + } + const doesThePasswordMatch = await bcrypt.compare(password, user.password); + + if (!doesThePasswordMatch) { + respond(response, 400, { + statusCode: 400, + statusMessage: "Username or password is invalid", + }); + return false; + } + + const accessToken = generateAccessToken(user); + user.token = accessToken; + sendAuthToken(response, accessToken); + + const refreshToken = generateRefreshToken(user); + await createSession(refreshToken, user.id); + sendResponse( response, { + access_token: accessToken, + refresh_token: refreshToken, + user: sanitizeUserForFrontend(user), + }); + return true; +} + +export async function logout(request: Request, response: Response) { + try { + const refreshToken = request.body.refresh_token as string; + await removeSession(refreshToken) + } catch (error) { } + + return respond(response, 200,{ message: 'Done' }) +} diff --git a/backend/src/controller/Count.ts b/backend/src/controller/Count.ts new file mode 100644 index 0000000..b071ee6 --- /dev/null +++ b/backend/src/controller/Count.ts @@ -0,0 +1,34 @@ +import {Request, Response} from "express"; +import Episode from "../entities/Episode"; +import Serie from "../entities/Serie"; +import { Not } from "typeorm"; +import { getDbManager } from "../services/datasourceService"; +import { sendResponse } from "../tools/Controller"; +import Podcast from "../entities/Podcast"; + +/** + * Loads all posts from the database. + */ +export async function count(request: Request, response: Response) { + const query = request.query; + var result = 0; + const q = {} + if (query.hasOwnProperty('excludeId')) { + const id = query['excludeId'] + q['id'] = Not(id) + } + if (query.hasOwnProperty('id')) { + q['id'] = query.id + } + if (query.hasOwnProperty('slug')) { + q['slug'] = query.slug + } + const manager = getDbManager() + if (query.hasOwnProperty('serie')) + result = await manager.countBy(Serie, q); + else if (query.hasOwnProperty('podcast')) + result = await manager.countBy(Podcast, q); + else + result = await manager.countBy(Episode, q); + return sendResponse( response, result.toString()); +} \ No newline at end of file diff --git a/backend/src/controller/Enums.ts b/backend/src/controller/Enums.ts new file mode 100644 index 0000000..8b6aa04 --- /dev/null +++ b/backend/src/controller/Enums.ts @@ -0,0 +1,17 @@ +import {Request, Response} from "express"; +import Enumerator from "../entities/Enumerator"; +import { sendResponse } from "../tools/Controller"; +import getRepository from "../services/datasourceService"; + +export async function setEnums(request: Request, response: Response) { + + const repository = getRepository(Enumerator) + + repository.save(request.body.map((item) => item)); + + // load posts + const enums = await repository.find(); + + // return loaded posts + sendResponse( response, { statusCode: 201, message: "enums saved"}); +} diff --git a/backend/src/controller/Episodes.ts b/backend/src/controller/Episodes.ts new file mode 100644 index 0000000..d6881a1 --- /dev/null +++ b/backend/src/controller/Episodes.ts @@ -0,0 +1,25 @@ +import { Request, Response } from "express"; +import { moveEpisode, saveEpisode } from "../services/episodeService"; +import { respond } from "../tools/Controller"; + +export async function saveEpisodeAction(request: Request, response: Response) { + try { + const episode = await saveEpisode(request.body); + respond(response, 201, { statusCode: 201, message: "Episode id=" + episode.id + " saved successfully", id: episode.id }); + } catch (err) { + respond(response, 500, { statusCode: 500, message: err.message }); + } +} + +export async function moveEpisodeAction(request: Request, response: Response) { + try { + const { episode, podcast, serie } = request.body; + const result = await moveEpisode(episode, podcast, serie); + if (!result) respond(response, 500, { message: (result as any).message }); + respond(response, 201, { statusCode: 201, message: "Episode id=" + episode.id + " saved successfully" }); + } catch (err) { + console.log(err) + respond(response, 500, { statusCode: 500, message: err.message }); + } +} + diff --git a/backend/src/controller/Files.ts b/backend/src/controller/Files.ts new file mode 100644 index 0000000..bde1a64 --- /dev/null +++ b/backend/src/controller/Files.ts @@ -0,0 +1,94 @@ +import * as fs from 'fs'; +import { Request, Response } from "express"; +import { getFileFromUrl } from "../services/filesService"; +import { ARCHIV_PATH, DATA_PATH, UPLOAD_TEMP_PATH } from "../tools/Configuration"; +import { getPathForMediaFile } from "../services/episodeService"; +import { copyFile, createDir, findFile, moveFile } from "../tools/DataFiles"; +import busboy = require('busboy'); +import { respond } from '../tools/Controller'; + +export async function fetchFileAction(request: Request, response: Response) { + + const body = request.body as any; + const result = await getFileFromUrl(body.orgurl,body.newpath, body.altpath, body.newfile) + respond(response, result.statusCode, result) +} + +export async function downloadFileAction(request: Request, response: Response) { + var path = '' + if (request.params.path) + path = DATA_PATH + request.params.path; + else if (request.params.name) { + path = DATA_PATH + await getPathForMediaFile(request.params.name) + } else + respond(response, 404, {statusCode: 404, message: "No path or filename provided"}) + return response.download(path) +} + +export async function copyFromLocalArchive(request: Request, response: Response) { + const path = findFile(request.body.name, ARCHIV_PATH) + if (path && path.length>0) { + var newpath = request.body.serverPath + request.body.slug; + if (copyFile(path, newpath, request.body.name)) + return respond(response, 201, { + statusCode: 201, + message: 'File fetched', + path: newpath + '/' + request.body.name, + }) + } + return respond(response, 500, {statusCode: 500, message: "File not found"}) + +} + +export async function uploadFile(request: Request, response: Response) { + +try { + var error = "create dir" + createDir(DATA_PATH+UPLOAD_TEMP_PATH) + var error = "parse form data" + const { filename, path, uploaded } = await paseFormdata(request,response) + var error = "move file" + if (moveFile(uploaded, path, filename)) + return respond(response, 201, {statusCode: 201, message: 'Created '+path+filename}); + } catch (err) { + return respond(response, 500, {statusCode: 500, message: error + ' ' + err.message}); + } + return respond(response, 500, {statusCode: 500, message: 'No File found'}); +} + +export async function paseFormdata(request: Request, response: Response) : Promise<{ uploaded: string, path: string, filename: string }> { + return new Promise((resolve) => { + var filedata = {} as any + const fields = {} as any + const busb = busboy({ headers: request.headers }) + busb.on('file', (name: string, file: any, info: any) => { + var { filename, encoding, mimeType } = info + if (filename.length==0) + filename = "tmp" + const path = DATA_PATH+UPLOAD_TEMP_PATH+filename; + var ws = fs.createWriteStream(path) + filedata = { + fieldname: name, + path, + filename, + encoding, + mimeType + } + file.on('close',()=>{ + ws.close() + }) + file.on('data',(chunk)=>{ + ws.write(chunk) + }) + }) + busb.on('field', (name : string, value: object, info: any) => { + fields[name] = value + }) + busb.on('close', () => { + resolve({ uploaded: filedata.path, path: fields.path, filename: fields.filename }) + }) + request.pipe(busb) + return + }) + +} diff --git a/backend/src/controller/Migrate.ts b/backend/src/controller/Migrate.ts new file mode 100644 index 0000000..eb60051 --- /dev/null +++ b/backend/src/controller/Migrate.ts @@ -0,0 +1,13 @@ +import { Request, Response } from "express"; +import { migrateWpEpisode } from "../services/wpMigrationService"; +import { respond } from "../tools/Controller"; + +export async function migrateEpisode(request: Request, response: Response) { + try { + const data = request.body; + await migrateWpEpisode(data.episode, (data.podcastId?data.podcastId:0)) + respond(response, 201, { statusCode: 201, message: "Episode id=" + (data as any).id + " saved successfully" }); + } catch (err) { + respond(response, 500, { statusCode: 500, message: err.message }); + } + } \ No newline at end of file diff --git a/backend/src/controller/Podcast.ts b/backend/src/controller/Podcast.ts new file mode 100644 index 0000000..5fd69be --- /dev/null +++ b/backend/src/controller/Podcast.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import { Request, Response } from "express"; +import { respond } from "../tools/Controller"; +import { getAllGen, getExtQueryGen } from "../services/genericService"; +import Podcast from "../entities/Podcast"; +import Enumerator from "../entities/Enumerator"; +import { getEnumFunctions } from "../tools/Enumerations"; +import { generateRss } from "../tools/RssGenerator"; +import { FEED_SLUG } from "../tools/Configuration"; +import { createDir, dataPath } from "../tools/DataFiles"; +import { setLastUpdate } from '../services/podcastService'; + +export async function generateRssAction(request: Request, response: Response) { + try { + const slug = request.query.slug as string; + const baseUrl = process.env.podcastRssUrl ?? request.query.mediaBase as string + const podcast = await getExtQueryGen(Podcast, { + where: { + slug + }, + relations: ["episodes"] + }) as Podcast + + + const enums = await getAllGen(Enumerator) as Array + + const xml = generateRss(podcast, baseUrl, FEED_SLUG, getEnumFunctions(enums) ) + + var dir = dataPath(FEED_SLUG); + createDir(dir) + const target_file = dir + podcast.slug+".xml" + fs.writeFileSync(target_file, xml) + setLastUpdate(podcast.id) + respond(response, 201, { message: "RSS for podcast slug=" + podcast.slug + " successfully generated" }); + } catch (error) { + respond(response, 500, { + statusCode: 500, + statusMessage: "Internal server error"}) + } +} diff --git a/backend/src/controller/Serie.ts b/backend/src/controller/Serie.ts new file mode 100644 index 0000000..7c59718 --- /dev/null +++ b/backend/src/controller/Serie.ts @@ -0,0 +1,22 @@ +import Serie from "../entities/Serie"; +import { setLastAndFirst } from "../services/serieService"; +import { simpleSave } from "./All"; +import {Request, Response} from "express"; +import { respond } from "../tools/Controller"; + +export async function updateSerieAfterEpisodeChange(request: Request, response: Response) { + try { + const body = request.body as any; + if (body.hasOwnProperty('id')) { + if (body.hasOwnProperty('title')) { + simpleSave(Serie, request, response) + } else { + var slug = await setLastAndFirst(body.id) + respond(response,200,slug) + } + } else + return respond(response, 501, "nd data"); + } catch (err) { + return respond(response, 500, err.message); + } +} \ No newline at end of file diff --git a/backend/src/controller/Settings.ts b/backend/src/controller/Settings.ts new file mode 100644 index 0000000..d854aba --- /dev/null +++ b/backend/src/controller/Settings.ts @@ -0,0 +1,41 @@ +import * as fs from 'fs'; +import { Request, Response } from "express"; +import { DATABASE_PATH, DATAFILES_PATH, EXT_MENU_AP, EXT_MENU_FILTER, EXT_MENU_BASEURL } from "../tools/Configuration"; +import { sendResponse } from '../tools/Controller'; +import { Any, EntityMetadata } from 'typeorm'; + +var menu = [] + +function linkGenerator( section: any ) : any { + const linker = item => { + if (!item.link || item.link.length <1) + if (item.page && item.page.slug && item.page.slug.length > 0) + item.link = EXT_MENU_BASEURL + item.page.slug + } + linker(section) + section.menu_items.forEach(element => linker(element)); + return section +} + +async function getExternalMenu( locale: string ) : Promise { + if (!EXT_MENU_AP.startsWith('http')) return [] + const url = EXT_MENU_AP.replace("locale",locale) + const result = await fetch(url) + const ext_menu = await result.json() as Array + return ext_menu.filter(( item: any ) => !item.link || item.link.toLowerCase().indexOf(EXT_MENU_FILTER?.toLowerCase()) === -1).map(linkGenerator) +} +async function getLocalMenu( locale: string ) : Promise { + const meta = JSON.parse(fs.readFileSync(DATABASE_PATH + DATAFILES_PATH + 'meta-'+locale+'.json',{encoding:'utf8', flag:'r'})); + console.log(EntityMetadata) + return meta.menu +} + +export async function getMetadata(request: Request, response: Response) { + const locale = request.query.locale + if (menu.length<1) { + const ext_menu = await getExternalMenu(locale as string) + const loc_menu = await getLocalMenu(locale as string) + menu = ext_menu.concat(loc_menu) + } + sendResponse( response, { menu }) +} \ No newline at end of file diff --git a/server/db/entities/Enumerator.ts b/backend/src/entities/Enumerator.ts similarity index 86% rename from server/db/entities/Enumerator.ts rename to backend/src/entities/Enumerator.ts index 9f1c9e0..3da5f05 100644 --- a/server/db/entities/Enumerator.ts +++ b/backend/src/entities/Enumerator.ts @@ -1,8 +1,7 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import IEnumerator from "../../../base/types/IEnumerator"; @Entity() -export default class Enumerator extends BaseEntity implements IEnumerator{ +export default class Enumerator extends BaseEntity{ @PrimaryGeneratedColumn() id: number; diff --git a/backend/src/entities/Episode.ts b/backend/src/entities/Episode.ts new file mode 100644 index 0000000..62c1ff7 --- /dev/null +++ b/backend/src/entities/Episode.ts @@ -0,0 +1,140 @@ +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + CreateDateColumn, + DeleteDateColumn, + UpdateDateColumn, +} from "typeorm"; +import Podcast from "./Podcast"; +import Serie from "./Serie"; + + +@Entity() +export default class Episode extends BaseEntity { + + @PrimaryGeneratedColumn() + id: number; + + @Column("text") + title: string; + + @Column("text") + link: string; + + @Column("text") + slug: string; + + @Column("datetime") + pubdate: Date; + + @Column("text") + creator: string; + + @Column("text") + + @Column({ + type: 'text', + nullable: true, + }) + description: string; + + @Column({ + type: 'text', + nullable: true, + }) + subtitle: string; + + @Column("text") + keyword: string; + + @Column({ + type: 'text', + nullable: true, + }) + summary: string; + + @Column("text") + image: string; + + @Column({ + type: 'text', + nullable: true, + }) + postimage: string; + + @Column("boolean") + block: boolean; + + @Column("boolean") + explicit: boolean; + + @Column("int") + duration: number; + + @Column("int") + rawsize: number; + + @Column("int") + state: number; + + @Column({ + type: 'boolean', + default: false, + }) + draft: boolean; + + @Column({ + type: 'text', + nullable: true, + }) + video_link: string; + + @Column({ + type: 'text', + nullable: true, + }) + cross_ref: string; + + @Column({ + type: 'int', + nullable: true, + }) + external_id: number; + + @Column({ + type: 'int', + nullable: true, + }) + ext_series_id: number; + + @Column({ + type: 'int', + nullable: true, + }) + ext_podcast_id: number; + + @Column({ + type: 'text', + nullable: true, + }) + lastbuild: string; + + @ManyToOne(() => Podcast, (podcast) => podcast.episodes) + podcast: Podcast; + + @ManyToOne(() => Serie, (serie) => serie.episodes) + serie: Serie; + + @CreateDateColumn({ type: "datetime" }) + public createdAt: Date; + + @UpdateDateColumn({ type: "datetime" }) + public updatedAt: Date; + + @DeleteDateColumn({ type: "datetime" }) + public deletedAt: Date; +} + diff --git a/backend/src/entities/Podcast.ts b/backend/src/entities/Podcast.ts new file mode 100644 index 0000000..ecc8b29 --- /dev/null +++ b/backend/src/entities/Podcast.ts @@ -0,0 +1,118 @@ +import { + BaseEntity, + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn, + CreateDateColumn, + DeleteDateColumn, + UpdateDateColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; +import Episode from './Episode'; + +@Entity() +export default class Podcast extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + cover_file: string; + + @Column('text') + title: string; + + @Column('text') + slug: string; + + @Column('text') + subtitle: string; + + @Column('text') + author: string; + + @Column('text') + summary: string; + + @Column('text') + description: string; + + @Column('int') + language_id: number; + + @Column('int') + category_id: number; + + @Column('int') + type_id: number; + + @Column('boolean') + explicit: boolean; + + @Column('text') + link: string; + + @Column('text') + copyright: string; + + @Column('text') + owner_name: string; + + @Column('text') + owner_email: string; + + @Column('text') + lastbuild: string; + + @Column('int') + state: number; + + @Column('int') + external_id: number; + + @Column({ + type: 'boolean', + default: false, + }) + draft: boolean; + + @Column({ + type: 'text', + default: '', + }) + apple_url: string; + + @Column({ + type: 'text', + default: '', + }) + spotify_url: string; + + @Column({ + type: 'text', + default: '', + }) + google_url: string; + + @Column({ + type: 'text', + default: '', + }) + stitcher_url: string; + + @OneToMany(() => Episode, (episode) => episode.podcast, { + cascade: true, + onDelete: 'CASCADE', + }) + episodes: Episode[]; + + @CreateDateColumn({ type: 'datetime' }) + public createdAt: Date; + + @UpdateDateColumn({ type: 'datetime' }) + public updatedAt: Date; + + @DeleteDateColumn({ type: 'datetime' }) + public deletedAt: Date; +} diff --git a/server/db/entities/Serie.ts b/backend/src/entities/Serie.ts similarity index 51% rename from server/db/entities/Serie.ts rename to backend/src/entities/Serie.ts index ce83285..cdf1f0f 100644 --- a/server/db/entities/Serie.ts +++ b/backend/src/entities/Serie.ts @@ -10,44 +10,11 @@ import { ManyToMany, JoinTable, } from "typeorm"; -import { ContentState } from "../../../base/types/ContentState"; -import ISerie from "../../../base/types/ISerie"; import Episode from "./Episode"; import Podcast from "./Podcast"; -export function initSerie(serie: Serie) { - serie.cover_file = ""; - serie.title = ""; - serie.slug = ""; - serie.subtitle = ""; - serie.description = ""; - serie.state = ContentState.draft; - serie.draft = false; - serie.lastbuild = ""; - serie.external_id = -1; - serie.firstEpisode = null; - serie.lastEpisode = null; -} - -export function getSerie(from): Serie { - var serie = new Serie(); - initSerie(serie); - serie.cover_file = from.cover_file; - serie.title = from.title; - serie.slug = from.slug; - serie.subtitle = from.subtitle; - serie.state = from.state; - serie.draft = from.draft; - serie.firstEpisode = from.firstEpisode; - serie.lastEpisode = from.lastEpisode; - if (from.hasOwnProperty("id")) serie.id = from.id; - if (from.hasOwnProperty("external_id")) serie.external_id = from.external_id; - if (from.hasOwnProperty("description")) serie.description = from.description; - return serie; -} - @Entity() -export default class Serie extends BaseEntity implements ISerie { +export default class Serie extends BaseEntity { @PrimaryGeneratedColumn() id: number; diff --git a/server/db/entities/Session.ts b/backend/src/entities/Session.ts similarity index 61% rename from server/db/entities/Session.ts rename to backend/src/entities/Session.ts index 72c1378..997df08 100644 --- a/server/db/entities/Session.ts +++ b/backend/src/entities/Session.ts @@ -1,18 +1,8 @@ import { BaseEntity, Column, CreateDateColumn, DeleteDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm"; -import ISession from "../../../base/types/ISession"; import User from "./User"; -export function getSession( sessiondata: ISession) { - var session = new Session(); - if (session.id>0) - session.id = sessiondata.id; - session.userId = sessiondata.userId; - session.refreshToken = sessiondata.refreshToken; - return session; -} - @Entity() -export default class Session extends BaseEntity implements ISession{ +export default class Session extends BaseEntity { @PrimaryGeneratedColumn() id: number; diff --git a/backend/src/entities/User.ts b/backend/src/entities/User.ts new file mode 100644 index 0000000..b9d8418 --- /dev/null +++ b/backend/src/entities/User.ts @@ -0,0 +1,30 @@ +import { BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import Session from "./Session"; + +@Entity() +export default class User extends BaseEntity { + + @PrimaryGeneratedColumn() + id: number; + + @Column("text") + username: string; + + @Column("text") + name: string; + + @Column("text") + email: string; + + @Column({ type: "text", nullable: true }) + password: string; + + @Column({ type: "text", nullable: true }) + token: string; + + @OneToMany(() => Session, (session) => session.user, { + cascade: true, + onDelete: "CASCADE", + }) + sessions: Session[]; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..d05e7d0 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,68 @@ +import "reflect-metadata"; +import {createConnection} from "typeorm"; +import {Request, Response} from "express"; +import * as express from "express"; +import * as bodyParser from "body-parser"; +import {AppRoutes, AuthRoutes} from "./routes"; +//import cors from 'cors'; +import cors = require('cors'); +import { logger, requestLoggerMiddleware } from "./services/loggerService"; +import authMiddleware from "./authMiddleware"; +import { NextFunction } from "express-serve-static-core"; +import { initDataSource } from "./services/datasourceService"; +import { DATABASE_PATH, DATA_PATH } from "./tools/Configuration"; +const cookieParser = require('cookie-parser'); +const proxy = require('express-http-proxy'); + +// create connection with database +initDataSource().then(() => { + // create express app + const app = express(); + app.use("/s", express.static(DATA_PATH+'/s')); + app.use("/img", express.static(DATA_PATH+'/img')); + app.use("/test", express.static(DATA_PATH+'/test')); + app.use(bodyParser.json()); + app.use(cookieParser()); + + app.use(function(req, res, next) { + res.header('Access-Control-Allow-Origin', req.get('Origin') ?? 'http://localhost:3000'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS'); + res.header('Access-Control-Expose-Headers', 'Content-Length'); + res.header('Access-Control-Allow-Headers', 'Accept, Content-Type, Credentials, Authorization, Cookie, X-Requested-With, Range, refresh_token, Special-Request-Header'); + // if (req.method === 'OPTIONS') { + // return res.sendStatus(200); + // } else { + return next(); + // } + }); + // register all application routes + AppRoutes.forEach(route => { + logger(3, "Adding free route " + route.method + " " + route.path) + app[route.method](route.path, (request: Request, response: Response, next: NextFunction) => { + requestLoggerMiddleware(request) + route.action(request, response) + .then(() => next) + .catch(err => next(err)); + }); + }) + AuthRoutes.forEach(route => { + logger(3, "Adding restricted route " + route.method + " " + route.path) + app[route.method](route.path, (request: Request, response: Response, next: NextFunction) => { + requestLoggerMiddleware(request) + authMiddleware(request,response, () => { + route.action(request, response) + .then(() => next) + .catch(err => next(err)) + }); + }) + }) + + //app.use('/',proxy("http://localhost:3000")) + + // run app + app.listen(3003); + + console.log("Express application is up and running on port 3003"); + +}).catch(error => console.log("TypeORM connection error: ", error)); diff --git a/backend/src/jwt.ts b/backend/src/jwt.ts new file mode 100644 index 0000000..53a6a32 --- /dev/null +++ b/backend/src/jwt.ts @@ -0,0 +1,66 @@ +import { Response} from "express"; +import * as jwt from "jsonwebtoken" +import User from "./entities/User" +import { getTokenSecret } from "./tools/Configuration"; + +export const generateAccessToken = (user: User) => { + + return jwt.sign({ userId: user.id }, getTokenSecret( "ACCESS" ), { + expiresIn: '10m' + }) +} + +export const generateRefreshToken = (user: User) => { + + return jwt.sign({ userId: user.id }, getTokenSecret( "REFRESH" ), { + expiresIn: '2d' + }) +} + +export const generateUrlToken = (username: string, purpose: string, expiry: string) => { + + return jwt.sign({ username, purpose }, getTokenSecret( "URL" ), { + expiresIn: expiry + }) +} + +export const decodeUrlToken = (token: string) : { purpose: string, username: string } | null => { + try { + return jwt.verify(token, getTokenSecret( "URL" )) + } catch (error) { + return null + } +} + +export const decodeRefreshToken = (token: string) => { + try { + return jwt.verify(token, getTokenSecret( "REFRESH" )) + } catch (error) { + return null + } +} + +export const decodeAccessToken = (token: string) => { + try { + return jwt.verify(token, getTokenSecret( "ACCESS" )) + } catch (error) { + return null + } +} + +export const sendRefreshToken = (response: Response, token: string) => { + response.cookie('refresh_token', token, { path: '/', httpOnly: true, sameSite: true, domain: 'localhost:3003' }) +} + +export const deleteRefreshToken = (response: Response) => { + response.clearCookie('refresh_token') +} + +export const sendAuthToken = (response: Response, token: string) => { + response.cookie('auth_token', token, { path: '/', httpOnly: true, sameSite: true, domain: 'localhost:3003' }) +} + +export const deleteAuthToken = (response: Response) => { + response.clearCookie('auth_token') +} + diff --git a/backend/src/migration/.gitkeep b/backend/src/migration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes.ts b/backend/src/routes.ts new file mode 100644 index 0000000..6297edc --- /dev/null +++ b/backend/src/routes.ts @@ -0,0 +1,179 @@ +import { deleteQuery, getAll, getExtMany, getExtQuery, getExtQueryAll, getQuery, simpleSave, simpleSaveMultiple } from "./controller/All"; +import { checkRefreshToken, checkToken, getUserToken, login, logout, setPassword } from "./controller/Auth"; +import { count } from "./controller/Count"; +import { setEnums } from "./controller/Enums"; +import { moveEpisodeAction, saveEpisodeAction } from "./controller/Episodes"; +import { copyFromLocalArchive, downloadFileAction, fetchFileAction, uploadFile } from "./controller/Files"; +import Enumerator from "./entities/Enumerator"; +import Episode from "./entities/Episode"; +import Podcast from "./entities/Podcast"; +import Serie from "./entities/Serie"; +import { getRouteSlug } from "./tools/Configuration"; +import { getMetadata } from "./controller/Settings"; +import { generateRssAction } from "./controller/Podcast"; +import { migrateEpisode } from "./controller/Migrate"; +import { updateSerieAfterEpisodeChange } from "./controller/Serie"; + +/** + * All application routes. + */ +export const AuthRoutes = [ + { + path: getRouteSlug("USERTOKEN_AP"), + method: "get", + action: getUserToken + }, + { + path: getRouteSlug("REFRESH_AP"), + method: "post", + action: checkRefreshToken + }, + { + path: getRouteSlug("PASSWORD_AP"), + method: "post", + action: setPassword + }, + { + path: getRouteSlug("CHECK_TOKEN_AP"), + method: "get", + action: checkToken + }, + { + path: getRouteSlug("ENUMERATIONS_AP"), + method: "post", + action: setEnums + }, + { + path: getRouteSlug("EPISODE_AP"), + method: "delete", + action: (req, res) => deleteQuery(Episode,req,res) + }, + { + path: getRouteSlug("EPISODE_AP"), + method: "post", + action: saveEpisodeAction + }, + { + path: getRouteSlug("EPISODEMOVE_AP"), + method: "post", + action: moveEpisodeAction + }, + { + path: getRouteSlug("PODCAST_AP"), + method: "post", + action: (req, res) => simpleSave(Podcast, req, res) + }, + { + path: getRouteSlug("PODCAST_AP"), + method: "delete", + action: (req, res) => deleteQuery(Podcast,req,res) + }, + { + path: getRouteSlug("SERIE_AP"), + method: "delete", + action: (req, res) => deleteQuery(Serie,req,res) + }, + { + path: getRouteSlug("SERIES_AP"), + method: "post", + action: (req, res) => simpleSaveMultiple(Serie,req,res) + }, + { + path: getRouteSlug("SERIE_AP"), + method: "post", + action: (req, res) => updateSerieAfterEpisodeChange(req,res) + }, + { + path: getRouteSlug("FETCHLOCAL_AP"), + method: "post", + action: fetchFileAction + }, + { + path: getRouteSlug("FROMARCHIVE_AP"), + method: "post", + action: copyFromLocalArchive + }, + { + path: getRouteSlug("UPLOAD_AP"), + method: "post", + action: uploadFile + }, + { + path: getRouteSlug("GENERATE_RSS_AP"), + method: "get", + action: generateRssAction + }, + { + path: getRouteSlug("EPISODEWP_AP"), + method: "post", + action: migrateEpisode + }, +] + +export const AppRoutes = [ + { + path: getRouteSlug("LOGIN_AP"), + method: "post", + action: login + }, + { + path: getRouteSlug("LOGOUT_AP"), + method: "post", + action: logout + }, + { + path: getRouteSlug("COUNT_AP"), + method: "get", + action: count + }, + { + path: getRouteSlug("ENUMERATIONS_AP"), + method: "get", + action: (req, res) => getAll(Enumerator, req, res) + }, + { + path: getRouteSlug("EPISODE_AP"), + method: "get", + action: (req, res) => getQuery(Episode, ["podcast", "serie"], req, res) + }, + { + path: getRouteSlug("EPISODES_AP"), + method: "get", + action: (req, res) => getExtMany(Episode, ["podcast", "serie"], req, res) + }, + { + path: getRouteSlug("PODCASTS_AP"), + method: "get", + action: (req, res) => getAll(Podcast, req, res) + }, + { + path: getRouteSlug("PODCAST_AP"), + method: "get", + action: (req, res) => getQuery(Podcast, ["episodes"], req, res) + }, + { + path: getRouteSlug("SERIES_AP"), + method: "get", + action: (req, res) => getExtQueryAll(Serie, { + where: {}, + order: { + lastEpisode: 'DESC' + }, + }, req, res) + }, + { + path: getRouteSlug("SERIE_AP"), + method: "get", + action: (req, res) => getQuery(Serie, ["episodes" ], req, res) + }, + { + path: getRouteSlug("FILES_AP"), + method: "get", + action: downloadFileAction + }, + { + path: getRouteSlug("METADATA_AP"), + method: "get", + action: getMetadata + } +]; \ No newline at end of file diff --git a/server/db/initdata.ts b/backend/src/services/datasourceService.ts similarity index 75% rename from server/db/initdata.ts rename to backend/src/services/datasourceService.ts index 039665a..32cd54d 100644 --- a/server/db/initdata.ts +++ b/backend/src/services/datasourceService.ts @@ -1,24 +1,78 @@ import { DataSource } from "typeorm"; -import Enumerator, { getEnumerator } from "./entities/Enumerator"; -import User from "./entities/User"; -import bcrypt from "bcrypt" -import getSecSettings from "../security"; - -export async function addAdmin(db: DataSource) { - const config = getSecSettings(); - const user = await db.manager.find(User, { where: { username: config.adminUser }}) +import Enumerator, { getEnumerator } from "../entities/Enumerator"; +import * as bcrypt from "bcrypt" +import User from "../entities/User"; +import { logger } from "./loggerService"; +import { DATABASE_FILE, DATABASE_PATH } from "../tools/Configuration"; + +export default function getRepository(T) { return dataSource.getRepository(T) } +export function getDbManager() { return dataSource.manager } + +const appDataSource = new DataSource({ + "type": "sqlite", + "database": DATABASE_PATH + "/" + DATABASE_FILE, + "synchronize": true, + "logging": false, + "entities": [ + "src/entities/**/*.ts" + ], + "migrations": [ + "src/migrations/**/*.ts" + ], + "subscribers": [ + "src/subscribers/**/*.ts" + ], + // "cli": { + // "entitiesDir": "src/entity", + // "migrationsDir": "src/migration", + // "subscribersDir": "src/subscriber" + // } +}) + +export function setTestDataSource(TstDataSource: DataSource) { + dataSource = TstDataSource +} + +var dataSource: DataSource = null + +export async function initDataSource(): Promise { + if (!dataSource) + dataSource = appDataSource; + if (dataSource.isInitialized) return dataSource; + else { + console.log("init db") + await dataSource.initialize(); + console.log("db initialized") + await dataSource.runMigrations(); + console.log("db migrated") + const german = await dataSource.manager.findOneBy(Enumerator, { + shorttext: "de-DE", + }); + if (!german) { + console.log("init data") + await fillDefaultEnums(dataSource); + } + await addAdmin(dataSource); + return dataSource; + } + } + +async function addAdmin(db: DataSource) { + const user = await db.manager.find(User, { where: { username: process.env.ADMIN_USER || "Admin" }}) if (user.length>0) return; const adminuser = new User(); - adminuser.username=config.ADMIN_USER; + adminuser.username= process.env.ADMIN_USER ?? "Admin"; adminuser.name='Administrator' adminuser.email='' const salt = bcrypt.genSaltSync(5); - adminuser.password = bcrypt.hashSync(config.ADMIN_PASSWORD, salt); - return await db.manager.save(adminuser); + adminuser.password = bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? "AdminPassword", salt); + const ret = await db.manager.save(adminuser); + logger(3,"Created Admin " + adminuser.username) + return ret } -export async function fillDefaultEnums(db: DataSource) { +async function fillDefaultEnums(db: DataSource) { const list = [ ["deutsch", "de-DE", "language", 0, 1], ["english", "en-US", "language", 0, 2], @@ -139,6 +193,7 @@ export async function fillDefaultEnums(db: DataSource) { return await db.manager.transaction(async (manager) => { return await manager.save( list.map((item) => { + logger(3, "Init db Enum "+JSON.stringify(item).substring(0,50)) return getEnumerator({ displaytext: item[0] as string, shorttext: item[1] as string, diff --git a/server/db/entities/Episode.ts b/backend/src/services/episodeService.ts similarity index 50% rename from server/db/entities/Episode.ts rename to backend/src/services/episodeService.ts index fece685..7835bad 100644 --- a/server/db/entities/Episode.ts +++ b/backend/src/services/episodeService.ts @@ -1,19 +1,43 @@ -import { - BaseEntity, - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, - CreateDateColumn, - DeleteDateColumn, - UpdateDateColumn, -} from "typeorm"; -import IEpisode from "../../../base/types/IEpisode"; -import Podcast from "./Podcast"; -import Serie from "./Serie"; - +import Episode from "../entities/Episode"; +import { isUpdate, saveGen, updateGen } from "./genericService"; +import { setLastAndFirst } from "./serieService"; +import writeTags from "../tools/Id3Tagger"; +import Serie from "../entities/Serie"; +import Podcast from "../entities/Podcast"; +import getRepository, { getDbManager } from "./datasourceService"; +import { isQualifiedUrl } from "../tools/Urls"; +import { dataPath, getFilename, moveFile } from "../tools/DataFiles"; +import { SERVER_MP3_PATH } from "../tools/Configuration"; +import { Like } from "typeorm"; + +export async function saveEpisode(episode : Episode): Promise { + if( isUpdate(episode) ) { + const record = await getRepository(Episode).preload(episode) as Episode + await updateGen(Episode, record) + if (episode.serie) + setLastAndFirst(episode.serie.id) + return episode; + } else { + const id = await saveGen(Episode, episode) + const epi = getEpisode({ ...episode, id }) as Episode + writeTags(epi) + return epi; + } +}; + +export async function moveEpisode(episode : Episode, podcast: Podcast, serie: Serie): Promise { + if (!isQualifiedUrl(episode.link)) { + const file = getFilename(episode.link) + const newpath = SERVER_MP3_PATH + podcast.slug + await moveFile(dataPath(episode.link), newpath, file) + episode.link = newpath + '/' + file + } + episode.podcast = podcast + episode.serie = serie + return await saveEpisode(episode) +} -export function getEpisode(from) { +export function getEpisode(from: any) : Episode { var episode = new Episode(); episode.id = from.id; episode.image = from.image; @@ -68,129 +92,13 @@ export function joinEpisodePodcastAndSerie(episode: Episode, podcast: Podcast, s } } -@Entity() -export default class Episode extends BaseEntity implements IEpisode { - - @PrimaryGeneratedColumn() - id: number; - - @Column("text") - title: string; - - @Column("text") - link: string; - - @Column("text") - slug: string; - - @Column("datetime") - pubdate: Date; - - @Column("text") - creator: string; - - @Column("text") - - @Column({ - type: 'text', - nullable: true, - }) - description: string; - - @Column({ - type: 'text', - nullable: true, - }) - subtitle: string; - - @Column("text") - keyword: string; - - @Column({ - type: 'text', - nullable: true, - }) - summary: string; - - @Column("text") - image: string; - - @Column({ - type: 'text', - nullable: true, - }) - postimage: string; - - @Column("boolean") - block: boolean; - - @Column("boolean") - explicit: boolean; - - @Column("int") - duration: number; - - @Column("int") - rawsize: number; - - @Column("int") - state: number; - - @Column({ - type: 'boolean', - default: false, - }) - draft: boolean; - - @Column({ - type: 'text', - nullable: true, - }) - video_link: string; - - @Column({ - type: 'text', - nullable: true, - }) - cross_ref: string; - - @Column({ - type: 'int', - nullable: true, - }) - external_id: number; - - @Column({ - type: 'int', - nullable: true, - }) - ext_series_id: number; - - @Column({ - type: 'int', - nullable: true, - }) - ext_podcast_id: number; - - @Column({ - type: 'text', - nullable: true, - }) - lastbuild: string; - - @ManyToOne(() => Podcast, (podcast) => podcast.episodes) - podcast: Podcast; - - @ManyToOne(() => Serie, (serie) => serie.episodes) - serie: Serie; - - @CreateDateColumn({ type: "datetime" }) - public createdAt: Date; - - @UpdateDateColumn({ type: "datetime" }) - public updatedAt: Date; - - @DeleteDateColumn({ type: "datetime" }) - public deletedAt: Date; -} +export const getPathForMediaFile = async function (filename: string): Promise { + var tmpQuery = { + where: { + link: Like("%"+filename) + } + }; + var result = await getRepository(Episode).findOneOrFail(tmpQuery); + return result.link +} \ No newline at end of file diff --git a/backend/src/services/filesService.ts b/backend/src/services/filesService.ts new file mode 100644 index 0000000..fb60410 --- /dev/null +++ b/backend/src/services/filesService.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import fetch from 'node-fetch' +import { createDir, dataPath } from '../tools/DataFiles'; +export interface IFetchFileResult { + statusCode: number, + message: string, + path?: string +} + +export async function getFileFromUrl( orgUrl: string, toPath: string, altPath: string, toFile: string) : Promise { + return new Promise(async (resolve, reject) => { + const path = dataPath(toPath); + const altpath = dataPath(altPath); + const filename = path + '/' + toFile; + const alternative = altpath + '/' + toFile; + if (fs.existsSync(filename)) + resolve({ + statusCode: 423, + message: 'File already exists', + path: filename, + }); + else if ( + altPath && + altPath.length > 0 && + fs.existsSync(alternative) + ) { + resolve({ + statusCode: 423, + message: 'File already exists', + path: alternative, + }); + } else { + try { + createDir(path); + const data = await fetch(orgUrl) + const buffer = Buffer.from(await data.arrayBuffer()); + fs.writeFileSync(filename, buffer); + } catch (err) { + resolve({ statusCode: 400, message: err.message }); + return; + } + resolve({ + statusCode: 201, + message: 'File fetched', + path: filename, + }); + } + }); +} \ No newline at end of file diff --git a/backend/src/services/genericService.ts b/backend/src/services/genericService.ts new file mode 100644 index 0000000..aac7401 --- /dev/null +++ b/backend/src/services/genericService.ts @@ -0,0 +1,89 @@ +import { ObjectLiteral, UpdateResult } from "typeorm"; +import getRepository, { getDbManager } from "./datasourceService"; +import { logger } from "./loggerService"; + + + +export async function getAllGen(T: any) : Promise { + + const repository = getRepository(T); + return await repository.find(); +} + +export async function getExtManyGen(T: any, extQuery: any) : Promise { + + // get a post repository to perform operations with post + const repository = getRepository(T); + + // load posts + return await repository.find(extQuery); +} + +export async function getExtQueryGen(T: any, extQuery: any) : Promise { + + // get a post repository to perform operations with post + const repository = getRepository(T); + + // load posts + return await repository.findOne(extQuery); +} + +export async function getExtQueryAllGen(T: any, extQuery: any) : Promise> { + + // get a post repository to perform operations with post + const repository = getRepository(T); + + // load posts + return await repository.find(extQuery); +} + + +export async function getQueryGen(T: any, query: any) : Promise { + + // get a post repository to perform operations with post + const repository = getRepository(T); + + // load posts + return await repository.findOne({ where: query}); +} + +export async function getByIdGen(T: any, id: number) : Promise { + + // get a post repository to perform operations with post + const repository = getRepository(T); + + // load posts + return await repository.findOne({ where: { id }}); +} + +export async function deleteIdGen(T: any, id: number) : Promise { + + const manager = getDbManager(); + const result = await manager.softDelete(T, id); + return (result && result.affected == 1) +} + +export async function deleteGen(T: any, query: ObjectLiteral) : Promise { + const manager = getDbManager(); + const result = await manager.softDelete(T,query); + return (result && result.affected >= 1) +} + +export function isUpdate(podcastObject: any): Boolean { + if (!podcastObject) return false; + var isupdate: Boolean = 'id' in podcastObject; + if (isupdate) isupdate = podcastObject.id as number > 0; + return isupdate; +}; + +export async function saveGen( T: any, object: T ): Promise { + var entity = getDbManager().create(T, object ) as T + const res = await getDbManager().insert(T,entity); + return (res.identifiers && res.identifiers[0]?res.identifiers[0].id:undefined) +}; + + export async function updateGen(T: any, object: T) : Promise { + const res = await getDbManager().update(T, (object as any).id, object); + return (res.affected && res.affected==1?(object as any).id:undefined) +}; + diff --git a/backend/src/services/loggerService.ts b/backend/src/services/loggerService.ts new file mode 100644 index 0000000..61f27ae --- /dev/null +++ b/backend/src/services/loggerService.ts @@ -0,0 +1,47 @@ +import {Request, Response} from "express"; +import * as fs from 'fs' +import { getRandomFileName } from "../tools/DataFiles"; + +const recorder = false + +var loglevel = 3 + +export const deleteOldTempFiles = (directory: string, ageInMinutes: number) => { + fs.readdirSync(directory).forEach(file => { + const isOlder = fs.statSync(directory+file).ctime.getTime() < (Date.now() - ageInMinutes * 60000) + if (isOlder) { + fs.unlinkSync(directory+file) + } + + }) +} + +export const logger = (level:number, message: string) => { + if (level<=loglevel) + console.log(new Date(), level+" "+message) +} + +export function logResponse(response: Response, code: number, body: any) { + if (code>=500) + logger(2, "response code: " +code+ " body " + JSON.stringify(body)) + else + logger(2, "response code: " +code+ " body " + JSON.stringify(body).substring(0, 50) + " ... ") + if (code>=200 && code<400 && recorder) { + fs.writeFileSync('./data/responses/'+response.req.method+'-'+response.req.url.split('/')[2]+'-'+getRandomFileName()+".json", JSON.stringify(body)) + deleteOldTempFiles('./data/responses/',5) + } +} + +export function requestLoggerMiddleware(request: Request) { + logger(2, request.method +" "+ request.url + " " + request.headers.authorization?.substring(0,15)+ " " + JSON.stringify(request.cookies).substring(0,40)) + if (recorder) { + const data = { + method: request.method, + url: request.url, + headers: request.headers, + body: (request.url.startsWith('/api/upload')?'uploadData':request.body) + } + fs.writeFileSync('./data/requests/'+request.method+'-'+request.url.split('/')[2]+'-'+getRandomFileName()+".json", JSON.stringify(data)) + deleteOldTempFiles('./data/requests/',5) + } +} \ No newline at end of file diff --git a/backend/src/services/podcastService.ts b/backend/src/services/podcastService.ts new file mode 100644 index 0000000..4bf9301 --- /dev/null +++ b/backend/src/services/podcastService.ts @@ -0,0 +1,54 @@ +import Enumerator from "../entities/Enumerator"; +import Podcast from "../entities/Podcast"; +import { createDir, dataPath, writeRss } from "../tools/DataFiles"; +import Enumerations from "../tools/Enumerations"; +import { generateRss } from "../tools/RssGenerator"; +import getRepository from "./datasourceService"; +import { getQueryGen, updateGen } from "./genericService"; + +export function getPodcast(from : any): Podcast { + var podcast = new Podcast(); + if (!from) return podcast; + if ('id' in from) podcast.id = from.id; + podcast.cover_file = from.cover_file; + podcast.title = from.title; + podcast.slug = from.slug; + podcast.subtitle = from.subtitle; + podcast.author = from.author; + podcast.summary = from.summary; + podcast.description = from.description; + podcast.language_id = from.language_id; + podcast.category_id = from.category_id; + podcast.type_id = from.type_id; + podcast.explicit = from.explicit; + podcast.link = from.link; + podcast.draft = from.draft; + podcast.copyright = from.copyright; + podcast.owner_name = from.owner_name; + podcast.owner_email = from.owner_email; + if (from.hasOwnProperty('state')) podcast.state = from.state; + if (from.hasOwnProperty('lastbuild')) podcast.lastbuild = from.lastbuild; + if (from.hasOwnProperty('external_id')) + podcast.external_id = from.external_id; + if (from.hasOwnProperty('apple_url')) + podcast.apple_url = from.apple_url; + if (from.hasOwnProperty('spotify_url')) + podcast.spotify_url = from.spotify_url; + if (from.hasOwnProperty('google_url')) + podcast.google_url = from.google_url; + if (from.hasOwnProperty('stitcher_url')) + podcast.stitcher_url = from.stitcher_url; + return podcast; + } + + export const setLastUpdate = async ( id: number ) : Promise => { + const podcast = await getQueryGen(Podcast, { id: id }) + podcast.lastbuild = Date.now().toLocaleString() + podcast.updatedAt = Date.now() + updateGen(Podcast, podcast as Podcast) + return podcast.lastbuild + } + + +// if (object.hasOwnProperty("updatedAt")) +// (object as any).updatedAt = new Date(); \ No newline at end of file diff --git a/backend/src/services/serieService.ts b/backend/src/services/serieService.ts new file mode 100644 index 0000000..5bdbf4e --- /dev/null +++ b/backend/src/services/serieService.ts @@ -0,0 +1,35 @@ +import Serie from "../entities/Serie" +import { getQueryGen, updateGen } from "./genericService" + +export const setLastAndFirst = async ( id: number ) : Promise => { + const serie = await getQueryGen(Serie, { id: id }) + var minmax = serie.episodes?.reduce( (minmax, episode) => { + const d = new Date(episode.pubdate) + if (d< minmax.min) + minmax.min = d + if (d > minmax.max) + minmax.max = d + return minmax + }, { min: new Date(), max: new Date(1900)}) + serie.lastEpisode = minmax?.max + serie.firstEpisode = minmax?.min + updateGen(Serie, serie as Serie) + return serie.slug + } + + +export function getSerie(from: any): Serie { + var serie = new Serie(); + serie.cover_file = from.cover_file; + serie.title = from.title; + serie.slug = from.slug; + serie.subtitle = from.subtitle; + serie.state = from.state; + serie.draft = from.draft; + serie.firstEpisode = from.firstEpisode; + serie.lastEpisode = from.lastEpisode; + if (from.hasOwnProperty("id")) serie.id = from.id; + if (from.hasOwnProperty("external_id")) serie.external_id = from.external_id; + if (from.hasOwnProperty("description")) serie.description = from.description; + return serie; + } \ No newline at end of file diff --git a/backend/src/services/sessionService.ts b/backend/src/services/sessionService.ts new file mode 100644 index 0000000..0c577d9 --- /dev/null +++ b/backend/src/services/sessionService.ts @@ -0,0 +1,75 @@ +import {Request, Response} from "express"; +import Session from '../entities/Session'; +import User from '../entities/User'; +import { generateAccessToken, sendAuthToken } from '../jwt'; +import {MoreThan} from 'typeorm'; +import { getUserById, sanitizeUserForFrontend } from './userService'; +import { deleteGen, getExtQueryGen, saveGen } from './genericService'; + +export async function createSession( refreshToken: string, userId: number ) : Promise { + const user = await getUserById(userId) + const session = new Session() + session.userId = userId + session.refreshToken = refreshToken + session.user = user + const id = await saveGen(Session, session); + return { ...session, id } as Session +} + +export async function readSession(query): Promise { + var tmpQuery = { + where: query, + relations: ["user"], + }; + return await getExtQueryGen(Session,tmpQuery) as Session +} + +export const removeSession = async (token: string) => { + var tmpQuery = { refreshToken: token } + return await deleteGen(Session,tmpQuery) +} +export const removeSessions = async (userId: number) => { + var tmpQuery = { userId } + return await deleteGen(Session, tmpQuery) +} +export const removeOldSessions = async (date: Date) => { + var tmpQuery = { updatedAt: MoreThan(date) } + return await deleteGen(Session, tmpQuery) +} + +export async function getSessionByToken(token: string): Promise { + return await readSession({refreshToken: token}) +} + + +export async function makeSession(user: User, response: Response): Promise { + const authToken = generateAccessToken(user) + const refreshToken = generateAccessToken(user) + const session = await createSession(authToken, user.id) + const userId = session.userId + + if (userId) { + sendAuthToken( response, authToken ) + return getUserBySessionToken(authToken) + } + + throw Error('Error Creating Session') +} + +export async function getUserBySessionToken(authToken: string): Promise { + const session = await getSessionByToken(authToken) + if (!session) + return null + return sanitizeUserForFrontend(session.user) +} + +export async function checkAuthentication(authToken: string, maxAgeInMin) : Promise { + const session = await readSession({authToken: authToken}) + if (!session) + return false; + if (maxAgeInMin<1) + return true; + const creation = new Date(session.createdAt); + const age = Date.now().valueOf() - creation.valueOf(); + return (age/60000) < maxAgeInMin; +} diff --git a/backend/src/services/userService.ts b/backend/src/services/userService.ts new file mode 100644 index 0000000..65b26b4 --- /dev/null +++ b/backend/src/services/userService.ts @@ -0,0 +1,65 @@ +import { INVITE_TIME, INVITE_TOKEN, PWRESET_TIME } from '../tools/Configuration'; +import * as jwt from '../jwt' +import { getExtQueryGen, getQueryGen, isUpdate, saveGen, updateGen } from './genericService'; +import User from '../entities/User'; + +export async function getUserByEmail(email: string): Promise { + return getQueryGen(User , { email: email }) as Promise; +} + +export async function getUserByUserName(username: string): Promise { + return getQueryGen(User , { username: username } ) as Promise; +} + +export async function getUserById( id: number ): Promise { + return getQueryGen(User , { id: id } )as Promise; +} + +export async function readUser(query): Promise { + var tmpQuery = { + where: query, + relations: ["sessions"], + }; + var result = await getExtQueryGen(User, tmpQuery) as User; + return result +} + +export async function createUser(user: User) : Promise { + const id = await saveGen(User, user); + return { ...user, id } as User +} + +export async function updateUser(user: User) : Promise { + const id = await updateGen(User, user); + return { ...user, id } as User +} + + +export async function createUserWithToken(data: User, type: string) : Promise { + var user = await getQueryGen(User, { username: data.username }) as User + var id:number + if (!user) { + user = { ...data } as User; + user.name = data.username + user.email = "" + } + user.token = jwt.generateUrlToken( data.username, type, (type==INVITE_TOKEN?INVITE_TIME:PWRESET_TIME) ) + if (isUpdate(user)) + id = await updateGen(User,user) + else + id = await saveGen(User,user) + return { ...user, id } as User +} + +export function sanitizeUserForFrontend(user: User | undefined): User { + + if (!user) { + return user + } + + delete user.password + delete user.sessions + + return user +} + diff --git a/backend/src/services/wpMigrationService.ts b/backend/src/services/wpMigrationService.ts new file mode 100644 index 0000000..a0a6174 --- /dev/null +++ b/backend/src/services/wpMigrationService.ts @@ -0,0 +1,31 @@ +import Episode from '../entities/Episode'; +import Serie from '../entities/Serie'; +import Podcast from '../entities/Podcast'; +import { joinEpisodePodcastAndSerie } from './episodeService'; +import { getByIdGen, getExtQueryGen, saveGen } from './genericService'; + +export async function migrateWpEpisode(episode: Episode, podcastId: number) : Promise { + + const getRelation = async (T:any, id: number, externalId: number) => { + if (id>0) + return await getByIdGen(T, podcastId ); + else + return await getExtQueryGen(T,{ + where: { external_id: externalId }, + relations: ['episodes'] + })[0] + } + + const podcast = await getRelation(Podcast,podcastId,episode.ext_podcast_id) + const serie = await getRelation(Serie,-1,episode.ext_series_id) + + joinEpisodePodcastAndSerie(episode,podcast,serie) + + const id = await saveGen(Episode,episode); + if (serie) + await saveGen(Podcast,serie); + if (podcast) + await saveGen(Podcast,podcast); + return { ...episode, id } as Episode +} + diff --git a/backend/src/subscriber/.gitkeep b/backend/src/subscriber/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/tests/Configuration.test.ts b/backend/src/tests/Configuration.test.ts new file mode 100644 index 0000000..ed39ffe --- /dev/null +++ b/backend/src/tests/Configuration.test.ts @@ -0,0 +1,28 @@ +import { ROUTES, getRouteSlug } from "../tools/Configuration"; + +describe("Configuration", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + process.env = { ...OLD_ENV }; // Make a copy + }); + afterAll(() => { + process.env = OLD_ENV; // Restore old environment + }); + test("getRouteSlug returns the configured value plus prefix", () => { + delete process.env.ROUTE_PREFIX + const actualResult = getRouteSlug("USERTOKEN_AP"); + expect(actualResult).toBe("/api/" + ROUTES.USERTOKEN_AP); + }); + test("returns the configured value plus prefix", () => { + process.env.ROUTE_PREFIX = "/opi/" + const actualResult = getRouteSlug("USERTOKEN_AP"); + expect(actualResult).toBe("/opi/" + ROUTES.USERTOKEN_AP); + }); + test("returns the configured value plus prefix and param", () => { + process.env.ROUTE_PREFIX = "/opi/" + const actualResult = getRouteSlug("CHECK_TOKEN_AP", true); + expect(actualResult).toBe("/opi/" + ROUTES.CHECK_TOKEN_AP + ROUTES.CHECK_TOKEN_PARAM); +}); +}); diff --git a/backend/src/tools/Configuration.ts b/backend/src/tools/Configuration.ts new file mode 100644 index 0000000..49e1b79 --- /dev/null +++ b/backend/src/tools/Configuration.ts @@ -0,0 +1,81 @@ +require('dotenv').config() + +export const DATABASE_PATH = process.env.DATABASE_PATH ?? 'data'; +export const DATA_PATH = process.env.DATA_PATH ?? 'public'; +export const DATABASE_FILE = process.env.DATABASE_FILE ?? "podcasts.sqlite" +export const EXT_MENU_AP = process.env.EXT_MENU_AP ?? "" +export const EXT_MENU_FILTER = process.env.EXT_MENU_FILTER ?? "" +export const EXT_MENU_BASEURL = process.env.EXT_MENU_BASEURL ?? "" +export const DATAFILES_PATH = '/files/' +export const SERVER_IMG_PATH = '/s/covers/'; +export const SERVER_POSTIMG_PATH = '/s/posts/'; +export const SERVER_MP3_PATH = '/s/audio/'; +export const FEED_SLUG = '/s/feed/'; +export const UPLOAD_TEMP_PATH = '/s/upload/'; + +export const ARCHIV_PATH ="archiv"; + +export const ROUTE_PREFIX_DEFAULT = "/api/" +export const ROUTES = { + AUTHUSER_AP: 'auth/user', + CHECK_TOKEN_AP: 'auth/checktoken', + CHECK_TOKEN_PARAM: '?token=', + COUNT_AP: 'count', + ENUMERATIONS_AP: 'enums', + EPISODE_AP: 'episode', + EPISODES_AP: 'episodes', + EPISODEWP_AP: 'episodewp', + EPISODEMOVE_AP: 'episodemove', + FILES_AP: 'files', + FILES_PARAM: '?path=', + FETCHLOCAL_AP: 'fetchfile', + FROMARCHIVE_AP: 'fromarchive', + GENERATE_RSS_AP: 'generaterss', + GENERATE_RSS_PARAM: '?slug=', + LOGIN_AP: 'auth/login', + LOGOUT_AP: 'auth/logout', + METADATA_AP: 'meta', + METADATA_PARAM: '?locale=', + PASSWORD_AP: 'auth/password', + PODCAST_AP: 'podcast', + PODCASTS_AP: 'podcasts', + REFRESH_AP: 'auth/refresh', + SERIES_AP: 'series', + SERIE_AP: 'serie', + UPLOAD_AP: 'upload', + USERTOKEN_AP: 'auth/usertoken', + USERTOKEN_PARAM: '?username=%%&type=', +} + +export function getRouteSlug(route: string, prepareParam = false) { + const prefix = process.env.ROUTE_PREFIX ?? ROUTE_PREFIX_DEFAULT + var paramName = route.replace("AP","PARAM") + if (prepareParam && ROUTES.hasOwnProperty(paramName)) + return prefix + ROUTES[route] + ROUTES[paramName] + return prefix + ROUTES[route] +} + +export function getTokenSecret( type: string ) : string { + switch(type) { + case 'REFRESH': + return process.env.JWT_REFRESH_TOKEN_SECRET ?? "CiHbNFoNGhFZjybkKRQidWRnJdKjcHRTT8jvNTbjaBKCKBwpyCL2LBHsXq77dP" + case 'ACCESS': + return process.env.JWT_ACCESS_TOKEN_SECRET ?? "N3GzpyEGdHjb9hmEfZmfCgif3jepjk5ECHsfxzEkv53mxexMQoFSyta3oL76Fsr2" + default: + return process.env.JWT_URL_TOKEN_SECRET ?? "gdQD3NPk38HTvw5T3B2CzrPyRCdJkp8NcM4D3wucFoqGp8GGB5Dm84nauFaTTyjG" + } +} + +export const INVITE_TOKEN = 'invitation'; +export const INVITE_TIME = '2d'; +export const PWRESET_TIME = '1d'; + + +// export const SETPASS_LINK= 'admin/setpassword?token='; +// export const WP_API_SLUG = 'wp-json/wp/v2/'; +// export const WP_PER_PAGE = '?per_page=100'; +// export const WP_MIN_PER_PAGE = '?per_page=1'; +// export const WP_HEADER_TOTAL = 'x-wp-total'; +// export const ROUTE_NEWPODCAST = 'newpodcast'; +// export const NUM_PAGINATION_LINKS = 7 +// export const NUM_ITEMS_PER_PAGE = 6 \ No newline at end of file diff --git a/backend/src/tools/Controller.ts b/backend/src/tools/Controller.ts new file mode 100644 index 0000000..0b55029 --- /dev/null +++ b/backend/src/tools/Controller.ts @@ -0,0 +1,17 @@ +import {Response} from "express"; +import { logResponse, logger } from "../services/loggerService"; + +export function respond( response: Response, code: number, body: any ) { + const cod = code || 500 + var bod = (body || {}) + response.status(cod).send(bod); + logResponse(response, cod, bod) +} + +export function sendResponse( response: Response, body: any ) { + respond(response,200,body) +} + +export function sendResponseCode( response: Response, code: number ) { + respond(response,code, {}) +} \ No newline at end of file diff --git a/backend/src/tools/Converters.ts b/backend/src/tools/Converters.ts new file mode 100644 index 0000000..9278874 --- /dev/null +++ b/backend/src/tools/Converters.ts @@ -0,0 +1,74 @@ +export function strToDurationInSec(durationStr: string) { + if (!durationStr) return -1; + const parts = durationStr.replace(/[\.-\/\\]/g, ':').split(":"); + var pos = 0; + var sum = 0; + while (parts.length > 0 && parts[0].length > 0) { + sum *= 60; + sum += Number(parts[pos]); + pos++; + if (pos == parts.length) return sum; + } + return sum; +} +export function durationInSecToStr(duration: number | string) { + if (typeof(duration)=="string") + return duration; + if (duration <= 0) return "0:00"; + var result = [ + Math.floor(duration / 60 / 60), + Math.floor(duration / 60) % 60, + Math.floor(duration % 60), + ] + .join(":") + .replace(/\b(\d)\b/g, "0$1"); + if (result.startsWith("00:")) result = result.substring(1); + if (result.length>5 && result.startsWith("0:")) result = result.substring(2); + if (result.startsWith("0")) result = result.substring(1); + if (result.startsWith(":")) result = "0" + result; + return result; +} + +const datePattern = + /^(0?[1-9]|[1-2][0-9]|3[01])([-\/.])(0?[1-9]|1[012])\2(19|20)?(\d\d)$/gm; +const datePatter2 = + /^(19|20)?(\d\d)([-\/.])(0?[1-9]|1[012])\3(3[01]|[12][0-9]|0?[1-9])/g; +export function strToDate(str: string): Date { + var result = str; + if (str.match(datePattern)) result = str.replace(datePattern, "$4$5-$3-$1"); + else { + const match = str.match(datePatter2); + if (match) result = match[0]; + } + return new Date(result); +} + +export function dateToIsoString(date: Date) { + return ( + date.getFullYear() + + "-" + + ("0" + (date.getMonth() + 1)).slice(-2) + + "-" + + ("0" + (date.getDate())).slice(-2) + ); +} + +export function saveSlugFormText(text: string, lowercase = true): string { + var slug = (lowercase?text.toLowerCase():text); + return slug + .replace(/([:;\.,]+\s?)+/g, "_") + .replace(/([!'/()*"~#@?%&\\:;,<>\*\+\|]|\DE\/EN)+/g, "") + .replace(" deen", "") + .replace("ä", "ae") + .replace("ö", "oe") + .replace("ü", "ue") + .replace("ß", "ss") + .replace(/\s+/g, "_"); +} + +export function getSaveFilename(name: string): string { + const ext_index = name.lastIndexOf('.') + const base = name.slice(0,ext_index) + const ext = name.slice(ext_index) + return saveSlugFormText(base,false)+ext +} diff --git a/backend/src/tools/DataFiles.ts b/backend/src/tools/DataFiles.ts new file mode 100644 index 0000000..d73cde2 --- /dev/null +++ b/backend/src/tools/DataFiles.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path' +import { DATA_PATH } from './Configuration'; + +export const getRandomFileName = () => { + var timestamp = new Date().toISOString().replace(/[-:.]/g,""); + var random = ("" + Math.random()).substring(2, 8); + var random_number = timestamp+random; + return random_number; +} + +export const dataPath = (path: string) : string => { + return DATA_PATH + path; +}; + +export const createDir = (dir: string) : void => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}; + +export const moveFile = function (fullpath: string, newpath : string, filename: string) { + var dir = dataPath(newpath); + createDir(dir); + const target_path = dir + '/' + filename; + fs.renameSync(fullpath, target_path); + return true; +}; + +export const copyFile = function (fullpath: string, newpath : string, filename: string) { + var dir = dataPath(newpath); + createDir(dir); + const target_path = dir + '/' + filename; + fs.copyFileSync(fullpath, target_path); + return true; +}; + +export const writeRss = function(feedSlug: string, podcastSlug: string, xml: string) { + var dir = dataPath(feedSlug); + createDir(dir) + const target_file = dir + podcastSlug+".xml" + fs.writeFileSync(target_file, xml) +} + +export const getFilename = function(target: string) { + return target.split("/").slice(-1)[0]; +} + + +export function findFile(name: string, path: string) : string { + const dirs = [path] + while (dirs.length>0) { + const currentDir = dirs.pop() + const items = fs.readdirSync(currentDir); + for (const item of items) { + if (!(fs.lstatSync(`${currentDir}/${item}`)).isDirectory()) { + if (item===name) + return `${currentDir}/${item}` + } else + dirs.push(`${currentDir}/${item}`) + } + } + return "" +} \ No newline at end of file diff --git a/backend/src/tools/Enumerations.ts b/backend/src/tools/Enumerations.ts new file mode 100644 index 0000000..9c4235b --- /dev/null +++ b/backend/src/tools/Enumerations.ts @@ -0,0 +1,101 @@ +import Enumerator from "../entities/Enumerator"; + +export enum EnumKey { + Language = 0, + PodcastGenres = 1, + PodcastTypes = 2, + State = 3, + Authors = 4, + Tags = 5, +} + +export function getEmptyEnum(): Enumerator { + var enumer = { + displaytext: "", + shorttext: "", + parentCategory: "", + enumkey_id: -1, + enumvalue_id: -1, + }; + return enumer as Enumerator; +} +export default class Enumerations { + public static isInitialized(list: Array): boolean { + return list.length > 0; + } + + public static getEnumeration( + enumKey: EnumKey, + list: Array + ): Array { + return list.filter((item) => item.enumkey_id === enumKey); + } + + public static byIdTextList( + enums: Array, + ids: Array + ): string { + let result = enums + .filter((item) => ids.includes(item.enumvalue_id)) + .reduce((prevItem, item) => { + if (prevItem && prevItem.length > 0) + return prevItem + ", " + item.displaytext; + else return item.displaytext; + }, ""); + return result; + } + + public static byIdOne(enums: Array, id: number): Enumerator { + let result = enums.find((item) => item.enumvalue_id == id); + + if (result) return result; + else return getEmptyEnum(); + } + + public static byShort(enums: Array, short: string): Enumerator { + let result = enums.find( + (item) => item.shorttext.toLowerCase() === short.toLowerCase() + ); + if (result) return result; + else return getEmptyEnum(); + } + + public static languages(list: Array): Array { + return Enumerations.getEnumeration(EnumKey.Language, list); + } + public static podcastGenres(list: Array): Array { + return Enumerations.getEnumeration(EnumKey.PodcastGenres, list); + } + public static podcastTypes(list: Array): Array { + return Enumerations.getEnumeration(EnumKey.PodcastTypes, list); + } + public static authors(list: Array): Array { + return Enumerations.getEnumeration(EnumKey.Authors, list); + } + public static tags(list: Array): Array { + return Enumerations.getEnumeration(EnumKey.Tags, list); + } +} + +export function getEnumFunctions(enums: Array) { + return { + getLanguage: (lang_id: number): Enumerator => { + return Enumerations.byIdOne(Enumerations.languages(enums), lang_id); + }, + + getGenre: (genre_id: number): Enumerator => { + return Enumerations.byIdOne(Enumerations.podcastGenres(enums), genre_id); + }, + + getAuthor: (autor_id: number): Enumerator => { + return Enumerations.byIdOne(Enumerations.authors(enums), autor_id); + }, + + getTag: (tag_id: number): Enumerator => { + return Enumerations.byIdOne(Enumerations.tags(enums), tag_id); + }, + getType: (type_id: number): Enumerator => { + return Enumerations.byIdOne(Enumerations.podcastTypes(enums), type_id); + }, + }; +} diff --git a/server/tagId3.ts b/backend/src/tools/Id3Tagger.ts similarity index 62% rename from server/tagId3.ts rename to backend/src/tools/Id3Tagger.ts index 1496d22..9fd5dca 100644 --- a/server/tagId3.ts +++ b/backend/src/tools/Id3Tagger.ts @@ -1,12 +1,8 @@ -import id3 from 'node-id3' -import fs from 'fs' -import { dateToIsoString, durationInSecToStr } from '~~/base/Converters'; -import IEpisode from '~~/base/types/IEpisode'; -import IPodcast from '~~/base/types/IPodcast'; -import ISerie from '~~/base/types/ISerie'; -import { nuxtPath } from './services/filesService'; - - +import * as id3 from 'node-id3' +import * as fs from 'fs' +import { dateToIsoString, durationInSecToStr } from './Converters'; +import { dataPath } from './DataFiles'; +import Episode from '../entities/Episode'; function embedCoverImage (tags: id3.Tags, file: string) : id3.Tags { const buffer = fs.readFileSync(file) @@ -22,7 +18,7 @@ function embedCoverImage (tags: id3.Tags, file: string) : id3.Tags { return tags } -function getTagsFromPodcast( episode: IEpisode ) : id3.Tags { +function getTagsFromPodcast( episode: Episode ) : id3.Tags { var tags: id3.Tags = { title: episode.title, artist: episode.creator, @@ -41,11 +37,13 @@ function getTagsFromPodcast( episode: IEpisode ) : id3.Tags { return tags } -export default function writeTags( episode: IEpisode ) : true|Error { +export default function writeTags( episode: Episode ) : true|Error { var tags = getTagsFromPodcast(episode) - tags = embedCoverImage(tags, nuxtPath(episode.image)) + const imgpath = dataPath(episode.image) + const mp3path = dataPath(episode.link) + tags = embedCoverImage(tags, imgpath) try { - const result = id3.write(tags, nuxtPath(episode.link)) + const result = id3.write(tags, mp3path) return result } catch(err) { console.log(err) diff --git a/backend/src/tools/RssGenerator.ts b/backend/src/tools/RssGenerator.ts new file mode 100644 index 0000000..23b97ea --- /dev/null +++ b/backend/src/tools/RssGenerator.ts @@ -0,0 +1,92 @@ +import { Podcast as Podclass } from 'podcast'; +import Podcast from '../entities/Podcast'; +import { serverUrl } from './Urls'; + +var testing = false; +export function setTesting() { testing = true } + + +export function generateRss( podcast: Podcast, baseUrl: string, feedSlug: string, enumFuncs: any) : string { + const feedOptions = { + title: podcast.title, + author: podcast.author, + siteUrl: podcast.link, + feedUrl: baseUrl+feedSlug+podcast.slug+".xml", + docs: baseUrl+"/"+podcast.slug, + description: podcast.description, + language: enumFuncs.getLanguage(podcast.language_id).shorttext, + copyright: podcast.copyright, + imageUrl: baseUrl+podcast.cover_file, + itunesSubtitle: podcast.subtitle, + itunesAuthor: podcast.author, + itunesSummary: podcast.summary, + itunesOwner: { name: podcast.owner_name, email: podcast.owner_email }, + itunesExplicit: podcast.explicit, + itunesCategory: [{ + text: enumFuncs.getGenre(podcast.category_id).parentCategory, + subcats: [{ + text: enumFuncs.getGenre(podcast.category_id).displaytext + }] + }], + itunesImage: baseUrl+podcast.cover_file, + itunesType: enumFuncs.getType(podcast.type_id).shorttext, + generator: "https://github.com/ccfreiburg/podkashde", + customElements: [{ + "podcast:locked" : "yes", + "podcast:guid": "bd4f4ece-3d3d-5b3d-9f24-4a081ee0d63d" + }], + customNamespaces: { + "sy": "http://purl.org/rss/1.0/modules/syndication/", + "slash": "http://purl.org/rss/1.0/modules/slash/", + "googleplay": "http://www.google.com/schemas/play-podcasts/1.0" + } + } + const feed = new Podclass(feedOptions) + + if (podcast.episodes) { + podcast.episodes.forEach( (episode) => { + feed.addItem({ + title: episode.title, + description: episode.description, + url: baseUrl+"/"+episode.slug, + guid: baseUrl+"/"+episode.slug, + author: episode.creator, + date: episode.pubdate, + enclosure: { + url: serverUrl(episode.link, baseUrl), + size: episode.rawsize, + type: "audio/mpeg" + }, + content: episode.description, + itunesAuthor: episode.creator, + //itunesKeywords: episode.keyword, + itunesExplicit: episode.explicit, + itunesSubtitle: episode.subtitle, + itunesSummary: episode.summary, + itunesDuration: episode.duration, + itunesImage: serverUrl(episode.image, baseUrl), + customElements: [ + {"itunes:block" : episode.block }, + {"googleplay:image" : [ + { + _attr: { + "href": serverUrl(episode.image, baseUrl) + } + }]}, + {"googleplay:description" : episode.description}, + {"googleplay:block" : (episode.block?"yes":"no")}, + {"googleplay:explicit" : (episode.explicit?"yes":"no")}, + {"image": [{ + "url": serverUrl(episode.image, baseUrl)}, + {"title": episode.title + }]} + ] + // itunesSeason: + // itunesEpisode: + // itunesNewFeedUrl: + }) + }) + } + + return feed.buildXml(); +} \ No newline at end of file diff --git a/backend/src/tools/Urls.ts b/backend/src/tools/Urls.ts new file mode 100644 index 0000000..f870e69 --- /dev/null +++ b/backend/src/tools/Urls.ts @@ -0,0 +1,14 @@ +export function isQualifiedUrl(target: string): Boolean { + if (target && target.length > 0) { + return target.startsWith("http://") || target.startsWith("https://"); + } + return false; + } + + export function serverUrl( episodeLink: string, baseUrl: string ) : string { + if (isQualifiedUrl(episodeLink)) + return episodeLink; + + return baseUrl + episodeLink; + } + \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..539d49c --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "version": "2.4.2", + "compilerOptions": { + "lib": ["es5", "es6"], + "outDir": "./dist", + "target": "es6", + "module": "commonjs", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/base/Constants.ts b/base/Constants.ts index 43a7f32..1921008 100644 --- a/base/Constants.ts +++ b/base/Constants.ts @@ -1,6 +1,5 @@ export const DATA_PATH ="public"; export const ARCHIV_PATH ="archiv"; -export const PUBLIC_RESOURCES = 'public'; export const FEED_SLUG = '/s/feed/'; export const SERVER_IMG_PATH = '/s/covers/'; export const SERVER_MP3_PATH = '/s/audio/'; @@ -8,37 +7,38 @@ export const SERVER_POSTIMG_PATH = '/s/posts/'; export const SERIES_IMG_PATH = 'series'; export const REQUIRED_IMG_WIDTH = 1400; export const REQUIRED_IMG_HEIGHT = 1400; -export const UPLOAD_AP = '/api/upload'; +export const UPLOAD_AP = 'upload'; export const UPLOAD_TEMP_PATH = 'public/s/upload/'; -export const FETCHLOCAL_AP = '/api/fetchfile'; -export const FROMARCHIVE_AP = '/api/fromarchive'; -export const SERIES_AP = '/api/series'; -export const SERIE_AP = '/api/serie'; -export const PODCAST_AP = '/api/podcast'; -export const PODCASTS_AP = '/api/podcasts'; -export const EPISODES_AP = '/api/episodes'; -export const EPISODE_AP = '/api/episode'; -export const EPISODEWP_AP = '/api/episodewp'; -export const EPISODEMOVE_AP = '/api/episodemove'; -export const COUNT_AP = '/api/count'; -export const ENUMERATIONS_AP = '/api/enums'; -export const GENERATE_RSS_AP = '/api/generaterss'; +export const FETCHLOCAL_AP = 'fetchfile'; +export const FROMARCHIVE_AP = 'fromarchive'; +export const SERIES_AP = 'series'; +export const SERIE_AP = 'serie'; +export const PODCAST_AP = 'podcast'; +export const PODCASTS_AP = 'podcasts'; +export const EPISODES_AP = 'episodes'; +export const EPISODE_AP = 'episode'; +export const EPISODEWP_AP = 'episodewp'; +export const EPISODEMOVE_AP = 'episodemove'; +export const COUNT_AP = 'count'; +export const ENUMERATIONS_AP = 'enums'; +export const GENERATE_RSS_AP = 'generaterss'; export const WP_API_SLUG = 'wp-json/wp/v2/'; export const WP_PER_PAGE = '?per_page=100'; export const WP_MIN_PER_PAGE = '?per_page=1'; export const WP_HEADER_TOTAL = 'x-wp-total'; export const ROUTE_NEWPODCAST = 'newpodcast'; -export const LOGIN_AP = '/api/auth/login'; -export const LOGOUT_AP = '/api/auth/logout'; -export const REFRESH_AP = '/api/auth/refresh'; -export const AUTHUSER_AP = '/api/auth/user'; -export const USERTOKEN_AP = '/api/auth/usertoken?username=%%&type='; -export const FILES_AP = '/api/files?path='; +export const LOGIN_AP = 'auth/login'; +export const LOGOUT_AP = 'auth/logout'; +export const REFRESH_AP = 'auth/refresh'; +export const AUTHUSER_AP = 'auth/user'; +export const USERTOKEN_AP = 'auth/usertoken?username=%%&type='; +export const FILES_AP = 'files?path='; export const INVITE_TOKEN = 'invitation'; export const INVITE_TIME = '2d'; export const PWRESET_TIME = '1d'; export const SETPASS_LINK = '/admin/setpassword?token='; -export const CHECK_TOKEN_AP = '/api/auth/checktoken?token='; -export const PASSWORD_AP = '/api/auth/password' +export const CHECK_TOKEN_AP = 'auth/checktoken?token='; +export const PASSWORD_AP = 'auth/password' export const NUM_PAGINATION_LINKS = 7 -export const NUM_ITEMS_PER_PAGE = 6 \ No newline at end of file +export const NUM_ITEMS_PER_PAGE = 6 +export const TOKEN_REFRESH_TIME = 60000 \ No newline at end of file diff --git a/base/ContentFile.ts b/base/ContentFile.ts index 23ef9e3..93d2b35 100644 --- a/base/ContentFile.ts +++ b/base/ContentFile.ts @@ -18,4 +18,11 @@ export class ContentFile { public static getFilename(target: string): string { return target.split("/").slice(-1)[0]; } + + public static getMediaUrl(path: string): string { + const config = useRuntimeConfig() + const ret = (path && path.length>3?config.public.mediaBase + path:""); + return ret + } + } diff --git a/base/Converters.ts b/base/Converters.ts index d863c70..5b21772 100644 --- a/base/Converters.ts +++ b/base/Converters.ts @@ -43,7 +43,7 @@ export function strToDate(str: string): Date { return new Date(result); } -export function dateToIsoString(date: Date) { +export function dateToIsoString(date: Date) : string { return ( date.getFullYear() + "-" + @@ -53,16 +53,33 @@ export function dateToIsoString(date: Date) { ); } +export function dateToString(date: Date, locale: string) : string { + const month = date.getMonth()+1 + if (locale=="de") + return (date.getDate() + "." + month +"." +date.getFullYear()) + else if (locale=="en") + return ( month +"/" +date.getDate() + "/" +date.getFullYear()) + else + return dateToIsoString(date) +} + +export function urlFriendlyString(text: string) :string { + return saveSlugFormText(text) +} + export function saveSlugFormText(text: string, lowercase = true): string { var slug = (lowercase?text.toLowerCase():text); return slug .replace(/([:;\.,]+\s?)+/g, "_") .replace(/([!'/()*"~#@?%&\\:;,<>\*\+\|]|\DE\/EN)+/g, "") .replace(" deen", "") - .replace("ä", "ae") - .replace("ö", "oe") - .replace("ü", "ue") - .replace("ß", "ss") + .replaceAll("ä", "ae") + .replaceAll("ö", "oe") + .replaceAll("ü", "ue") + .replaceAll("Ä", "Ae") + .replaceAll("Ö", "Oe") + .replaceAll("Ü", "Ue") + .replaceAll("ß", "ss") .replace(/\s+/g, "_"); } diff --git a/base/EpisodeDetailValidation.ts b/base/EpisodeDetailValidation.ts index 6aaa5ef..1fbc7b0 100644 --- a/base/EpisodeDetailValidation.ts +++ b/base/EpisodeDetailValidation.ts @@ -1,6 +1,6 @@ import { REQUIRED_IMG_WIDTH, REQUIRED_IMG_HEIGHT } from '../base/Constants'; -import IEpisode from './types/IEpisode'; -import IValidationError from './types/IValidationError'; +import type IEpisode from './types/IEpisode'; +import type IValidationError from './types/IValidationError'; const i18naccessor = "episode.validation." diff --git a/base/Menu.ts b/base/Menu.ts deleted file mode 100644 index 5381a01..0000000 --- a/base/Menu.ts +++ /dev/null @@ -1,119 +0,0 @@ -import IMenuSection from '~~/base/types/IMenuSection'; - -export function initDefaultMenu(): Object { - var id = 0; - const menuResult = { - defaultBase: '/', - keys: [ - { - order: id++, - name: 'menu.admin', - description: 'menu.adminsub', - entries: [ - { - order: id++, - slug: 'admin/login', - name: 'menu.login', - onlyNotLoggedIn: '', - }, - { - order: id++, - slug: '#logout', - name: 'menu.logout', - onlyLoggedIn: '', - }, - { - order: id++, - slug: 'admin/setpassword', - name: 'menu.changepassword', - onlyLoggedIn: '', - }, - { - order: id++, - slug: 'admin/invitation', - name: 'menu.invitataion', - onlyAdmin: '', - }, - { - order: id++, - slug: 'admin/import', - name: 'menu.import', - onlyAdmin: '', - }, - ], - }, - { - order: id++, - name: 'menu.podcasts', - description: 'menu.podcastsub', - entries: [ - { - order: id++, - slug: 'podcasts', - name: 'menu.list', - }, - { - order: id++, - slug: 'recent', - name: 'menu.recent', - }, - { - order: id++, - slug: 'serie', - name: 'menu.series', - }, - { - order: id++, - slug: 'admin/new-podcast', - name: 'menu.new', - onlyLoggedIn: '', - }, - { - order: id++, - slug: 'admin/new-serie', - name: 'menu.newseries', - onlyLoggedIn: '', - }, - ], - }, - ] - } - return menuResult; -} - -export function getDefaultMenu( - menudata: Object, - username: string -): Object { - const loggin = username.length > 0; - const admin = username.startsWith('admin'); - const menuResult = { - defaultBase: menudata.defaultBase, - keys: menudata.keys.map((section) => { - return { - order: section.order, - name: section.name, - description: section.description, - entries: section.entries.filter( - (entry) => - (entry.hasOwnProperty('onlyLoggedIn') && loggin) || - (entry.hasOwnProperty('onlyNotLoggedIn') && !loggin) || - (entry.hasOwnProperty('onlyAdmin') && admin) || - !( - entry.hasOwnProperty('onlyLoggedIn') || - entry.hasOwnProperty('onlyNotLoggedIn') || - entry.hasOwnProperty('onlyAdmin') - ) - ), - } - }) - } - return menuResult; -} - -export async function getMenu( - url: string, -): Promise { - const menudata = await $fetch(url); - return menudata; -} diff --git a/base/PodcastDetailValidation.ts b/base/PodcastDetailValidation.ts index 0c80881..40ef1b9 100644 --- a/base/PodcastDetailValidation.ts +++ b/base/PodcastDetailValidation.ts @@ -22,7 +22,6 @@ function email(fields: Partial, errors: Array) { }); } function link(fields: Partial, key: string, errors: Array, emptyallowed: boolean = false) { - console.log(key + " " + fields[key]) if (!fields[key] || fields[key].length==0 && emptyallowed) return var re = @@ -37,7 +36,6 @@ export default function validation( imgHeight: number ): Array { var errors = [] as Array; - console.log("hallo") if ( !(imgWidth == REQUIRED_IMG_WIDTH && imgHeight == REQUIRED_IMG_HEIGHT) || !fields['cover_file'] || fields.cover_file.length < 1 diff --git a/base/RssGenerator.ts b/base/RssGenerator.ts index 7a06489..e052c67 100644 --- a/base/RssGenerator.ts +++ b/base/RssGenerator.ts @@ -1,5 +1,4 @@ import { Podcast } from 'podcast'; -import { useSettings } from '~~/composables/settingsdata'; import { FEED_SLUG, SERVER_IMG_PATH, SERVER_MP3_PATH } from './Constants'; import { ContentFile } from './ContentFile'; import IPodcast from './types/IPodcast'; diff --git a/base/SeriesDetailValidation.ts b/base/SeriesDetailValidation.ts index 2a28dca..71c3ec6 100644 --- a/base/SeriesDetailValidation.ts +++ b/base/SeriesDetailValidation.ts @@ -16,8 +16,8 @@ export default function validation( ): Array { var errors = [] as Array; if ( - !(imgWidth == REQUIRED_IMG_WIDTH && imgHeight == REQUIRED_IMG_HEIGHT) || - !fields['cover_file'] || fields.cover_file.length < 1 + (imgWidth != REQUIRED_IMG_WIDTH || imgHeight != REQUIRED_IMG_HEIGHT) && + (!fields['cover_file'] || fields.cover_file.length < 1) ) errors.push({ field: 'img', @@ -26,4 +26,4 @@ export default function validation( nonEmpty(fields, 'title', errors); nonEmpty(fields, 'slug', errors); return errors; -} +} \ No newline at end of file diff --git a/base/WpImport.ts b/base/WpImport.ts index 48ce4f9..addbec6 100644 --- a/base/WpImport.ts +++ b/base/WpImport.ts @@ -119,7 +119,9 @@ export function episodeFromWpMetadata( podcastImage, enumerations ): IEpisode { - var pubdate = strToDate(wpEpisode.meta.date_recorded); + var pubdate = strToDate((wpEpisode.meta.date_recorded.length>0?wpEpisode.meta.date_recorded:wpEpisode.date)) + if (!pubdate) + pubdate = new Date(); var image = wpEpisode.meta.cover_image; if (image.length < 1) image = podcastImage; var postimage = image; @@ -129,7 +131,7 @@ export function episodeFromWpMetadata( ) postimage = wpEpisode.episode_featured_image; var keyword = wpEpisode.tags.map((id) => enumerations.getTag(id).displaytext).join(', ') - var creator = enumerations.getAuthor(wpEpisode.speaker[0]).displaytext; + var creator = (wpEpisode.speaker?enumerations.getAuthor(wpEpisode.speaker[0]).displaytext:''); if (creator.length<1) creator = autorFromDescription(wpEpisode.excerpt.rendered) var cross_ref = verseFromDescription(wpEpisode.content.rendered) diff --git a/base/types/IFetchFileResult.ts b/base/types/IFetchFileResult.ts index 3506574..1b5cf75 100644 --- a/base/types/IFetchFileResult.ts +++ b/base/types/IFetchFileResult.ts @@ -1,5 +1,5 @@ export interface IFetchFileResult { - status: number, + statusCode: number, message: string, path?: string } \ No newline at end of file diff --git a/base/types/IPageMenuItem.ts b/base/types/IPageMenuItem.ts new file mode 100644 index 0000000..2f9eb11 --- /dev/null +++ b/base/types/IPageMenuItem.ts @@ -0,0 +1,5 @@ +export default interface IPageMenuItem { + name: string; + slug: string; + layout: string; + } \ No newline at end of file diff --git a/components/AudioFileSelector.vue b/components/AudioFileSelector.vue index aefec8a..0cecb37 100644 --- a/components/AudioFileSelector.vue +++ b/components/AudioFileSelector.vue @@ -13,11 +13,12 @@ + diff --git a/components/Menu/Link.vue b/components/Menu/Link.vue new file mode 100644 index 0000000..8fda68e --- /dev/null +++ b/components/Menu/Link.vue @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/Menu/index.vue b/components/Menu/index.vue new file mode 100644 index 0000000..fa3f458 --- /dev/null +++ b/components/Menu/index.vue @@ -0,0 +1,88 @@ + + + + + + + + + + {{ section.Name }} + + + + + + + + + + + + + {{ section.Description }} + + + + + + + {{ entry.Name }} + + + + + + + + + + + + + + + + + + + + diff --git a/components/MessgeToast.vue b/components/MessageToast.vue similarity index 74% rename from components/MessgeToast.vue rename to components/MessageToast.vue index 11b94d6..30b5035 100644 --- a/components/MessgeToast.vue +++ b/components/MessageToast.vue @@ -8,17 +8,18 @@ > - {{ toasterMessage }} + + {{ toasterMessage }} @@ -38,14 +39,13 @@ + diff --git a/components/FormDetail.vue b/components/Page/FormDetail.vue similarity index 82% rename from components/FormDetail.vue rename to components/Page/FormDetail.vue index f7cd8ee..cf22c24 100644 --- a/components/FormDetail.vue +++ b/components/Page/FormDetail.vue @@ -1,6 +1,5 @@ -import { booleanLiteral } from "@babel/types"; - + {{ @@ -10,7 +9,7 @@ import { booleanLiteral } from "@babel/types"; }} - + @@ -21,11 +20,10 @@ import { booleanLiteral } from "@babel/types"; {{ $t(type + ".save") }} - + diff --git a/components/Page/NavBar.vue b/components/Page/NavBar.vue new file mode 100644 index 0000000..dafea4a --- /dev/null +++ b/components/Page/NavBar.vue @@ -0,0 +1,165 @@ + + + + + + + + + + + {{ $t(locale) }} + + + + + + + + + {{ $t(navLocale) }} + + {{ $t(navLocale) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/Page/SubMenu.vue b/components/Page/SubMenu.vue new file mode 100644 index 0000000..7080abf --- /dev/null +++ b/components/Page/SubMenu.vue @@ -0,0 +1,43 @@ + + + + + + + + + + + {{ $t(item.name) }} + + + + + + {{ $t(item.name) }} + + + + + + + + + diff --git a/components/PodcastDetail.vue b/components/PodcastDetail.vue index 8522fdb..0b7ae7e 100644 --- a/components/PodcastDetail.vue +++ b/components/PodcastDetail.vue @@ -1,11 +1,9 @@ - - + {{(isEdit ? $t("podcast.edit") : $t("podcast.new"))}} - - + @@ -18,11 +16,11 @@ v-model:value="fields.summary" /> - - - fields.explicit = val" @@ -45,7 +43,7 @@ v-model:value="fields.stitcher_url" /> fields.draft = val" :labelChecked="$t('podcast.label.draft_true')" :labelUnChecked="$t('podcast.label.draft_false')" /> - + {{ $t("podcast.label.errors") }} @@ -63,19 +61,18 @@ - + diff --git a/components/SelectPodcastModal.vue b/components/SelectPodcastModal.vue index 35f525f..f0fc451 100644 --- a/components/SelectPodcastModal.vue +++ b/components/SelectPodcastModal.vue @@ -1,27 +1,27 @@ - + class="fixed top-0 left-0 z-50 w-full h-full overflow-x-hidden overflow-y-auto outline-none fade bg-skin-muted dark:bg-skin-muted-dark bg-opacity-80 dark:bg-opacity-80"> + + class="relative flex flex-col w-full text-current border-2 rounded-md shadow-lg outline-none pointer-events-auto bg-skin-muted dark:bg-skin-muted-dark border-skin-light dark:border-skin-dark bg-clip-padding"> - + class="flex items-center justify-between flex-shrink-0 p-4 border-2 modal-header border-skin-light dark:border-skin-dark rounded-t-md"> + {{ $t('podcast.change') }} - + {{ error }} -
+ {{ section.Description }} +
{{ $t("podcast.label.errors") }}