0
0
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:
Paul D'Ambra 2022-10-14 11:27:44 +01:00 committed by GitHub
parent a8cf68277b
commit 525ac12045
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 488 additions and 21 deletions

View File

@ -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>

View File

@ -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)

View File

@ -49,4 +49,8 @@
p {
margin-bottom: 0;
}
img {
max-width: 100%;
}
}

View File

@ -36,3 +36,9 @@
border: 1px solid var(--danger);
}
}
.LemonTextMarkdown {
&.FileDropTarget {
border: 3px dashed var(--primary);
}
}

View File

@ -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'}>

View File

@ -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 => {

View File

@ -2101,3 +2101,9 @@ export enum FeatureFlagReleaseType {
ReleaseToggle = 'Release toggle',
Variants = 'Multiple variants',
}
export interface MediaUploadResponse {
id: string
image_location: string
name: string
}

View File

@ -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

View File

@ -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)

View File

@ -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):

View 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.")

View 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 {}

View 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,
},
),
]

View File

@ -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",

View 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"])

View File

@ -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")

View File

@ -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/.*$",
]
),
)

View File

@ -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: