mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-21 21:49:51 +01:00
feat: markdown upload media (#12231)
# Problem You can add gifs and images to text cards but need to know how to write markdown (and realise it is possible) # Changes * Adds an API (authenticated) to allow image upload * Adds an endpoint to view images (immutable cache headers set) * Adds some basic validation * Adds UI to allow drop of file onto a text card (well, any component using the LemonTextMarkdown) to upload the image and insert a link to it in the markdown content
This commit is contained in:
parent
a8cf68277b
commit
525ac12045
@ -4,14 +4,14 @@
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<envs>
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
<env name="DJANGO_SETTINGS_MODULE" value="posthog.settings" />
|
||||
<env name="DEBUG" value="1" />
|
||||
<env name="CLICKHOUSE_SECURE" value="False" />
|
||||
<env name="KAFKA_URL" value="kafka://localhost" />
|
||||
<env name="DATABASE_URL" value="postgres://posthog:posthog@localhost:5432/posthog" />
|
||||
<env name="SKIP_SERVICE_VERSION_REQUIREMENTS" value="1" />
|
||||
<env name="DEBUG" value="1" />
|
||||
<env name="DJANGO_SETTINGS_MODULE" value="posthog.settings" />
|
||||
<env name="KAFKA_URL" value="kafka://localhost" />
|
||||
<env name="PRINT_SQL" value="1" />
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
<env name="SKIP_SERVICE_VERSION_REQUIREMENTS" value="1" />
|
||||
</envs>
|
||||
<option name="SDK_HOME" value="$PROJECT_DIR$/env/bin/python" />
|
||||
<option name="WORKING_DIRECTORY" value="" />
|
||||
@ -29,4 +29,4 @@
|
||||
<option name="customRunCommand" value="" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
</component>
|
@ -25,6 +25,7 @@ import {
|
||||
SubscriptionType,
|
||||
TeamType,
|
||||
UserType,
|
||||
MediaUploadResponse,
|
||||
} from '~/types'
|
||||
import { getCurrentOrganizationId, getCurrentTeamId } from './utils/logics'
|
||||
import { CheckboxValueType } from 'antd/lib/checkbox/Group'
|
||||
@ -328,6 +329,10 @@ class ApiRequest {
|
||||
return this.integrations(teamId).addPathComponent(id).addPathComponent('channels')
|
||||
}
|
||||
|
||||
public media(teamId?: TeamType['id']): ApiRequest {
|
||||
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
|
||||
}
|
||||
|
||||
// Request finalization
|
||||
|
||||
public async get(options?: { signal?: AbortSignal }): Promise<any> {
|
||||
@ -819,6 +824,12 @@ const api = {
|
||||
},
|
||||
},
|
||||
|
||||
media: {
|
||||
async upload(data: FormData): Promise<MediaUploadResponse> {
|
||||
return await new ApiRequest().media().create({ data })
|
||||
},
|
||||
},
|
||||
|
||||
async get(url: string, signal?: AbortSignal): Promise<any> {
|
||||
const res = await api.getRaw(url, signal)
|
||||
return await getJSONOrThrow(res)
|
||||
|
@ -49,4 +49,8 @@
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
@ -36,3 +36,9 @@
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.LemonTextMarkdown {
|
||||
&.FileDropTarget {
|
||||
border: 3px dashed var(--primary);
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,18 @@
|
||||
import './LemonTextArea.scss'
|
||||
import React, { useRef } from 'react'
|
||||
import React, { createRef, useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { Tabs } from 'antd'
|
||||
import { IconMarkdown } from 'lib/components/icons'
|
||||
import { IconMarkdown, IconTools, IconUploadFile } from 'lib/components/icons'
|
||||
import { TextCardBody } from 'lib/components/Cards/TextCard/TextCard'
|
||||
import { Spinner } from 'lib/components/Spinner/Spinner'
|
||||
import api from 'lib/api'
|
||||
import { lemonToast } from 'lib/components/lemonToast'
|
||||
import { useValues } from 'kea'
|
||||
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
|
||||
import posthog from 'posthog-js'
|
||||
import { Link } from 'lib/components/Link'
|
||||
import { Tooltip } from 'lib/components/Tooltip'
|
||||
|
||||
export interface LemonTextAreaProps
|
||||
extends Pick<
|
||||
@ -48,9 +56,7 @@ export const LemonTextArea = React.forwardRef<HTMLTextAreaElement, LemonTextArea
|
||||
|
||||
onKeyDown?.(e)
|
||||
}}
|
||||
onChange={(event) => {
|
||||
onChange?.(event.currentTarget.value ?? '')
|
||||
}}
|
||||
onChange={(event) => onChange?.(event.currentTarget.value ?? '')}
|
||||
{...textProps}
|
||||
/>
|
||||
)
|
||||
@ -63,13 +69,121 @@ interface LemonTextMarkdownProps {
|
||||
}
|
||||
|
||||
export function LemonTextMarkdown({ value, onChange, ...editAreaProps }: LemonTextMarkdownProps): JSX.Element {
|
||||
const { objectStorageAvailable } = useValues(preflightLogic)
|
||||
|
||||
// dragCounter and drag are used to track whether the user is dragging a file over the textarea
|
||||
// without drag counter the textarea highlight would flicker when the user drags a file over it
|
||||
let dragCounter = 0
|
||||
const [drag, setDrag] = useState(false)
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const dropRef = createRef<HTMLDivElement>()
|
||||
|
||||
const handleDrag = (e: DragEvent): void => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragIn = (e: DragEvent): void => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter++
|
||||
if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
|
||||
setDrag(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOut = (e: DragEvent): void => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter--
|
||||
if (dragCounter === 0) {
|
||||
setDrag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = async (e: DragEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDrag(false)
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
try {
|
||||
setIsUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('image', e.dataTransfer.files[0])
|
||||
const media = await api.media.upload(formData)
|
||||
onChange(value + `\n\n![${media.name}](${media.image_location})`)
|
||||
posthog.capture('markdown image uploaded', { name: media.name })
|
||||
} catch (error) {
|
||||
const errorDetail = (error as any).detail || 'unknown error'
|
||||
posthog.capture('markdown image upload failed', { error: errorDetail })
|
||||
lemonToast.error(`Error uploading image: ${errorDetail}`)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
e.dataTransfer.clearData()
|
||||
dragCounter = 0
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const div = dropRef.current
|
||||
if (!div || !objectStorageAvailable) {
|
||||
return
|
||||
} else {
|
||||
div.addEventListener('dragenter', handleDragIn)
|
||||
div.addEventListener('dragleave', handleDragOut)
|
||||
div.addEventListener('dragover', handleDrag)
|
||||
div.addEventListener('drop', handleDrop)
|
||||
return () => {
|
||||
div?.removeEventListener('dragenter', handleDragIn)
|
||||
div?.removeEventListener('dragleave', handleDragOut)
|
||||
div?.removeEventListener('dragover', handleDrag)
|
||||
div?.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}
|
||||
}, [value, objectStorageAvailable])
|
||||
|
||||
return (
|
||||
<Tabs>
|
||||
<Tabs.TabPane tab="Write" key="write-card" destroyInactiveTabPane={true}>
|
||||
<LemonTextArea {...editAreaProps} autoFocus value={value} onChange={(newValue) => onChange(newValue)} />
|
||||
<div className="text-muted inline-flex items-center space-x-1">
|
||||
<IconMarkdown className={'text-2xl'} />
|
||||
<span>Markdown formatting support</span>
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={clsx('LemonTextMarkdown flex flex-col p-2 space-y-1 rounded', drag && 'FileDropTarget')}
|
||||
>
|
||||
<LemonTextArea {...editAreaProps} autoFocus value={value} onChange={onChange} />
|
||||
<div className="text-muted inline-flex items-center space-x-1">
|
||||
<IconMarkdown className={'text-2xl'} />
|
||||
<span>Markdown formatting support</span>
|
||||
</div>
|
||||
{objectStorageAvailable ? (
|
||||
<div className="text-muted inline-flex items-center space-x-1">
|
||||
<IconUploadFile className={'text-2xl mr-1'} />
|
||||
<span>Attach images by dragging and dropping them</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted inline-flex items-center space-x-1">
|
||||
<Tooltip title={'Enable object storage to add images by dragging and dropping.'}>
|
||||
<IconTools className={'text-xl mr-1'} />
|
||||
</Tooltip>
|
||||
<span>
|
||||
Add external images using{' '}
|
||||
<Link to={'https://www.markdownguide.org/basic-syntax/#images-1'}>
|
||||
{' '}
|
||||
Markdown image links
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="text-muted inline-flex items-center space-x-1">
|
||||
<Spinner />
|
||||
uploading image...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Preview" key={'preview-card'}>
|
||||
|
@ -198,6 +198,10 @@ export const preflightLogic = kea<preflightLogicType>([
|
||||
(preflight): boolean =>
|
||||
Boolean(preflight && Object.values(preflight.available_social_auth_providers).filter((i) => i).length),
|
||||
],
|
||||
objectStorageAvailable: [
|
||||
(s) => [s.preflight],
|
||||
(preflight): boolean => Boolean(preflight && preflight.object_storage),
|
||||
],
|
||||
realm: [
|
||||
(s) => [s.preflight],
|
||||
(preflight): Realm | null => {
|
||||
|
@ -2101,3 +2101,9 @@ export enum FeatureFlagReleaseType {
|
||||
ReleaseToggle = 'Release toggle',
|
||||
Variants = 'Multiple variants',
|
||||
}
|
||||
|
||||
export interface MediaUploadResponse {
|
||||
id: string
|
||||
image_location: string
|
||||
name: string
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ auth: 0012_alter_user_first_name_max_length
|
||||
axes: 0006_remove_accesslog_trusted
|
||||
contenttypes: 0002_remove_content_type_name
|
||||
ee: 0013_silence_deprecated_tags_warnings
|
||||
posthog: 0269_soft_delete_tiles
|
||||
posthog: 0270_add_uploaded_media
|
||||
rest_hooks: 0002_swappable_hook_model
|
||||
sessions: 0001_initial
|
||||
social_django: 0010_uid_db_index
|
||||
|
@ -31,6 +31,7 @@ from . import (
|
||||
sharing,
|
||||
site_app,
|
||||
team,
|
||||
uploaded_media,
|
||||
user,
|
||||
)
|
||||
|
||||
@ -105,6 +106,7 @@ projects_router.register(
|
||||
r"property_definitions", property_definition.PropertyDefinitionViewSet, "project_property_definitions", ["team_id"]
|
||||
)
|
||||
|
||||
projects_router.register(r"uploaded_media", uploaded_media.MediaViewSet, "project_media", ["team_id"])
|
||||
|
||||
# General endpoints (shared across CH & PG)
|
||||
router.register(r"login", authentication.LoginViewSet)
|
||||
|
@ -6,11 +6,12 @@ from django.db.models import Prefetch, Q, QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import exceptions, response, serializers, viewsets
|
||||
from rest_framework import exceptions, serializers, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from posthog.api.forbid_destroy_model import ForbidDestroyModel
|
||||
from posthog.api.insight import InsightSerializer, InsightViewSet
|
||||
@ -360,17 +361,17 @@ class DashboardsViewSet(TaggedItemViewSetMixin, StructuredViewSetMixin, ForbidDe
|
||||
|
||||
return queryset
|
||||
|
||||
def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> response.Response:
|
||||
def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
pk = kwargs["pk"]
|
||||
queryset = self.get_queryset()
|
||||
dashboard = get_object_or_404(queryset, pk=pk)
|
||||
dashboard.last_accessed_at = now()
|
||||
dashboard.save(update_fields=["last_accessed_at"])
|
||||
serializer = DashboardSerializer(dashboard, context={"view": self, "request": request})
|
||||
return response.Response(serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(methods=["PATCH"], detail=True)
|
||||
def move_tile(self, request: Request, *args: Any, **kwargs: Any) -> response.Response:
|
||||
def move_tile(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
# TODO could things be rearranged so this is PATCH call on a resource and not a custom endpoint?
|
||||
tile = request.data["tile"]
|
||||
from_dashboard = kwargs["pk"]
|
||||
@ -383,7 +384,7 @@ class DashboardsViewSet(TaggedItemViewSetMixin, StructuredViewSetMixin, ForbidDe
|
||||
serializer = DashboardSerializer(
|
||||
Dashboard.objects.get(id=from_dashboard), context={"view": self, "request": request}
|
||||
)
|
||||
return response.Response(serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class LegacyDashboardsViewSet(DashboardsViewSet):
|
||||
|
98
posthog/api/test/test_uploaded_media.py
Normal file
98
posthog/api/test/test_uploaded_media.py
Normal file
@ -0,0 +1,98 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from boto3 import resource
|
||||
from botocore.config import Config
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
|
||||
from posthog.models import UploadedMedia
|
||||
from posthog.settings import (
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID,
|
||||
OBJECT_STORAGE_BUCKET,
|
||||
OBJECT_STORAGE_ENDPOINT,
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY,
|
||||
)
|
||||
from posthog.storage import object_storage
|
||||
from posthog.test.base import APIBaseTest
|
||||
|
||||
MEDIA_ROOT = tempfile.mkdtemp()
|
||||
|
||||
TEST_BUCKET = "Test-Uploads"
|
||||
|
||||
|
||||
def get_path_to(fixture_file: str) -> str:
|
||||
file_dir = os.path.dirname(__file__)
|
||||
return os.path.join(file_dir, "fixtures", fixture_file)
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
|
||||
class TestMediaAPI(APIBaseTest):
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(MEDIA_ROOT, ignore_errors=True) # delete the temp dir
|
||||
# delete s3 files
|
||||
s3 = resource(
|
||||
"s3",
|
||||
endpoint_url=OBJECT_STORAGE_ENDPOINT,
|
||||
aws_access_key_id=OBJECT_STORAGE_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=OBJECT_STORAGE_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
region_name="us-east-1",
|
||||
)
|
||||
bucket = s3.Bucket(OBJECT_STORAGE_BUCKET)
|
||||
bucket.objects.filter(Prefix=TEST_BUCKET).delete()
|
||||
|
||||
super().tearDownClass()
|
||||
|
||||
def test_can_upload_and_retrieve_a_file(self) -> None:
|
||||
with self.settings(OBJECT_STORAGE_ENABLED=True, OBJECT_STORAGE_MEDIA_UPLOADS_FOLDER=TEST_BUCKET):
|
||||
fake_file = SimpleUploadedFile(name="test_image.jpg", content=b"a fake image", content_type="image/jpeg")
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/uploaded_media", {"image": fake_file}, format="multipart"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json())
|
||||
assert response.json()["name"] == "test_image.jpg"
|
||||
media_location = response.json()["image_location"]
|
||||
assert re.match(r"^http://localhost:8000/uploaded_media/.*/test_image.jpg", media_location) is not None
|
||||
|
||||
upload = UploadedMedia.objects.get(id=response.json()["id"])
|
||||
|
||||
content = object_storage.read_bytes(upload.media_location)
|
||||
assert content == b"a fake image"
|
||||
|
||||
self.client.logout()
|
||||
response = self.client.get(media_location)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == "image/jpeg"
|
||||
|
||||
def test_rejects_non_image_file_type(self) -> None:
|
||||
fake_file = SimpleUploadedFile(name="test_image.jpg", content=b"a fake image", content_type="text/csv")
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/uploaded_media", {"image": fake_file}, format="multipart"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, response.json())
|
||||
|
||||
def test_rejects_too_large_file_type(self) -> None:
|
||||
four_megabytes_plus_a_little = b"1" * (4 * 1024 * 1024 + 1)
|
||||
fake_big_file = SimpleUploadedFile(
|
||||
name="test_image.jpg", content=four_megabytes_plus_a_little, content_type="image/jpeg"
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/uploaded_media", {"image": fake_big_file}, format="multipart"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json())
|
||||
self.assertEqual(response.json()["detail"], "Uploaded media must be less than 4MB")
|
||||
|
||||
def test_rejects_upload_when_object_storage_is_unavailable(self) -> None:
|
||||
with override_settings(OBJECT_STORAGE_ENABLED=False):
|
||||
fake_big_file = SimpleUploadedFile(name="test_image.jpg", content=b"", content_type="image/jpeg")
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/uploaded_media", {"image": fake_big_file}, format="multipart"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json())
|
||||
self.assertEqual(response.json()["detail"], "Object storage must be available to allow media uploads.")
|
101
posthog/api/uploaded_media.py
Normal file
101
posthog/api/uploaded_media.py
Normal file
@ -0,0 +1,101 @@
|
||||
from typing import Dict
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.exceptions import APIException, NotFound, UnsupportedMediaType, ValidationError
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
|
||||
from posthog.api.routing import StructuredViewSetMixin
|
||||
from posthog.internal_metrics import incr
|
||||
from posthog.models import UploadedMedia
|
||||
from posthog.models.uploaded_media import ObjectStorageUnavailable
|
||||
from posthog.permissions import ProjectMembershipNecessaryPermissions, TeamMemberAccessPermission
|
||||
from posthog.storage import object_storage
|
||||
|
||||
FOUR_MEGABYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def download(request, *args, **kwargs) -> HttpResponse:
|
||||
"""
|
||||
Images are immutable, so we can cache them forever
|
||||
They are served unauthenticated as they might be presented on shared dashboards
|
||||
"""
|
||||
instance: UploadedMedia = UploadedMedia.objects.get(pk=kwargs["image_uuid"])
|
||||
|
||||
if not instance or not instance.file_name == kwargs["file_name"]:
|
||||
raise NotFound("Image not found")
|
||||
|
||||
file_bytes = object_storage.read_bytes(instance.media_location)
|
||||
|
||||
incr("uploaded_media.served", tags={"team_id": instance.team_id, "uuid": kwargs["image_uuid"]})
|
||||
|
||||
return HttpResponse(
|
||||
file_bytes,
|
||||
content_type=instance.content_type,
|
||||
headers={"Cache-Control": "public, max-age=315360000, immutable"},
|
||||
)
|
||||
|
||||
|
||||
class MediaViewSet(StructuredViewSetMixin, viewsets.GenericViewSet):
|
||||
queryset = UploadedMedia.objects.all()
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
permission_classes = [
|
||||
IsAuthenticatedOrReadOnly,
|
||||
ProjectMembershipNecessaryPermissions,
|
||||
TeamMemberAccessPermission,
|
||||
]
|
||||
|
||||
@extend_schema(
|
||||
description="""
|
||||
When object storage is available this API allows upload of media which can be used, for example, in text cards on dashboards.
|
||||
|
||||
Uploaded media must have a content type beginning with 'image/' and be less than 4MB.
|
||||
"""
|
||||
)
|
||||
def create(self, request, *args, **kwargs) -> Response:
|
||||
try:
|
||||
file = request.data["image"]
|
||||
|
||||
if file.size > FOUR_MEGABYTES:
|
||||
raise ValidationError(code="file_too_large", detail="Uploaded media must be less than 4MB")
|
||||
|
||||
if file.content_type.startswith("image/"):
|
||||
uploaded_media = UploadedMedia.save_content(
|
||||
team=self.team,
|
||||
created_by=request.user,
|
||||
file_name=file.name,
|
||||
content_type=file.content_type,
|
||||
content=file.file,
|
||||
)
|
||||
if uploaded_media is None:
|
||||
raise APIException("Could not save media")
|
||||
headers = self.get_success_headers(uploaded_media.get_absolute_url())
|
||||
incr("uploaded_media.uploaded", tags={"team_id": self.team.pk, "content_type": file.content_type})
|
||||
return Response(
|
||||
{
|
||||
"id": uploaded_media.id,
|
||||
"image_location": uploaded_media.get_absolute_url(),
|
||||
"name": uploaded_media.file_name,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
raise UnsupportedMediaType(file.content_type)
|
||||
except KeyError:
|
||||
raise ValidationError(code="no-image-provided", detail="An image file must be provided")
|
||||
except ObjectStorageUnavailable:
|
||||
raise ValidationError(
|
||||
code="object_storage_required", detail="Object storage must be available to allow media uploads."
|
||||
)
|
||||
|
||||
def get_success_headers(self, location: str) -> Dict:
|
||||
try:
|
||||
return {"Location": location}
|
||||
except (TypeError, KeyError):
|
||||
return {}
|
42
posthog/migrations/0270_add_uploaded_media.py
Normal file
42
posthog/migrations/0270_add_uploaded_media.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Generated by Django 3.2.15 on 2022-10-13 10:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import posthog.models.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("posthog", "0269_soft_delete_tiles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UploadedMedia",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("media_location", models.TextField(blank=True, max_length=1000, null=True)),
|
||||
("content_type", models.TextField(blank=True, max_length=100, null=True)),
|
||||
("file_name", models.TextField(blank=True, max_length=1000, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
@ -39,6 +39,7 @@ from .subscription import Subscription
|
||||
from .tag import Tag
|
||||
from .tagged_item import TaggedItem
|
||||
from .team import Team
|
||||
from .uploaded_media import UploadedMedia
|
||||
from .user import User, UserManager
|
||||
|
||||
__all__ = [
|
||||
@ -97,6 +98,7 @@ __all__ = [
|
||||
"TaggedItem",
|
||||
"Team",
|
||||
"Text",
|
||||
"UploadedMedia",
|
||||
"User",
|
||||
"UserManager",
|
||||
"UserPromptSequenceState",
|
||||
|
71
posthog/models/uploaded_media.py
Normal file
71
posthog/models/uploaded_media.py
Normal file
@ -0,0 +1,71 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import structlog
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
from posthog.models.team import Team
|
||||
from posthog.models.user import User
|
||||
from posthog.models.utils import UUIDModel
|
||||
from posthog.storage import object_storage
|
||||
from posthog.storage.object_storage import ObjectStorageError
|
||||
from posthog.utils import absolute_uri
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ObjectStorageUnavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UploadedMedia(UUIDModel):
|
||||
team: models.ForeignKey = models.ForeignKey("Team", on_delete=models.CASCADE)
|
||||
created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True, blank=True)
|
||||
created_by: models.ForeignKey = models.ForeignKey("User", on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# path in object storage or some other location identifier for the asset
|
||||
# 1000 characters would hold a 20 UUID forward slash separated path with space to spare
|
||||
media_location: models.TextField = models.TextField(null=True, blank=True, max_length=1000)
|
||||
content_type: models.TextField = models.TextField(null=True, blank=True, max_length=100)
|
||||
file_name: models.TextField = models.TextField(null=True, blank=True, max_length=1000)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return absolute_uri(f"/uploaded_media/{self.id}/{self.file_name}")
|
||||
|
||||
@classmethod
|
||||
def save_content(
|
||||
cls, team: Team, created_by: User, file_name: str, content_type: str, content: bytes
|
||||
) -> Optional["UploadedMedia"]:
|
||||
try:
|
||||
media = UploadedMedia.objects.create(
|
||||
team=team, created_by=created_by, file_name=file_name, content_type=content_type
|
||||
)
|
||||
if settings.OBJECT_STORAGE_ENABLED:
|
||||
save_content_to_object_storage(media, content)
|
||||
else:
|
||||
logger.error(
|
||||
"uploaded_media.upload_attempted_without_object_storage_configured",
|
||||
file_name=file_name,
|
||||
team=team.pk,
|
||||
)
|
||||
raise ObjectStorageUnavailable()
|
||||
return media
|
||||
except ObjectStorageError as ose:
|
||||
capture_exception(ose)
|
||||
logger.error(
|
||||
"uploaded_media.object-storage-error", file_name=file_name, team=team.pk, exception=ose, exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def save_content_to_object_storage(uploaded_media: UploadedMedia, content: bytes) -> None:
|
||||
path_parts: List[str] = [
|
||||
settings.OBJECT_STORAGE_MEDIA_UPLOADS_FOLDER,
|
||||
f"team-{uploaded_media.team.pk}",
|
||||
f"media-{uploaded_media.pk}",
|
||||
]
|
||||
object_path = f'/{"/".join(path_parts)}'
|
||||
object_storage.write(object_path, content)
|
||||
uploaded_media.media_location = object_path
|
||||
uploaded_media.save(update_fields=["media_location"])
|
@ -23,3 +23,4 @@ OBJECT_STORAGE_ENABLED = get_from_env("OBJECT_STORAGE_ENABLED", True if DEBUG el
|
||||
OBJECT_STORAGE_BUCKET = os.getenv("OBJECT_STORAGE_BUCKET", "posthog")
|
||||
OBJECT_STORAGE_SESSION_RECORDING_FOLDER = os.getenv("OBJECT_STORAGE_SESSION_RECORDING_FOLDER", "session_recordings")
|
||||
OBJECT_STORAGE_EXPORTS_FOLDER = os.getenv("OBJECT_STORAGE_EXPORTS_FOLDER", "exports")
|
||||
OBJECT_STORAGE_MEDIA_UPLOADS_FOLDER = os.getenv("OBJECT_STORAGE_MEDIA_UPLOADS_FOLDER", "media_uploads")
|
||||
|
@ -250,6 +250,8 @@ GZIP_RESPONSE_ALLOW_LIST = get_list(
|
||||
"^/?api/projects/\\d+/session_recordings/?$",
|
||||
"^/?api/projects/\\d+/exports/\\d+/content/?$",
|
||||
"^/?api/projects/\\d+/activity_log/important_changes/?$",
|
||||
"^/?api/projects/\\d+/uploaded_media/?$",
|
||||
"^/uploaded_media/.*$",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
@ -23,6 +23,7 @@ from posthog.api import (
|
||||
signup,
|
||||
site_app,
|
||||
unsubscribe,
|
||||
uploaded_media,
|
||||
user,
|
||||
)
|
||||
from posthog.api.decide import hostname_in_allowed_url_list
|
||||
@ -153,6 +154,7 @@ urlpatterns = [
|
||||
"login/<str:backend>/", authentication.sso_login, name="social_begin"
|
||||
), # overrides from `social_django.urls` to validate proper license
|
||||
path("", include("social_django.urls", namespace="social")),
|
||||
path("uploaded_media/<str:image_uuid>/<str:file_name>", uploaded_media.download),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
Loading…
Reference in New Issue
Block a user