feature/debug-info (#286)

* rename 'ENV' to 'PRODUCTION' and default to true

* set env PRODUCTION

* refactor file download process

* add last_recipe.json and log downloads

* changelog + version bump

* set env on workflows

* bump version

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-04-10 21:42:04 -08:00 committed by GitHub
parent b3b1778890
commit 2a158ab290
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 191 additions and 53 deletions

View File

@ -11,6 +11,8 @@ on:
jobs: jobs:
tests: tests:
env:
PRODUCTION: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
#---------------------------------------------- #----------------------------------------------

View File

@ -20,7 +20,7 @@ RUN apk add --no-cache libxml2-dev \
zlib-dev zlib-dev
ENV ENV True ENV PRODUCTION true
EXPOSE 80 EXPOSE 80
WORKDIR /app/ WORKDIR /app/
@ -48,6 +48,7 @@ COPY ./dev/data/templates /app/data/templates
COPY --from=build-stage /app/dist /app/dist COPY --from=build-stage /app/dist /app/dist
VOLUME [ "/app/data/" ] VOLUME [ "/app/data/" ]
RUN chmod +x /app/mealie/run.sh RUN chmod +x /app/mealie/run.sh
CMD /app/mealie/run.sh CMD /app/mealie/run.sh

View File

@ -2,6 +2,8 @@ FROM python:3
WORKDIR /app/ WORKDIR /app/
ENV PRODUCTION false
RUN apt-get update -y && \ RUN apt-get update -y && \
apt-get install -y python-pip python-dev apt-get install -y python-pip python-dev

View File

@ -0,0 +1,23 @@
# v0.4.2
**App Version: v0.4.2**
**Database Version: v0.4.0**
!!! error "Breaking Changes"
1. With a recent refactor some users been experiencing issues with an environmental variable not being set correct. If you are experiencing issues, please provide your comments [Here](https://github.com/hay-kot/mealie/issues/281).
2. If you are a developer, you may experience issues with development as a new environmental variable has been introduced. Setting `PRODUCTION=false` will allow you to develop as normal.
- Improved Nextcloud Migration. Mealie will now walk the directories in a zip file looking for directories that match the pattern of a Nextcloud Recipe. Closes #254
- Rewrite Keywords to Tag Fields
- Rewrite url to orgURL
- Improved Chowdown Migration
- Migration report is now similar to the Backup report
- Tags/Categories are now title cased on import "dinner" -> "Dinner"
- Fixed Initialization script (v0.4.1a Hot Fix) Closes #274
- Depreciate `ENV` variable to `PRODUCTION`
- Set `PRODUCTION` env variable to default to true
- Unify Logger across the backend
- mealie.log and last_recipe.json are now downloadable from the frontend from the /admin/about
- New download schema where you request a token and then use that token to hit a single endpoint to download a file. This is a notable change if you are using the API to download backups.

File diff suppressed because one or more lines are too long

View File

@ -77,6 +77,7 @@ nav:
- Guidelines: "contributors/developers-guide/general-guidelines.md" - Guidelines: "contributors/developers-guide/general-guidelines.md"
- Development Road Map: "roadmap.md" - Development Road Map: "roadmap.md"
- Change Log: - Change Log:
- v0.4.2 Backend/Migrations: "changelog/v0.4.2.md"
- v0.4.1 Frontend/UI: "changelog/v0.4.1.md" - v0.4.1 Frontend/UI: "changelog/v0.4.1.md"
- v0.4.0 Authentication: "changelog/v0.4.0.md" - v0.4.0 Authentication: "changelog/v0.4.0.md"
- v0.3.0 Improvements: "changelog/v0.3.0.md" - v0.3.0 Improvements: "changelog/v0.3.0.md"

View File

@ -61,9 +61,16 @@ const apiReq = {
processResponse(response); processResponse(response);
return response; return response;
}, },
async download(url) {
const response = await this.get(url);
const token = response.data.fileToken;
const tokenURL = baseURL + "utils/download?token=" + token;
window.open(tokenURL, "_blank");
return response.data;
},
}; };
export { apiReq }; export { apiReq };
export { baseURL }; export { baseURL };

View File

@ -4,7 +4,7 @@ import { store } from "@/store";
const backupBase = baseURL + "backups/"; const backupBase = baseURL + "backups/";
const backupURLs = { export const backupURLs = {
// Backup // Backup
available: `${backupBase}available`, available: `${backupBase}available`,
createBackup: `${backupBase}export/database`, createBackup: `${backupBase}export/database`,
@ -13,6 +13,8 @@ const backupURLs = {
downloadBackup: fileName => `${backupBase}${fileName}/download`, downloadBackup: fileName => `${backupBase}${fileName}/download`,
}; };
export const backupAPI = { export const backupAPI = {
/** /**
* Request all backups available on the server * Request all backups available on the server
@ -55,7 +57,7 @@ export const backupAPI = {
* @returns Download URL * @returns Download URL
*/ */
async download(fileName) { async download(fileName) {
let response = await apiReq.get(backupURLs.downloadBackup(fileName)); const url = backupURLs.downloadBackup(fileName);
return response.data; apiReq.download(url);
}, },
}; };

View File

@ -37,14 +37,7 @@
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions> <v-card-actions>
<v-btn <TheDownloadBtn :download-url="downloadUrl" />
color="accent"
text
:loading="downloading"
@click="downloadFile(`/api/backups/${name}/download`)"
>
{{ $t("general.download") }}
</v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="error" text @click="raiseEvent('delete')"> <v-btn color="error" text @click="raiseEvent('delete')">
{{ $t("general.delete") }} {{ $t("general.delete") }}
@ -66,9 +59,10 @@
<script> <script>
import ImportOptions from "@/components/Admin/Backup/ImportOptions"; import ImportOptions from "@/components/Admin/Backup/ImportOptions";
import axios from "axios"; import TheDownloadBtn from "@/components/UI/TheDownloadBtn.vue";
import { backupURLs } from "@/api/backup";
export default { export default {
components: { ImportOptions }, components: { ImportOptions, TheDownloadBtn },
props: { props: {
name: { name: {
default: "Backup Name", default: "Backup Name",
@ -92,6 +86,11 @@ export default {
downloading: false, downloading: false,
}; };
}, },
computed: {
downloadUrl() {
return backupURLs.downloadBackup(this.name);
},
},
methods: { methods: {
updateOptions(options) { updateOptions(options) {
this.options = options; this.options = options;
@ -116,23 +115,6 @@ export default {
this.close(); this.close();
this.$emit(event, eventData); this.$emit(event, eventData);
}, },
async downloadFile(downloadURL) {
this.downloading = true;
const response = await axios({
url: downloadURL,
method: "GET",
responseType: "blob", // important
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${this.name}.zip`);
document.body.appendChild(link);
link.click();
this.downloading = false;
},
}, },
}; };
</script> </script>

View File

@ -0,0 +1,51 @@
<template>
<v-btn color="accent" text :loading="downloading" @click="downloadFile">
{{ showButtonText }}
</v-btn>
</template>
<script>
/**
* The download button used for the entire site
* pass a URL to the endpoint that will return a
* file_token which will then be used to request the file
* from the server and open that link in a new tab
*/
import { apiReq } from "@/api/api-utils";
export default {
props: {
/**
* URL to get token from
*/
downloadUrl: {
default: "",
},
/**
* Override button text. Defaults to "Download"
*/
buttonText: {
default: null,
},
},
data() {
return {
downloading: false,
};
},
computed: {
showButtonText() {
return this.buttonText || this.$t("general.download");
},
},
methods: {
async downloadFile() {
this.downloading = true;
await apiReq.download(this.downloadUrl);
this.downloading = false;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -20,6 +20,17 @@
</v-list-item> </v-list-item>
</v-list-item-group> </v-list-item-group>
</v-card-text> </v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<TheDownloadBtn
button-text="Download Recipe JSON"
download-url="/api/debug/last-recipe-json"
/>
<TheDownloadBtn
button-text="Download Log"
download-url="/api/debug/log"
/>
</v-card-actions>
<v-divider></v-divider> <v-divider></v-divider>
</v-card> </v-card>
</div> </div>
@ -27,7 +38,9 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import TheDownloadBtn from "@/components/UI/TheDownloadBtn";
export default { export default {
components: { TheDownloadBtn },
data() { data() {
return { return {
prettyInfo: [], prettyInfo: [],

View File

@ -5,7 +5,7 @@ from mealie.core import root_logger
# import utils.startup as startup # import utils.startup as startup
from mealie.core.config import APP_VERSION, settings from mealie.core.config import APP_VERSION, settings
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes
from mealie.routes.groups import groups from mealie.routes.groups import groups
from mealie.routes.mealplans import mealplans from mealie.routes.mealplans import mealplans
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes
@ -29,6 +29,7 @@ def start_scheduler():
def api_routers(): def api_routers():
# Authentication # Authentication
app.include_router(utility_routes.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(groups.router) app.include_router(groups.router)
# Recipes # Recipes
@ -36,7 +37,6 @@ def api_routers():
app.include_router(category_routes.router) app.include_router(category_routes.router)
app.include_router(tag_routes.router) app.include_router(tag_routes.router)
app.include_router(recipe_crud_routes.router) app.include_router(recipe_crud_routes.router)
# Meal Routes # Meal Routes
app.include_router(mealplans.router) app.include_router(mealplans.router)
# Settings Routes # Settings Routes

View File

@ -3,16 +3,19 @@ import secrets
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
import dotenv
from pydantic import BaseSettings, Field, validator from pydantic import BaseSettings, Field, validator
APP_VERSION = "v0.4.1" APP_VERSION = "v0.4.2"
DB_VERSION = "v0.4.0" DB_VERSION = "v0.4.0"
CWD = Path(__file__).parent CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env") ENV = BASE_DIR.joinpath(".env")
PRODUCTION = os.getenv("ENV", "False").lower() in ["true", "1"]
dotenv.load_dotenv(ENV)
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
def determine_data_dir(production: bool) -> Path: def determine_data_dir(production: bool) -> Path:
@ -83,7 +86,7 @@ app_dirs = AppDirectories(CWD, DATA_DIR)
class AppSettings(BaseSettings): class AppSettings(BaseSettings):
global DATA_DIR global DATA_DIR
PRODUCTION: bool = Field(False, env="ENV") PRODUCTION: bool = Field(True, env="PRODUCTION")
IS_DEMO: bool = False IS_DEMO: bool = False
API_PORT: int = 9000 API_PORT: int = 9000
API_DOCS: bool = True API_DOCS: bool = True

View File

@ -1,9 +1,10 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from mealie.schema.user import UserInDB from pathlib import Path
from jose import jwt from jose import jwt
from mealie.core.config import settings from mealie.core.config import settings
from mealie.db.database import db from mealie.db.database import db
from mealie.schema.user import UserInDB
from passlib.context import CryptContext from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -20,6 +21,11 @@ def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM) return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
def create_file_token(file_path: Path) -> bool:
token_data = {"file": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def authenticate_user(session, email: str, password: str) -> UserInDB: def authenticate_user(session, email: str, password: str) -> UserInDB:
user: UserInDB = db.users.get(session, email, "email") user: UserInDB = db.users.get(session, email, "email")
if not user: if not user:

View File

@ -1,10 +1,12 @@
import operator import operator
import shutil import shutil
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user, validate_file_token
from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from mealie.schema.snackbar import SnackResponse from mealie.schema.snackbar import SnackResponse
from mealie.services.backups import imports from mealie.services.backups import imports
@ -68,13 +70,10 @@ def upload_backup_file(archive: UploadFile = File(...)):
@router.get("/{file_name}/download") @router.get("/{file_name}/download")
async def download_backup_file(file_name: str): async def download_backup_file(file_name: str):
""" Upload a .zip File to later be imported into Mealie """ """ Returns a token to download a file """
file = app_dirs.BACKUP_DIR.joinpath(file_name) file = app_dirs.BACKUP_DIR.joinpath(file_name)
if file.is_file: return {"fileToken": create_file_token(file)}
return FileResponse(file, media_type="application/octet-stream", filename=file_name)
else:
return SnackResponse.error("No File Found")
@router.post("/{file_name}/import", status_code=200) @router.post("/{file_name}/import", status_code=200)

View File

@ -3,6 +3,7 @@ import json
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from mealie.core.config import APP_VERSION, app_dirs, settings from mealie.core.config import APP_VERSION, app_dirs, settings
from mealie.core.root_logger import LOGGER_FILE from mealie.core.root_logger import LOGGER_FILE
from mealie.core.security import create_file_token
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.debug import AppInfo, DebugInfo from mealie.schema.debug import AppInfo, DebugInfo
@ -37,10 +38,8 @@ async def get_mealie_version():
@router.get("/last-recipe-json") @router.get("/last-recipe-json")
async def get_last_recipe_json(current_user=Depends(get_current_user)): async def get_last_recipe_json(current_user=Depends(get_current_user)):
""" Doc Str """ """ Returns a token to download a file """
return {"fileToken": create_file_token(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"))}
with open(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"), "r") as f:
return json.loads(f.read())
@router.get("/log/{num}") @router.get("/log/{num}")
@ -51,6 +50,12 @@ async def get_log(num: int, current_user=Depends(get_current_user)):
return log_text return log_text
@router.get("/log")
async def get_log_file():
""" Returns a token to download a file """
return {"fileToken": create_file_token(LOGGER_FILE)}
def tail(f, lines=20): def tail(f, lines=20):
total_lines_wanted = lines total_lines_wanted = lines

View File

@ -1,3 +1,6 @@
from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
@ -25,7 +28,25 @@ async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(
token_data = TokenData(username=username) token_data = TokenData(username=username)
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
user = db.users.get(session, token_data.username, "email") user = db.users.get(session, token_data.username, "email")
if user is None: if user is None:
raise credentials_exception raise credentials_exception
return user return user
async def validate_file_token(token: Optional[str] = None) -> Path:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate file token",
)
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
file_path = Path(payload.get("file"))
except JWTError:
raise credentials_exception
return file_path

View File

@ -0,0 +1,20 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends
from mealie.routes.deps import validate_file_token
from mealie.schema.snackbar import SnackResponse
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/utils", tags=["Utils"], include_in_schema=True)
@router.get("/download")
async def download_file(file_path: Optional[Path] = Depends(validate_file_token)):
""" Uses a file token obtained by an active user to retrieve a file from the operating
system. """
print("File Name:", file_path)
if file_path.is_file():
return FileResponse(file_path, media_type="application/octet-stream", filename=file_path.name)
else:
return SnackResponse.error("No File Found")