Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New post #2127

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ venv/
#####################################
client_secrets.json

# Google Cloud Storage #
########################
google-cloud-secret*

# Webpack #
###########
/static/bundles
Expand Down
69 changes: 69 additions & 0 deletions fields/encrypted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import itertools

import django.db
import django.db.models

import cryptography.fernet

from sefaria.utils.encryption import get_crypter


CRYPTER = get_crypter()


def encrypt_str(s):
# be sure to encode the string to bytes
return CRYPTER.encrypt(s.encode('utf-8'))


def decrypt_str(t):
# be sure to decode the bytes to a string
return CRYPTER.decrypt(t.encode('utf-8')).decode('utf-8')


def calc_encrypted_length(n):
# calculates the characters necessary to hold an encrypted string of
# n bytes
return len(encrypt_str('a' * n))


class EncryptedMixin(object):
def to_python(self, value):
if value is None:
return value

if isinstance(value, (bytes, str)):
if isinstance(value, bytes):
value = value.decode('utf-8')
try:
value = decrypt_str(value)
except cryptography.fernet.InvalidToken:
pass

return super(EncryptedMixin, self).to_python(value)

def from_db_value(self, value, *args, **kwargs):
return self.to_python(value)

def get_db_prep_save(self, value, connection):
value = super(EncryptedMixin, self).get_db_prep_save(value, connection)

if value is None:
return value
# decode the encrypted value to a unicode string, else this breaks in pgsql
return (encrypt_str(str(value))).decode('utf-8')

def get_internal_type(self):
return "TextField"

def deconstruct(self):
name, path, args, kwargs = super(EncryptedMixin, self).deconstruct()

if 'max_length' in kwargs:
del kwargs['max_length']

return name, path, args, kwargs


class EncryptedCharField(EncryptedMixin, django.db.models.CharField):
pass
69 changes: 69 additions & 0 deletions fields/file_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# file: myapp/fields.py

from django.db.models.fields.files import ImageField, ImageFieldFile

from sefaria.google_storage_manager import GoogleStorageManager

class GCSImageFieldFile(ImageFieldFile):
"""
Minimal subclass of ImageFieldFile that stores files on Google Cloud Storage (GCS).
We override `save()` and `delete()` to call GoogleStorageManager.
"""

@property
def url(self):
"""
Return the GCS URL we stored in `self.name`.
Django normally constructs the URL from default storage, but here
we already have the public URL in `self.name`.
"""
return self.name

def save(self, name, content, save=True):
"""
1) Upload file to GCS via GoogleStorageManager.
2) Store the returned public URL in `self.name`.
3) Optionally save the model field.
"""
public_url = GoogleStorageManager.upload_file(
from_file=content.file, # file-like object
to_filename=name, # use incoming name for simplicity
bucket_name=self.field.bucket_name
)
self.name = public_url
self._committed = True

if save:
setattr(self.instance, self.field.name, self)
self.instance.save(update_fields=[self.field.name])

def delete(self, save=True):
"""
Remove file from GCS (if exists), clear self.name, optionally save.
"""
if self.name:
# Extract the actual filename from the URL, then delete from GCS
filename = GoogleStorageManager.get_filename_from_url(self.name)
if filename:
GoogleStorageManager.delete_filename(
filename=filename,
bucket_name=self.field.bucket_name
)
self.name = None
self._committed = False

if save:
setattr(self.instance, self.field.name, self)
self.instance.save(update_fields=[self.field.name])


class GCSImageField(ImageField):
"""
Minimal custom ImageField that uses GCSImageFieldFile for storage.
Stores the public GCS URL in the database instead of a local path.
"""
attr_class = GCSImageFieldFile

def __init__(self, bucket_name=None, *args, **kwargs):
self.bucket_name = bucket_name or GoogleStorageManager.PROFILES_BUCKET
super().__init__(*args, **kwargs)
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ data:

SEFARIA_BOT_API_KEY = os.getenv("SEFARIA_BOT_API_KEY")

# Field Encryption
FIELD_ENCRYPTION_KEY_STR = os.getenv("FIELD_ENCRYPTION_KEY")
FIELD_ENCRYPTION_KEY = FIELD_ENCRYPTION_KEY_STR.encode('utf-8')

CLOUDFLARE_ZONE= os.getenv("CLOUDFLARE_ZONE")
CLOUDFLARE_EMAIL= os.getenv("CLOUDFLARE_EMAIL")
CLOUDFLARE_TOKEN= os.getenv("CLOUDFLARE_TOKEN")
Expand Down
Empty file added plugins/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions plugins/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .models import Plugin
from django.contrib import admin
class PluginAdmin(admin.ModelAdmin):
list_display = ('name', 'url', 'secret')
search_fields = ('name',)
fields = ('name', 'description', 'url', 'image')

admin.site.register(Plugin, PluginAdmin)
6 changes: 6 additions & 0 deletions plugins/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class PluginsAppConfig(AppConfig):
name = "plugins"
verbose_name = "Plugins Management"
28 changes: 28 additions & 0 deletions plugins/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2024-12-05 21:25
from __future__ import unicode_literals

from django.db import migrations, models
import fields.encrypted


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='Plugin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField()),
('url', models.URLField()),
('secret', fields.encrypted.EncryptedCharField()),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
21 changes: 21 additions & 0 deletions plugins/migrations/0002_plugin_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2024-12-31 12:16
from __future__ import unicode_literals

from django.db import migrations
import fields.file_fields


class Migration(migrations.Migration):

dependencies = [
('plugins', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='plugin',
name='image',
field=fields.file_fields.GCSImageField(blank=True, null=True, upload_to=''),
),
]
Empty file added plugins/migrations/__init__.py
Empty file.
35 changes: 35 additions & 0 deletions plugins/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.db import models
from fields.encrypted import EncryptedCharField
from cryptography.fernet import Fernet
from fields.file_fields import GCSImageField

class Plugin(models.Model):
name = models.CharField(max_length=100)
description = models.TextField()
url = models.URLField()
secret = EncryptedCharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
image = GCSImageField(blank=True, null=True)

# on create, generate a secret
def save(self, *args, **kwargs):
if not self.secret:
self.secret = self._generate_secret()

super(Plugin, self).save(*args, **kwargs)

def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"url": self.url,
"image": self.image.url if self.image else None,
}

def _generate_secret(self):
key = Fernet.generate_key()
return key.decode('utf-8')

def __str__(self):
return self.name
16 changes: 16 additions & 0 deletions plugins/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""

from django.test import TestCase


class SimpleTest(TestCase):
def test_basic_addition(self):
"""
Tests that 1 + 1 always equals 2.
"""
self.assertEqual(1 + 1, 2)
77 changes: 77 additions & 0 deletions plugins/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
from django.http import HttpResponse
import requests

import structlog

from plugins.models import Plugin
from sefaria.client.util import jsonResponse
from sefaria.utils.encryption import encrypt_str_with_key
logger = structlog.get_logger(__name__)


def dev(request):
"""
Render the dev version of a plugin.

@query_param request: Django request object
@query_param plugin_url: URL of the plugin

This endpoint pulls the plugin from the plugin_url and updates the plugin's
custome element name to target.
"""
plugin_url = request.GET.get("plugin_url")
target = request.GET.get("target")

custom_component_name = target
costum_component_class_name = (target[0].upper() + target[1:]).replace("-", "")

content = requests.get(plugin_url)
plugin = content.text

# replace all instances of the plugin's custom element name with the target
plugin = plugin.replace("sefaria-plugin", custom_component_name)
plugin = plugin.replace("SefariaPlugin", costum_component_class_name)

return HttpResponse(plugin, content_type="text/javascript")


def get_user_plugin_secret(request, plugin_id):
"""
Get the secret for a user's plugin.

@query_param request: Django request object
@query_param plugin_id: ID of the plugin
"""

user = request.user
plugin = Plugin.objects.get(id=plugin_id)

# encrypt the user id using the plugin secret
plugin_secret = plugin.secret
user_id = str(user.id)

# encrypt the user id
encrypted_user_id = encrypt_str_with_key(user_id, plugin_secret)

json_response = {
"encrypted_user_id": encrypted_user_id.decode('utf-8')
}

return jsonResponse(json_response)


def all_plugins(request):
"""
Get all plugins.

@query_param request: Django request object
"""

plugins = Plugin.objects.all()

json_response = {
"plugins": [plugin.to_dict() for plugin in plugins]
}

return jsonResponse(json_response)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ undecorated==0.3.0
unicodecsv==0.14.1
unidecode==1.1.1
user-agents==2.2.0
cryptography==44.0.0
pytest-django==4.9.*

#opentelemetry-distro
Expand Down
1 change: 1 addition & 0 deletions sefaria/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
'webpack_loader',
'django_user_agents',
'rest_framework',
'plugins.apps.PluginsAppConfig',
#'easy_timezones'
# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
Expand Down
Loading
Loading