www/server: introduce share mode

This commit is contained in:
2023-11-07 12:39:48 +01:00
parent 6282583d92
commit 226b92c347
8 changed files with 228 additions and 34 deletions

View File

@@ -0,0 +1,30 @@
"""add share_mode
Revision ID: 0fea6d96b096
Revises: 38a927dcb099
Create Date: 2023-11-07 11:12:21.614198
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0fea6d96b096'
down_revision: Union[str, None] = '38a927dcb099'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('transcript', sa.Column('share_mode', sa.String(), server_default='private', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('transcript', 'share_mode')
# ### end Alembic commands ###

View File

@@ -2,10 +2,11 @@ import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
import sqlalchemy import sqlalchemy
from fastapi import HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from reflector.db import database, metadata from reflector.db import database, metadata
from reflector.processors.types import Word as ProcessorWord from reflector.processors.types import Word as ProcessorWord
@@ -30,6 +31,12 @@ transcripts = sqlalchemy.Table(
sqlalchemy.Column("target_language", sqlalchemy.String, nullable=True), sqlalchemy.Column("target_language", sqlalchemy.String, nullable=True),
# with user attached, optional # with user attached, optional
sqlalchemy.Column("user_id", sqlalchemy.String), sqlalchemy.Column("user_id", sqlalchemy.String),
sqlalchemy.Column(
"share_mode",
sqlalchemy.String,
nullable=False,
server_default="private",
),
) )
@@ -99,6 +106,7 @@ class Transcript(BaseModel):
events: list[TranscriptEvent] = [] events: list[TranscriptEvent] = []
source_language: str = "en" source_language: str = "en"
target_language: str = "en" target_language: str = "en"
share_mode: Literal["private", "semi-private", "public"] = "private"
def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: def add_event(self, event: str, data: BaseModel) -> TranscriptEvent:
ev = TranscriptEvent(event=event, data=data.model_dump()) ev = TranscriptEvent(event=event, data=data.model_dump())
@@ -169,6 +177,7 @@ class TranscriptController:
order_by: str | None = None, order_by: str | None = None,
filter_empty: bool | None = False, filter_empty: bool | None = False,
filter_recording: bool | None = False, filter_recording: bool | None = False,
return_query: bool = False,
) -> list[Transcript]: ) -> list[Transcript]:
""" """
Get all transcripts Get all transcripts
@@ -195,6 +204,9 @@ class TranscriptController:
if filter_recording: if filter_recording:
query = query.filter(transcripts.c.status != "recording") query = query.filter(transcripts.c.status != "recording")
if return_query:
return query
results = await database.fetch_all(query) results = await database.fetch_all(query)
return results return results
@@ -210,6 +222,47 @@ class TranscriptController:
return None return None
return Transcript(**result) return Transcript(**result)
async def get_by_id_for_http(
self,
transcript_id: str,
user_id: str | None,
) -> Transcript:
"""
Get a transcript by ID for HTTP request.
If not found, it will raise a 404 error.
If the user is not allowed to access the transcript, it will raise a 403 error.
This method checks the share mode of the transcript and the user_id
to determine if the user can access the transcript.
"""
query = transcripts.select().where(transcripts.c.id == transcript_id)
result = await database.fetch_one(query)
if not result:
raise HTTPException(status_code=404, detail="Transcript not found")
# if the transcript is anonymous, share mode is not checked
transcript = Transcript(**result)
if transcript.user_id is None:
return transcript
if transcript.share_mode == "private":
# in private mode, only the owner can access the transcript
if transcript.user_id == user_id:
return transcript
elif transcript.share_mode == "semi-private":
# in semi-private mode, only the owner and the users with the link
# can access the transcript
if user_id is not None:
return transcript
elif transcript.share_mode == "public":
# in public mode, everyone can access the transcript
return transcript
raise HTTPException(status_code=403, detail="Transcript access denied")
async def add( async def add(
self, self,
name: str, name: str,

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated, Optional from typing import Annotated, Literal, Optional
import reflector.auth as auth import reflector.auth as auth
from fastapi import ( from fastapi import (
@@ -11,7 +11,8 @@ from fastapi import (
WebSocketDisconnect, WebSocketDisconnect,
status, status,
) )
from fastapi_pagination import Page, paginate from fastapi_pagination import Page
from fastapi_pagination.ext.databases import paginate
from jose import jwt from jose import jwt
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from reflector.db.transcripts import ( from reflector.db.transcripts import (
@@ -48,6 +49,7 @@ def create_access_token(data: dict, expires_delta: timedelta):
class GetTranscript(BaseModel): class GetTranscript(BaseModel):
id: str id: str
user_id: str | None
name: str name: str
status: str status: str
locked: bool locked: bool
@@ -56,6 +58,7 @@ class GetTranscript(BaseModel):
short_summary: str | None short_summary: str | None
long_summary: str | None long_summary: str | None
created_at: datetime created_at: datetime
share_mode: str = Field("private")
source_language: str | None source_language: str | None
target_language: str | None target_language: str | None
@@ -72,6 +75,7 @@ class UpdateTranscript(BaseModel):
title: Optional[str] = Field(None) title: Optional[str] = Field(None)
short_summary: Optional[str] = Field(None) short_summary: Optional[str] = Field(None)
long_summary: Optional[str] = Field(None) long_summary: Optional[str] = Field(None)
share_mode: Optional[Literal["public", "semi-private", "private"]] = Field(None)
class DeletionStatus(BaseModel): class DeletionStatus(BaseModel):
@@ -82,12 +86,19 @@ class DeletionStatus(BaseModel):
async def transcripts_list( async def transcripts_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
from reflector.db import database
if not user and not settings.PUBLIC_MODE: if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
return paginate( return await paginate(
await transcripts_controller.get_all(user_id=user_id, order_by="-created_at") database,
await transcripts_controller.get_all(
user_id=user_id,
order_by="-created_at",
return_query=True,
),
) )
@@ -165,10 +176,9 @@ async def transcript_get(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) return await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
return transcript
@router.patch("/transcripts/{transcript_id}", response_model=GetTranscript) @router.patch("/transcripts/{transcript_id}", response_model=GetTranscript)
@@ -192,6 +202,8 @@ async def transcript_update(
values["short_summary"] = info.short_summary values["short_summary"] = info.short_summary
if info.title is not None: if info.title is not None:
values["title"] = info.title values["title"] = info.title
if info.share_mode is not None:
values["share_mode"] = info.share_mode
await transcripts_controller.update(transcript, values) await transcripts_controller.update(transcript, values)
return transcript return transcript
@@ -229,12 +241,12 @@ async def transcript_get_audio_mp3(
except jwt.JWTError: except jwt.JWTError:
raise unauthorized_exception raise unauthorized_exception
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
if not transcript.audio_mp3_filename.exists(): if not transcript.audio_mp3_filename.exists():
raise HTTPException(status_code=404, detail="Audio not found") raise HTTPException(status_code=500, detail="Audio not found")
truncated_id = str(transcript.id).split("-")[0] truncated_id = str(transcript.id).split("-")[0]
filename = f"recording_{truncated_id}.mp3" filename = f"recording_{truncated_id}.mp3"
@@ -253,12 +265,12 @@ async def transcript_get_audio_waveform(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
) -> AudioWaveform: ) -> AudioWaveform:
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
if not transcript.audio_mp3_filename.exists(): if not transcript.audio_mp3_filename.exists():
raise HTTPException(status_code=404, detail="Audio not found") raise HTTPException(status_code=500, detail="Audio not found")
await run_in_threadpool(transcript.convert_audio_to_waveform) await run_in_threadpool(transcript.convert_audio_to_waveform)
@@ -274,9 +286,9 @@ async def transcript_get_topics(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
# convert to GetTranscriptTopic # convert to GetTranscriptTopic
return [ return [
@@ -345,9 +357,9 @@ async def transcript_record_webrtc(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
if transcript.locked: if transcript.locked:
raise HTTPException(status_code=400, detail="Transcript is locked") raise HTTPException(status_code=400, detail="Transcript is locked")

View File

@@ -99,7 +99,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
/> />
</div> </div>
<div className="flex-grow max-w-full"> <div className="flex-grow max-w-full">
<ShareLink /> <ShareLink
protectedPath={protectedPath}
transcriptId={transcript?.response?.id}
userId={transcript?.response?.userId}
shareMode={transcript?.response?.shareMode}
/>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1,15 +1,37 @@
import React, { useState, useRef, useEffect, use } from "react"; import React, { useState, useRef, useEffect, use } from "react";
import { featureEnabled } from "../domainContext"; import { featureEnabled } from "../domainContext";
import getApi from "../../lib/getApi";
import { useFiefUserinfo } from "@fief/fief/nextjs/react";
import SelectSearch from "react-select-search";
import "react-select-search/style.css";
import "../../styles/button.css";
import "../../styles/form.scss";
const ShareLink = () => { type ShareLinkProps = {
protectedPath: boolean;
transcriptId: string;
userId: string | null;
shareMode: string;
};
const ShareLink = (props: ShareLinkProps) => {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [currentUrl, setCurrentUrl] = useState<string>(""); const [currentUrl, setCurrentUrl] = useState<string>("");
const requireLogin = featureEnabled("requireLogin");
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState(props.shareMode);
const api = getApi(props.protectedPath);
const userinfo = useFiefUserinfo();
useEffect(() => { useEffect(() => {
setCurrentUrl(window.location.href); setCurrentUrl(window.location.href);
}, []); }, []);
useEffect(() => {
setIsOwner(!!(requireLogin && userinfo?.sub === props.userId));
}, [userinfo, props.userId]);
const handleCopyClick = () => { const handleCopyClick = () => {
if (inputRef.current) { if (inputRef.current) {
let text_to_copy = inputRef.current.value; let text_to_copy = inputRef.current.value;
@@ -23,6 +45,16 @@ const ShareLink = () => {
} }
}; };
const updateShareMode = async (selectedShareMode: string) => {
if (!api) return;
const updatedTranscript = await api.v1TranscriptUpdate({
transcriptId: props.transcriptId,
updateTranscript: {
shareMode: selectedShareMode,
},
});
setShareMode(updatedTranscript.shareMode);
};
const privacyEnabled = featureEnabled("privacy"); const privacyEnabled = featureEnabled("privacy");
return ( return (
@@ -30,18 +62,50 @@ const ShareLink = () => {
className="p-2 md:p-4 rounded" className="p-2 md:p-4 rounded"
style={{ background: "rgba(96, 165, 250, 0.2)" }} style={{ background: "rgba(96, 165, 250, 0.2)" }}
> >
{privacyEnabled ? ( {requireLogin && (
<p className="text-sm mb-2"> <p className="text-sm mb-2">
You can share this link with others. Anyone with the link will have {shareMode === "private" && (
access to the page, including the full audio recording, for the next 7 <p>This transcript is only accessible by you.</p>
days. )}
</p> {shareMode === "semi-private" && (
) : ( <p>This transcript is accessible by any authenticated users.</p>
<p className="text-sm mb-2"> )}
You can share this link with others. Anyone with the link will have {shareMode === "public" && (
access to the page, including the full audio recording. <p>This transcript is accessible by anyone.</p>
)}
{isOwner && api && (
<p>
<SelectSearch
className="select-search--top select-search"
options={[
{ name: "Private", value: "private" },
{ name: "Semi-private", value: "semi-private" },
{ name: "Public", value: "public" },
]}
value={shareMode}
onChange={updateShareMode}
/>
</p>
)}
</p> </p>
)} )}
{!requireLogin && (
<>
{privacyEnabled ? (
<p className="text-sm mb-2">
You can share this link with others. Anyone with the link will
have access to the page, including the full audio recording, for
the next 7 days.
</p>
) : (
<p className="text-sm mb-2">
You can share this link with others. Anyone with the link will
have access to the page, including the full audio recording.
</p>
)}
</>
)}
<div className="flex items-center"> <div className="flex items-center">
<input <input
type="text" type="text"

View File

@@ -25,6 +25,12 @@ export interface GetTranscript {
* @memberof GetTranscript * @memberof GetTranscript
*/ */
id: any | null; id: any | null;
/**
*
* @type {any}
* @memberof GetTranscript
*/
userId: any | null;
/** /**
* *
* @type {any} * @type {any}
@@ -73,6 +79,12 @@ export interface GetTranscript {
* @memberof GetTranscript * @memberof GetTranscript
*/ */
createdAt: any | null; createdAt: any | null;
/**
*
* @type {any}
* @memberof GetTranscript
*/
shareMode?: any | null;
/** /**
* *
* @type {any} * @type {any}
@@ -93,6 +105,7 @@ export interface GetTranscript {
export function instanceOfGetTranscript(value: object): boolean { export function instanceOfGetTranscript(value: object): boolean {
let isInstance = true; let isInstance = true;
isInstance = isInstance && "id" in value; isInstance = isInstance && "id" in value;
isInstance = isInstance && "userId" in value;
isInstance = isInstance && "name" in value; isInstance = isInstance && "name" in value;
isInstance = isInstance && "status" in value; isInstance = isInstance && "status" in value;
isInstance = isInstance && "locked" in value; isInstance = isInstance && "locked" in value;
@@ -120,6 +133,7 @@ export function GetTranscriptFromJSONTyped(
} }
return { return {
id: json["id"], id: json["id"],
userId: json["user_id"],
name: json["name"], name: json["name"],
status: json["status"], status: json["status"],
locked: json["locked"], locked: json["locked"],
@@ -128,6 +142,7 @@ export function GetTranscriptFromJSONTyped(
shortSummary: json["short_summary"], shortSummary: json["short_summary"],
longSummary: json["long_summary"], longSummary: json["long_summary"],
createdAt: json["created_at"], createdAt: json["created_at"],
shareMode: !exists(json, "share_mode") ? undefined : json["share_mode"],
sourceLanguage: json["source_language"], sourceLanguage: json["source_language"],
targetLanguage: json["target_language"], targetLanguage: json["target_language"],
}; };
@@ -142,6 +157,7 @@ export function GetTranscriptToJSON(value?: GetTranscript | null): any {
} }
return { return {
id: value.id, id: value.id,
user_id: value.userId,
name: value.name, name: value.name,
status: value.status, status: value.status,
locked: value.locked, locked: value.locked,
@@ -150,6 +166,7 @@ export function GetTranscriptToJSON(value?: GetTranscript | null): any {
short_summary: value.shortSummary, short_summary: value.shortSummary,
long_summary: value.longSummary, long_summary: value.longSummary,
created_at: value.createdAt, created_at: value.createdAt,
share_mode: value.shareMode,
source_language: value.sourceLanguage, source_language: value.sourceLanguage,
target_language: value.targetLanguage, target_language: value.targetLanguage,
}; };

View File

@@ -49,6 +49,12 @@ export interface UpdateTranscript {
* @memberof UpdateTranscript * @memberof UpdateTranscript
*/ */
longSummary?: any | null; longSummary?: any | null;
/**
*
* @type {any}
* @memberof UpdateTranscript
*/
shareMode?: any | null;
} }
/** /**
@@ -81,6 +87,7 @@ export function UpdateTranscriptFromJSONTyped(
longSummary: !exists(json, "long_summary") longSummary: !exists(json, "long_summary")
? undefined ? undefined
: json["long_summary"], : json["long_summary"],
shareMode: !exists(json, "share_mode") ? undefined : json["share_mode"],
}; };
} }
@@ -97,5 +104,6 @@ export function UpdateTranscriptToJSON(value?: UpdateTranscript | null): any {
title: value.title, title: value.title,
short_summary: value.shortSummary, short_summary: value.shortSummary,
long_summary: value.longSummary, long_summary: value.longSummary,
share_mode: value.shareMode,
}; };
} }

View File

@@ -35,3 +35,8 @@ body.is-light-mode .input-container {
max-width: 100%; max-width: 100%;
width: auto; width: auto;
} }
body .select-search-container .select-search--top.select-search-select {
top: auto;
bottom: 46px;
}