mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #227 from learnhouse/feat/learnhouse-init-install
Init easy backend install from cli & improve self hosting
This commit is contained in:
commit
0af92d8f3f
27 changed files with 1181 additions and 1195 deletions
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"name": "learnhouse-backend",
|
|
||||||
"dockerComposeFile": ["../docker-compose.yml"],
|
|
||||||
"service": "api",
|
|
||||||
"workspaceFolder": "/usr/learnhouse",
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"eamodio.gitlens",
|
|
||||||
"ms-python.python",
|
|
||||||
"ms-python.black-formatter",
|
|
||||||
"ms-python.vscode-pylance",
|
|
||||||
"styled-components.vscode-styled-components",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"ms-python.isort",
|
|
||||||
"redhat.vscode-yaml",
|
|
||||||
"bradlc.vscode-tailwindcss"
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"[python]": {
|
|
||||||
"editor.defaultFormatter": "ms-python.python"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"shutdownAction": "stopCompose"
|
|
||||||
}
|
|
||||||
16
.github/workflows/docker-build.yaml
vendored
Normal file
16
.github/workflows/docker-build.yaml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
name: App Build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "**"
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: docker build -t learnhouse .
|
||||||
|
working-directory: .
|
||||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Base image
|
||||||
|
FROM python:3.12-slim-bookworm as base
|
||||||
|
|
||||||
|
# Install Nginx, curl, and build-essential
|
||||||
|
RUN apt update && apt install -y nginx curl build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& rm /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Install Node tools
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& npm install -g corepack pm2
|
||||||
|
|
||||||
|
# Frontend Build
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_LEARNHOUSE_API_URL=http://localhost/api/v1/
|
||||||
|
ENV NEXT_PUBLIC_LEARNHOUSE_BACKEND_URL=http://localhost/
|
||||||
|
ENV NEXT_PUBLIC_LEARNHOUSE_DOMAIN=localhost
|
||||||
|
ENV NEXT_PUBLIC_LEARNHOUSE_COLLABORATION_WS_URL=ws://localhost:1998
|
||||||
|
|
||||||
|
WORKDIR /app/web
|
||||||
|
COPY ./apps/web/package.json ./apps/web/pnpm-lock.yaml* ./
|
||||||
|
COPY ./apps/web /app/web
|
||||||
|
RUN rm -f .env*
|
||||||
|
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile && pnpm run build; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final image
|
||||||
|
FROM base as runner
|
||||||
|
RUN addgroup --system --gid 1001 system \
|
||||||
|
&& adduser --system --uid 1001 app \
|
||||||
|
&& mkdir .next \
|
||||||
|
&& chown app:system .next
|
||||||
|
COPY --from=deps /app/web/public ./app/web/public
|
||||||
|
COPY --from=deps --chown=app:system /app/web/.next/standalone ./app/web/
|
||||||
|
COPY --from=deps --chown=app:system /app/web/.next/static ./app/web/.next/static
|
||||||
|
|
||||||
|
# Backend Build
|
||||||
|
WORKDIR /app/api
|
||||||
|
COPY ./apps/api/poetry.lock* ./
|
||||||
|
COPY ./apps/api/pyproject.toml ./
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install poetry \
|
||||||
|
&& poetry config virtualenvs.create false \
|
||||||
|
&& poetry install --no-interaction --no-ansi
|
||||||
|
COPY ./apps/api ./
|
||||||
|
|
||||||
|
# Run the backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./extra/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
ENV PORT=8000 LEARNHOUSE_PORT=9000 HOSTNAME=0.0.0.0
|
||||||
|
COPY ./extra/start.sh /app/start.sh
|
||||||
|
CMD ["sh", "start.sh"]
|
||||||
121
apps/api/cli.py
Normal file
121
apps/api/cli.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from typing import Annotated
|
||||||
|
from pydantic import EmailStr
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlmodel import SQLModel, Session
|
||||||
|
import typer
|
||||||
|
from config.config import get_learnhouse_config
|
||||||
|
from src.db.organizations import OrganizationCreate
|
||||||
|
from src.db.users import UserCreate
|
||||||
|
from src.services.install.install import (
|
||||||
|
install_create_organization,
|
||||||
|
install_create_organization_user,
|
||||||
|
install_default_elements,
|
||||||
|
)
|
||||||
|
|
||||||
|
cli = typer.Typer()
|
||||||
|
|
||||||
|
def generate_password(length):
|
||||||
|
characters = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
||||||
|
password = ''.join(random.choice(characters) for _ in range(length))
|
||||||
|
return password
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def install(
|
||||||
|
short: Annotated[bool, typer.Option(help="Install with predefined values")] = False
|
||||||
|
):
|
||||||
|
# Get the database session
|
||||||
|
learnhouse_config = get_learnhouse_config()
|
||||||
|
engine = create_engine(
|
||||||
|
learnhouse_config.database_config.sql_connection_string, echo=False, pool_pre_ping=True # type: ignore
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
db_session = Session(engine)
|
||||||
|
|
||||||
|
if short:
|
||||||
|
# Install the default elements
|
||||||
|
print("Installing default elements...")
|
||||||
|
install_default_elements(db_session)
|
||||||
|
print("Default elements installed ✅")
|
||||||
|
|
||||||
|
# Create the Organization
|
||||||
|
print("Creating default organization...")
|
||||||
|
org = OrganizationCreate(
|
||||||
|
name="Default Organization",
|
||||||
|
description="Default Organization",
|
||||||
|
slug="default",
|
||||||
|
email="",
|
||||||
|
logo_image="",
|
||||||
|
)
|
||||||
|
install_create_organization(org, db_session)
|
||||||
|
print("Default organization created ✅")
|
||||||
|
|
||||||
|
# Create Organization User
|
||||||
|
print("Creating default organization user...")
|
||||||
|
# Generate random 6 digit password
|
||||||
|
email = "admin@school.dev"
|
||||||
|
password = generate_password(8)
|
||||||
|
user = UserCreate(
|
||||||
|
username="admin", email=EmailStr(email), password=password
|
||||||
|
)
|
||||||
|
install_create_organization_user(user, "default", db_session)
|
||||||
|
print("Default organization user created ✅")
|
||||||
|
|
||||||
|
# Show the user how to login
|
||||||
|
print("Installation completed ✅")
|
||||||
|
print("")
|
||||||
|
print("Login with the following credentials:")
|
||||||
|
print("email: " + email)
|
||||||
|
print("password: " + password)
|
||||||
|
print("⚠️ Remember to change the password after logging in ⚠️")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Install the default elements
|
||||||
|
print("Installing default elements...")
|
||||||
|
install_default_elements(db_session)
|
||||||
|
print("Default elements installed ✅")
|
||||||
|
|
||||||
|
# Create the Organization
|
||||||
|
print("Creating your organization...")
|
||||||
|
orgname = typer.prompt("What's shall we call your organization?")
|
||||||
|
slug = typer.prompt(
|
||||||
|
"What's the slug for your organization? (e.g. school, acme)"
|
||||||
|
)
|
||||||
|
org = OrganizationCreate(
|
||||||
|
name=orgname,
|
||||||
|
description="Default Organization",
|
||||||
|
slug=slug.lower(),
|
||||||
|
email="",
|
||||||
|
logo_image="",
|
||||||
|
)
|
||||||
|
install_create_organization(org, db_session)
|
||||||
|
print(orgname + " Organization created ✅")
|
||||||
|
|
||||||
|
# Create Organization User
|
||||||
|
print("Creating your organization user...")
|
||||||
|
username = typer.prompt("What's the username for the user?")
|
||||||
|
email = typer.prompt("What's the email for the user?")
|
||||||
|
password = typer.prompt("What's the password for the user?", hide_input=True)
|
||||||
|
user = UserCreate(username=username, email=EmailStr(email), password=password)
|
||||||
|
install_create_organization_user(user, slug, db_session)
|
||||||
|
print(username + " user created ✅")
|
||||||
|
|
||||||
|
# Show the user how to login
|
||||||
|
print("Installation completed ✅")
|
||||||
|
print("")
|
||||||
|
print("Login with the following credentials:")
|
||||||
|
print("email: " + email)
|
||||||
|
print("password: The password you entered")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def main():
|
||||||
|
cli()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
991
apps/api/poetry.lock
generated
991
apps/api/poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ version = "0.1.0"
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
boto3 = "^1.34.79"
|
boto3 = "^1.34.79"
|
||||||
botocore = "^1.34.84"
|
botocore = "^1.34.84"
|
||||||
chromadb = "^0.4.22"
|
|
||||||
faker = "^24.9.0"
|
faker = "^24.9.0"
|
||||||
fastapi = "^0.110.1"
|
fastapi = "^0.110.1"
|
||||||
fastapi-jwt-auth = "^0.5.0"
|
fastapi-jwt-auth = "^0.5.0"
|
||||||
|
|
@ -37,6 +36,8 @@ sentry-sdk = {extras = ["fastapi"], version = "^1.45.0"}
|
||||||
sqlmodel = "^0.0.16"
|
sqlmodel = "^0.0.16"
|
||||||
tiktoken = "^0.6.0"
|
tiktoken = "^0.6.0"
|
||||||
uvicorn = "0.29.0"
|
uvicorn = "0.29.0"
|
||||||
|
typer = "^0.12.3"
|
||||||
|
chromadb = "^0.4.24"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
|
||||||
37
apps/api/src/core/events/autoinstall.py
Normal file
37
apps/api/src/core/events/autoinstall.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlmodel import SQLModel, Session, select
|
||||||
|
|
||||||
|
from cli import install
|
||||||
|
from config.config import get_learnhouse_config
|
||||||
|
from src.db.organizations import Organization
|
||||||
|
|
||||||
|
|
||||||
|
def auto_install():
|
||||||
|
# Get the database session
|
||||||
|
learnhouse_config = get_learnhouse_config()
|
||||||
|
engine = create_engine(
|
||||||
|
learnhouse_config.database_config.sql_connection_string, echo=False, pool_pre_ping=True # type: ignore
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
db_session = Session(engine)
|
||||||
|
|
||||||
|
orgs = db_session.exec(select(Organization)).all()
|
||||||
|
|
||||||
|
if len(orgs) == 0:
|
||||||
|
print("No organizations found. Starting auto-installation 🏗️")
|
||||||
|
install(short=True)
|
||||||
|
|
||||||
|
if orgs:
|
||||||
|
for org in orgs:
|
||||||
|
default_org = db_session.exec(select(Organization).where(Organization.slug == 'default')).first()
|
||||||
|
|
||||||
|
if not default_org:
|
||||||
|
print("No default organization found. Starting auto-installation 🏗️")
|
||||||
|
install(short=True)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Organizations found. Skipping auto-installation 🚀")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from config.config import LearnHouseConfig, get_learnhouse_config
|
from config.config import LearnHouseConfig, get_learnhouse_config
|
||||||
|
from src.core.events.autoinstall import auto_install
|
||||||
from src.core.events.content import check_content_directory
|
from src.core.events.content import check_content_directory
|
||||||
from src.core.events.database import close_database, connect_to_db
|
from src.core.events.database import close_database, connect_to_db
|
||||||
from src.core.events.logs import create_logs_dir
|
from src.core.events.logs import create_logs_dir
|
||||||
|
|
@ -25,6 +26,9 @@ def startup_app(app: FastAPI) -> Callable:
|
||||||
# Create content directory
|
# Create content directory
|
||||||
await check_content_directory()
|
await check_content_directory()
|
||||||
|
|
||||||
|
# Check if auto-installation is needed
|
||||||
|
auto_install()
|
||||||
|
|
||||||
return start_app
|
return start_app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from src.db.install import InstallRead
|
||||||
from src.core.events.database import get_db_session
|
from src.core.events.database import get_db_session
|
||||||
from src.db.organizations import OrganizationCreate
|
from src.db.organizations import OrganizationCreate
|
||||||
from src.db.users import UserCreate
|
from src.db.users import UserCreate
|
||||||
|
|
||||||
from src.services.install.install import (
|
from src.services.install.install import (
|
||||||
create_install_instance,
|
create_install_instance,
|
||||||
get_latest_install_instance,
|
get_latest_install_instance,
|
||||||
|
|
@ -43,7 +42,7 @@ async def api_get_latest_install_instance(
|
||||||
async def api_install_def_elements(
|
async def api_install_def_elements(
|
||||||
db_session=Depends(get_db_session),
|
db_session=Depends(get_db_session),
|
||||||
):
|
):
|
||||||
elements = await install_default_elements(db_session)
|
elements = install_default_elements(db_session)
|
||||||
|
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
|
|
@ -53,7 +52,7 @@ async def api_install_org(
|
||||||
org: OrganizationCreate,
|
org: OrganizationCreate,
|
||||||
db_session=Depends(get_db_session),
|
db_session=Depends(get_db_session),
|
||||||
):
|
):
|
||||||
organization = await install_create_organization(org, db_session)
|
organization = install_create_organization(org, db_session)
|
||||||
|
|
||||||
return organization
|
return organization
|
||||||
|
|
||||||
|
|
@ -64,7 +63,7 @@ async def api_install_user(
|
||||||
org_slug: str,
|
org_slug: str,
|
||||||
db_session=Depends(get_db_session),
|
db_session=Depends(get_db_session),
|
||||||
):
|
):
|
||||||
user = await install_create_organization_user(data, org_slug, db_session)
|
user = install_create_organization_user(data, org_slug, db_session)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ async def authenticate_user(
|
||||||
user = await security_get_user(request, db_session, email)
|
user = await security_get_user(request, db_session, email)
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
if not await security_verify_password(password, user.password):
|
if not security_verify_password(password, user.password):
|
||||||
return False
|
return False
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ ALGORITHM = "HS256"
|
||||||
### 🔒 Passwords Hashing ##############################################################
|
### 🔒 Passwords Hashing ##############################################################
|
||||||
|
|
||||||
|
|
||||||
async def security_hash_password(password: str):
|
def security_hash_password(password: str):
|
||||||
return pbkdf2_sha256.hash(password)
|
return pbkdf2_sha256.hash(password)
|
||||||
|
|
||||||
|
|
||||||
async def security_verify_password(plain_password: str, hashed_password: str):
|
def security_verify_password(plain_password: str, hashed_password: str):
|
||||||
return pbkdf2_sha256.verify(plain_password, hashed_password)
|
return pbkdf2_sha256.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ async def update_install_instance(
|
||||||
|
|
||||||
|
|
||||||
# Install Default roles
|
# Install Default roles
|
||||||
async def install_default_elements(db_session: Session):
|
def install_default_elements(db_session: Session):
|
||||||
"""
|
"""
|
||||||
"""
|
"""
|
||||||
# remove all default roles
|
# remove all default roles
|
||||||
|
|
@ -300,7 +300,7 @@ async def install_default_elements(db_session: Session):
|
||||||
|
|
||||||
|
|
||||||
# Organization creation
|
# Organization creation
|
||||||
async def install_create_organization(
|
def install_create_organization(
|
||||||
org_object: OrganizationCreate, db_session: Session
|
org_object: OrganizationCreate, db_session: Session
|
||||||
):
|
):
|
||||||
org = Organization.model_validate(org_object)
|
org = Organization.model_validate(org_object)
|
||||||
|
|
@ -364,14 +364,14 @@ async def install_create_organization(
|
||||||
return org
|
return org
|
||||||
|
|
||||||
|
|
||||||
async def install_create_organization_user(
|
def install_create_organization_user(
|
||||||
user_object: UserCreate, org_slug: str, db_session: Session
|
user_object: UserCreate, org_slug: str, db_session: Session
|
||||||
):
|
):
|
||||||
user = User.model_validate(user_object)
|
user = User.model_validate(user_object)
|
||||||
|
|
||||||
# Complete the user object
|
# Complete the user object
|
||||||
user.user_uuid = f"user_{uuid4()}"
|
user.user_uuid = f"user_{uuid4()}"
|
||||||
user.password = await security_hash_password(user_object.password)
|
user.password = security_hash_password(user_object.password)
|
||||||
user.email_verified = False
|
user.email_verified = False
|
||||||
user.creation_date = str(datetime.now())
|
user.creation_date = str(datetime.now())
|
||||||
user.update_date = str(datetime.now())
|
user.update_date = str(datetime.now())
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ async def change_password_with_reset_code(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Change password
|
# Change password
|
||||||
user.password = await security_hash_password(new_password)
|
user.password = security_hash_password(new_password)
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ async def create_user(
|
||||||
|
|
||||||
# Complete the user object
|
# Complete the user object
|
||||||
user.user_uuid = f"user_{uuid4()}"
|
user.user_uuid = f"user_{uuid4()}"
|
||||||
user.password = await security_hash_password(user_object.password)
|
user.password = security_hash_password(user_object.password)
|
||||||
user.email_verified = False
|
user.email_verified = False
|
||||||
user.creation_date = str(datetime.now())
|
user.creation_date = str(datetime.now())
|
||||||
user.update_date = str(datetime.now())
|
user.update_date = str(datetime.now())
|
||||||
|
|
@ -164,7 +164,7 @@ async def create_user_without_org(
|
||||||
|
|
||||||
# Complete the user object
|
# Complete the user object
|
||||||
user.user_uuid = f"user_{uuid4()}"
|
user.user_uuid = f"user_{uuid4()}"
|
||||||
user.password = await security_hash_password(user_object.password)
|
user.password = security_hash_password(user_object.password)
|
||||||
user.email_verified = False
|
user.email_verified = False
|
||||||
user.creation_date = str(datetime.now())
|
user.creation_date = str(datetime.now())
|
||||||
user.update_date = str(datetime.now())
|
user.update_date = str(datetime.now())
|
||||||
|
|
@ -340,13 +340,13 @@ async def update_user_password(
|
||||||
# RBAC check
|
# RBAC check
|
||||||
await rbac_check(request, current_user, "update", user.user_uuid, db_session)
|
await rbac_check(request, current_user, "update", user.user_uuid, db_session)
|
||||||
|
|
||||||
if not await security_verify_password(form.old_password, user.password):
|
if not security_verify_password(form.old_password, user.password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update user
|
# Update user
|
||||||
user.password = await security_hash_password(form.new_password)
|
user.password = security_hash_password(form.new_password)
|
||||||
user.update_date = str(datetime.now())
|
user.update_date = str(datetime.now())
|
||||||
|
|
||||||
# Update user in database
|
# Update user in database
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,71 @@
|
||||||
#
|
FROM node:18-alpine AS base
|
||||||
FROM node:18-alpine
|
|
||||||
|
|
||||||
#
|
# Install dependencies only when needed
|
||||||
WORKDIR /usr/learnhouse/front
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
#
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package.json /usr/learnhouse/front/package.json
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
#
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
#
|
# Rebuild the source code only when needed
|
||||||
COPY ./ /usr/learnhouse
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
#
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
CMD ["npm", "run", "dev"]
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Remove .env files from the final image
|
||||||
|
# This is a good practice to avoid leaking sensitive data
|
||||||
|
# Learn more about it in the Next.js documentation: https://nextjs.org/docs/basic-features/environment-variables
|
||||||
|
RUN rm -f .env*
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
|
||||||
|
# server.js is created by next build from the standalone output
|
||||||
|
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||||
|
CMD HOSTNAME="0.0.0.0" node server.js
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs'
|
|
||||||
import NextError from 'next/error'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function GlobalError({
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
|
||||||
Sentry.captureException(error)
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
{/* This is the default Next.js error component but it doesn't allow omitting the statusCode property yet. */}
|
|
||||||
<NextError statusCode={undefined as any} />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import FormLayout, {
|
|
||||||
ButtonBlack,
|
|
||||||
Flex,
|
|
||||||
FormField,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
Textarea,
|
|
||||||
} from '@components/StyledElements/Form/Form'
|
|
||||||
import { BarLoader } from 'react-spinners'
|
|
||||||
import * as Form from '@radix-ui/react-form'
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import * as Sentry from '@sentry/browser'
|
|
||||||
import { CheckCircleIcon } from 'lucide-react'
|
|
||||||
import { useSession } from '@components/Contexts/SessionContext'
|
|
||||||
|
|
||||||
export const FeedbackModal = (user: any) => {
|
|
||||||
const session = useSession() as any
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [view, setView] = useState<'feedbackForm' | 'success'>('feedbackForm')
|
|
||||||
const [feedbackMessage, setFeedbackMessage] = useState('')
|
|
||||||
|
|
||||||
const handleSubmit = async (e: any) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsSubmitting(true)
|
|
||||||
|
|
||||||
const user = session.user ? session.user : null
|
|
||||||
const eventId = Sentry.captureMessage(
|
|
||||||
`Feedback from ${user ? user.email : 'Anonymous'} - ${feedbackMessage}`
|
|
||||||
)
|
|
||||||
|
|
||||||
const userFeedback = {
|
|
||||||
event_id: eventId,
|
|
||||||
name: user ? user.full_name : 'Anonymous',
|
|
||||||
email: user ? user.email : 'Anonymous',
|
|
||||||
comments: feedbackMessage,
|
|
||||||
}
|
|
||||||
Sentry.captureUserFeedback(userFeedback)
|
|
||||||
setIsSubmitting(false)
|
|
||||||
setView('success')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFeedbackMessage = (event: React.ChangeEvent<any>) => {
|
|
||||||
setFeedbackMessage(event.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (view == 'feedbackForm') {
|
|
||||||
return (
|
|
||||||
<FormLayout onSubmit={handleSubmit}>
|
|
||||||
<FormField name="feedback-message">
|
|
||||||
<Flex
|
|
||||||
css={{ alignItems: 'baseline', justifyContent: 'space-between' }}
|
|
||||||
>
|
|
||||||
<FormLabel>Feedback message</FormLabel>
|
|
||||||
<FormMessage match="valueMissing">
|
|
||||||
Please provide learning elements, separated by comma (,)
|
|
||||||
</FormMessage>
|
|
||||||
</Flex>
|
|
||||||
<Form.Control asChild>
|
|
||||||
<Textarea
|
|
||||||
style={{ height: 150 }}
|
|
||||||
onChange={handleFeedbackMessage}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Form.Control>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
|
||||||
<Form.Submit asChild>
|
|
||||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<BarLoader
|
|
||||||
cssOverride={{ borderRadius: 60 }}
|
|
||||||
width={60}
|
|
||||||
color="#ffffff"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'Submit Feedback'
|
|
||||||
)}
|
|
||||||
</ButtonBlack>
|
|
||||||
</Form.Submit>
|
|
||||||
</Flex>
|
|
||||||
</FormLayout>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center space-y-5">
|
|
||||||
<div className="flex flex-col items-center space-y-5 pt-10">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="text-9xl text-green-500">
|
|
||||||
<CheckCircleIcon></CheckCircleIcon>
|
|
||||||
</div>
|
|
||||||
<div className="text-3xl text-green-500">
|
|
||||||
<div>Thank you for your feedback!</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xl text-gray-500">
|
|
||||||
<div>We will take it into account.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ButtonBlack onClick={() => setView('feedbackForm')}>
|
|
||||||
Send another feedback
|
|
||||||
</ButtonBlack>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FeedbackModal
|
|
||||||
|
|
@ -1,25 +1,8 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
output: 'standalone',
|
||||||
}
|
}
|
||||||
|
|
||||||
const { withSentryConfig } = require("@sentry/nextjs");
|
module.exports = nextConfig
|
||||||
|
|
||||||
const SentryWebpackPluginOptions = {
|
|
||||||
// For all available options, see:
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
|
||||||
|
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
|
||||||
widenClientFileUpload: true,
|
|
||||||
|
|
||||||
// Transpiles SDK to be compatible with IE11 (increases bundle size)
|
|
||||||
transpileClientSDK: true,
|
|
||||||
|
|
||||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
|
||||||
tunnelRoute: "/monitoring",
|
|
||||||
|
|
||||||
// Hides source maps from generated client bundles
|
|
||||||
hideSourceMaps: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = withSentryConfig(nextConfig, SentryWebpackPluginOptions);
|
|
||||||
|
|
@ -18,8 +18,6 @@
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@sentry/browser": "^7.109.0",
|
|
||||||
"@sentry/nextjs": "^7.109.0",
|
|
||||||
"@stitches/react": "^1.2.8",
|
"@stitches/react": "^1.2.8",
|
||||||
"@tiptap/core": "^2.2.4",
|
"@tiptap/core": "^2.2.4",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.2.4",
|
"@tiptap/extension-code-block-lowlight": "^2.2.4",
|
||||||
|
|
@ -35,9 +33,11 @@
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.5",
|
||||||
"framer-motion": "^10.18.0",
|
"framer-motion": "^10.18.0",
|
||||||
|
"highlight.js": "^11.9.0",
|
||||||
|
"katex": "^0.16.10",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"lucide-react": "^0.363.0",
|
"lucide-react": "^0.363.0",
|
||||||
"next": "14.1.4",
|
"next": "14.2.2",
|
||||||
"prosemirror-state": "^1.4.3",
|
"prosemirror-state": "^1.4.3",
|
||||||
"randomcolor": "^0.6.2",
|
"randomcolor": "^0.6.2",
|
||||||
"re-resizable": "^6.9.11",
|
"re-resizable": "^6.9.11",
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
"react-katex": "^3.0.1",
|
"react-katex": "^3.0.1",
|
||||||
"react-spinners": "^0.13.8",
|
"react-spinners": "^0.13.8",
|
||||||
"react-youtube": "^10.1.0",
|
"react-youtube": "^10.1.0",
|
||||||
|
"sharp": "^0.33.3",
|
||||||
"styled-components": "^6.1.8",
|
"styled-components": "^6.1.8",
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
|
|
|
||||||
724
apps/web/pnpm-lock.yaml
generated
724
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +0,0 @@
|
||||||
// This file configures the initialization of Sentry on the client.
|
|
||||||
// The config you add here will be used whenever a users loads a page in their browser.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs'
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
|
||||||
const DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: DSN,
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
|
|
||||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
|
||||||
// in development and sample at a lower rate in production
|
|
||||||
replaysSessionSampleRate: 0.1,
|
|
||||||
|
|
||||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
|
||||||
integrations: [
|
|
||||||
new Sentry.Replay({
|
|
||||||
// Additional Replay configuration goes in here, for example:
|
|
||||||
maskAllText: true,
|
|
||||||
blockAllMedia: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// This file configures the initialization of Sentry on the server.
|
|
||||||
// The config you add here will be used whenever the server handles a request.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs'
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
|
||||||
const DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
|
|
||||||
Sentry.init({
|
|
||||||
dsn: DSN,
|
|
||||||
tracesSampleRate: 1.0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
api:
|
app:
|
||||||
build: apps/api/.
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "1338:80"
|
- "80:80"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/usr/learnhouse
|
- .:/usr/learnhouse
|
||||||
environment:
|
env_file:
|
||||||
- LEARNHOUSE_COOKIE_DOMAIN=.localhost
|
- ./extra/example-learnhouse-conf.env
|
||||||
# This overrides the default config.yaml (optimized for docker container based development)
|
|
||||||
- LEARNHOUSE_SQL_CONNECTION_STRING=postgresql://learnhouse:learnhouse@db:5432/learnhouse
|
|
||||||
- LEARNHOUSE_REDIS_CONNECTION_STRING=redis://redis:6379/learnhouse
|
|
||||||
- LEARNHOUSE_PORT=80
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
7
extra/example-learnhouse-conf.env
Normal file
7
extra/example-learnhouse-conf.env
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Frontend
|
||||||
|
NEXT_PUBLIC_LEARNHOUSE_MULTI_ORG=false
|
||||||
|
NEXT_PUBLIC_LEARNHOUSE_DEFAULT_ORG=default
|
||||||
|
# Backend
|
||||||
|
LEARNHOUSE_COOKIE_DOMAIN=.localhost
|
||||||
|
LEARNHOUSE_SQL_CONNECTION_STRING=postgresql://learnhouse:learnhouse@db:5432/learnhouse
|
||||||
|
LEARNHOUSE_REDIS_CONNECTION_STRING=redis://redis:6379/learnhouse
|
||||||
40
extra/nginx.conf
Normal file
40
extra/nginx.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# NextJS Revalidation
|
||||||
|
location /api/revalidate {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Python Backend API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost:9000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend Static Content
|
||||||
|
location /content {
|
||||||
|
proxy_pass http://localhost:9000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extra/start.sh
Normal file
14
extra/start.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Start the services
|
||||||
|
pm2 start server.js --cwd /app/web --name learnhouse-web > /dev/null 2>&1
|
||||||
|
pm2 start app.py --cwd /app/api --name learnhouse-api > /dev/null 2>&1
|
||||||
|
|
||||||
|
# Check if the services are running qnd log the status
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# Start Nginx in the background
|
||||||
|
nginx -g 'daemon off;' &
|
||||||
|
|
||||||
|
# Tail Nginx error and access logs
|
||||||
|
pm2 logs
|
||||||
Loading…
Add table
Add a link
Reference in a new issue