Skip to content

Commit

Permalink
Fixed #95: Allow Single User
Browse files Browse the repository at this point in the history
This feature serves two purposes:

- sets a variable on both frontend and backend indicating that we are in single-user mode. this is the concept we'll use to switch between public SaaS and private single-user
- sets a single User ID that is sent to all ~users~

This makes sure that anyone connecting is on the same session, sees the same projects. Even though the cookie might expire, re-requesting one gets the same User ID.

On the backend, if SINGLE_USER_ID is set, it is checked for existence on start or created if not.
In the frontend, homepage checks whether it is expecting a single user mode (ENV) and if yes, fetches the User and Projects from backend before redirecting to /collections
  • Loading branch information
rgaudin committed Sep 12, 2024
1 parent 2f6e54f commit e254f34
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 8 deletions.
8 changes: 8 additions & 0 deletions backend/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from uuid import UUID

import humanfriendly
from rq import Retry
Expand All @@ -31,6 +32,9 @@ class BackendConf:
illustration_quota: int = 0
api_version_prefix: str = "/v1" # our API

# single-user mode (Kiwix only)
single_user_id: str = os.getenv("SINGLE_USER_ID", "").strip() or ""

# Database
postgres_uri: str = os.getenv("POSTGRES_URI") or "nodb"

Expand Down Expand Up @@ -130,6 +134,10 @@ def __post_init__(self):
os.getenv("ZIMFARM_TASK_DISK") or "200MiB"
)

@property
def single_user(self) -> UUID:
return UUID(self.single_user_id)

Check warning on line 139 in backend/api/constants.py

View check run for this annotation

Codecov / codecov/patch

backend/api/constants.py#L139

Added line #L139 was not covered by tests


constants = BackendConf()
logger = constants.logger
18 changes: 16 additions & 2 deletions backend/api/database/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import datetime
from uuid import UUID

from sqlalchemy import select
from sqlalchemy import func, select

from api.database import Session as DBSession
from api.database.models import File, Project
from api.database.models import File, Project, User


def get_file_by_id(file_id: UUID) -> File:
Expand All @@ -26,3 +27,16 @@ def get_project_by_id(project_id: UUID) -> Project:
raise ValueError(f"Project not found: {project_id}")
session.expunge(project)
return project


def ensure_user_with(id_: UUID) -> bool:
"""whether such a user has been created"""
with DBSession.begin() as session:
stmt = select(func.count()).select_from(User).filter_by(id=id_)

Check warning on line 35 in backend/api/database/utils.py

View check run for this annotation

Codecov / codecov/patch

backend/api/database/utils.py#L35

Added line #L35 was not covered by tests
if session.scalars(stmt).one() > 0:
return False
user = User(created_on=datetime.datetime.now(tz=datetime.UTC), projects=[])
session.add(user)
user.id = id_
session.add(user)
return True

Check warning on line 42 in backend/api/database/utils.py

View check run for this annotation

Codecov / codecov/patch

backend/api/database/utils.py#L37-L42

Added lines #L37 - L42 were not covered by tests
5 changes: 5 additions & 0 deletions backend/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@

from api import __description__, __titile__, __version__
from api.constants import constants, determine_mandatory_environment_variables
from api.database.utils import ensure_user_with
from api.routes import archives, files, projects, users, utils


@asynccontextmanager
async def lifespan(_: FastAPI):
determine_mandatory_environment_variables()

if constants.single_user_id:
# make sure said user is present in DB (creates otherwise)
ensure_user_with(id_=constants.single_user)

Check warning on line 20 in backend/api/main.py

View check run for this annotation

Codecov / codecov/patch

backend/api/main.py#L20

Added line #L20 was not covered by tests
yield


Expand Down
1 change: 0 additions & 1 deletion backend/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ async def validated_user(
)
stmt = select(User).filter_by(id=user_id)
user = session.execute(stmt).scalar()
stmt = select(User)
if not user:
# using delete_cookie to construct the cookie header
# but passing it to HTTPException as FastAPI middleware creates Response for it
Expand Down
14 changes: 10 additions & 4 deletions backend/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session

from api.constants import constants
Expand All @@ -25,10 +26,15 @@ async def create_user(
response: Response, session: Session = Depends(gen_session)
) -> UserModel:
"""Post this endpoint to create a user."""
new_user = User(created_on=datetime.datetime.now(tz=datetime.UTC), projects=[])
session.add(new_user)
session.flush()
session.refresh(new_user)
if constants.single_user_id:
new_user: User = session.execute(

Check warning on line 30 in backend/api/routes/users.py

View check run for this annotation

Codecov / codecov/patch

backend/api/routes/users.py#L30

Added line #L30 was not covered by tests
select(User).filter_by(id=constants.single_user)
).scalar_one()
else:
new_user = User(created_on=datetime.datetime.now(tz=datetime.UTC), projects=[])
session.add(new_user)
session.flush()
session.refresh(new_user)
response.set_cookie(
key=constants.authentication_cookie_name,
value=str(new_user.id),
Expand Down
2 changes: 2 additions & 0 deletions dev/reload-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ services:
- NAUTILUS_FILE_QUOTA=${NAUTILUS_FILE_QUOTA}
- NAUTILUS_PROJECT_QUOTA=${NAUTILUS_PROJECT_QUOTA}
- NAUTILUS_FILE_REFRESH_EVERY_MS=${NAUTILUS_FILE_REFRESH_EVERY_MS}
- NAUTILUS_IS_SINGLE_USER=1
- DEBUG=1
depends_on:
- backend
Expand Down Expand Up @@ -97,6 +98,7 @@ services:
- MAILGUN_API_KEY=${MAILGUN_API_KEY}
- MAILGUN_API_URL=${MAILGUN_API_URL}
- MAILGUN_FROM=${MAILGUN_FROM}
- SINGLE_USER_ID=ec7c62b2-65f5-46d0-be28-51c5e6d206ea
depends_on:
database:
condition: service_healthy
Expand Down
1 change: 1 addition & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ COPY entrypoint.sh /app/
RUN ls /app

ENV NAUTILUS_WEB_API http://localhost:8080/v1
ENV NAUTILUS_IS_SINGLE_USER ""
EXPOSE 80

ENTRYPOINT [ "/app/entrypoint.sh" ]
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export interface Environ {
NAUTILUS_FILE_QUOTA: number
NAUTILUS_PROJECT_QUOTA: number
NAUTILUS_FILE_REFRESH_EVERY_MS: number
NAUTILUS_IS_SINGLE_USER: boolean
}

export interface AlertMessage {
Expand Down Expand Up @@ -165,7 +166,8 @@ export const EmptyConstants = new Constants({
NAUTILUS_WEB_API: 'noapi',
NAUTILUS_FILE_QUOTA: 100000000,
NAUTILUS_PROJECT_QUOTA: 100000000,
NAUTILUS_FILE_REFRESH_EVERY_MS: 1000
NAUTILUS_FILE_REFRESH_EVERY_MS: 1000,
NAUTILUS_IS_SINGLE_USER: false
})

// using iec to be consistent accross tools (MiB): jedec renders MiB as MB
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/views/SingleUserHome.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<template>
<div class="d-flex flex-column vh-100">
<div class="flex-shrink-1">
<p>Retrieving single user & projects…</p>
</div>
</div>
</template>

<script setup lang="ts">
import { type Project } from '@/constants'
import type { User } from '@/constants'
import { useAppStore, useProjectStore } from '@/stores/stores'
import { createNewProject } from '@/utils'
import router from '@/router'
const storeProject = useProjectStore()
const storeApp = useAppStore()
async function restrieveUser(): Promise<User | null> {
var user: User | null = null
try {
const createUserRespone = await storeApp.axiosInstance.post<User>('/users')
user = createUserRespone.data
} catch (error: any) {
console.log('Unable to create a new user.', error)
storeApp.alertsError('Unable to create a new user.')
}
return user
}
async function retrieveProjects(): Promise<Project[]> {
var projects: Project[] = []
try {
const response = await storeApp.axiosInstance.get<Project[]>('/projects')
console.log(response.data)
projects = response.data
} catch (error: unknown) {
console.log('Unable to retrieve projects info', error)
storeApp.alertsError('Unable to retrieve projects info')
}
if (projects.length == 0) {
let project: Project | null = await createNewProject('First Project')
if (project !== null) {
projects.push(project)
}
}
return projects
}
await restrieveUser()
const projects = await retrieveProjects()
storeProject.setProjects(projects)
if (projects.length) {
storeProject.setLastProjectId(projects[projects.length - 1].id)
}
router.push('/collections')
</script>
4 changes: 4 additions & 0 deletions frontend/src/views/StartView.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<div class="d-flex flex-column vh-100">
<div class="flex-shrink-1">
<div v-if="storeApp.constants.env.NAUTILUS_IS_SINGLE_USER">
<SingleUserHome />
</div>
<Suspense>
<CollectionsView v-if="isValidProjectId" />
<HomeView v-else />
Expand All @@ -11,6 +14,7 @@
</template>

<script setup lang="ts">
import SingleUserHome from '@/views/SingleUserHome.vue'
import FooterComponent from '@/components/FooterComponent.vue'
import CollectionsView from './CollectionsView.vue'
import HomeView from '@/views/HomeView.vue'
Expand Down

0 comments on commit e254f34

Please sign in to comment.