mirror of
https://github.com/decompme/decomp.me.git
synced 2026-02-21 13:59:25 -06:00
use django User model
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Profile, Assembly, Compilation, Asm, Scratch
|
||||
from .github import GitHubUser
|
||||
|
||||
admin.site.register(Profile)
|
||||
admin.site.register(GitHubUser)
|
||||
admin.site.register(Asm)
|
||||
admin.site.register(Assembly)
|
||||
admin.site.register(Compilation)
|
||||
|
||||
125
backend/coreapp/github.py
Normal file
125
backend/coreapp/github.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from typing import Optional
|
||||
from github3api import GitHubAPI
|
||||
import requests
|
||||
|
||||
from .models import Profile
|
||||
|
||||
API_CACHE_TIMEOUT = 60 * 60 # 1 hour
|
||||
|
||||
class BadOAuthCode(APIException):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
default_detail = "Invalid or expired GitHub OAuth verification code."
|
||||
default_code = "bad_oauth_code"
|
||||
|
||||
class MissingOAuthScope(APIException):
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
default_code = "bad_oauth_scope"
|
||||
|
||||
def __init__(self, scope: str):
|
||||
super(f"The GitHub OAuth verification code was valid but lacks required scope '{scope}'.")
|
||||
|
||||
class GitHubUserDetails:
|
||||
def __init__(self, json):
|
||||
self.id: int = json["id"]
|
||||
self.username: str = json["login"]
|
||||
self.email: Optional[str] = json["email"]
|
||||
self.avatar_url: str = json["avatar_url"]
|
||||
self.name: str = json["name"]
|
||||
self.html_url: str = json["html_url"]
|
||||
|
||||
class GitHubUser(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
primary_key=True,
|
||||
related_name="github",
|
||||
)
|
||||
github_id = models.PositiveIntegerField(unique=True, editable=False)
|
||||
access_token = models.CharField(max_length=100)
|
||||
|
||||
def details(self, use_cache: bool = True) -> GitHubUserDetails:
|
||||
cache_key = f"github_user_details:{self.github_id}"
|
||||
cached = cache.get(cache_key) if use_cache else None
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
data = GitHubAPI(bearer_token=self.access_token).get(f"/user/{self.github_id}")
|
||||
details = GitHubUserDetails(data)
|
||||
|
||||
cache.set(cache_key, details, API_CACHE_TIMEOUT)
|
||||
return details
|
||||
|
||||
def github_api_url(self):
|
||||
return f"https://api.github.com/user/{self.github_id}"
|
||||
|
||||
def __str__(self):
|
||||
return "@" + self.details().username
|
||||
|
||||
@staticmethod
|
||||
def login(request: HttpRequest, oauth_code: str) -> "GitHubUser":
|
||||
response = requests.post(
|
||||
"https://github.com/login/oauth/access_token",
|
||||
json={
|
||||
"client_id": settings.GITHUB_CLIENT_ID,
|
||||
"client_secret": settings.GITHUB_CLIENT_SECRET,
|
||||
"code": oauth_code,
|
||||
},
|
||||
headers={ "Accept": "application/json" },
|
||||
).json()
|
||||
|
||||
error: Optional[str] = response.get("error")
|
||||
if error == "bad_verification_code":
|
||||
raise BadOAuthCode()
|
||||
elif error:
|
||||
raise Exception(f"GitHub login sent unknown error '{error}'")
|
||||
|
||||
scopes = str(response["scope"]).split(",")
|
||||
if not "public_repo" in scopes:
|
||||
raise MissingOAuthScope("public_repo")
|
||||
|
||||
access_token: str = response["access_token"]
|
||||
|
||||
details = GitHubUserDetails(GitHubAPI(bearer_token=access_token).get("/user"))
|
||||
|
||||
try:
|
||||
gh_user = GitHubUser.objects.get(github_id=details.id)
|
||||
except GitHubUser.DoesNotExist:
|
||||
gh_user = GitHubUser()
|
||||
user = request.user
|
||||
new_user = request.user.is_anonymous
|
||||
|
||||
try:
|
||||
request.user.github
|
||||
new_user = True
|
||||
except User.github.RelatedObjectDoesNotExist:
|
||||
# request.user lacks a github link, so we can attach gh_user to it
|
||||
pass
|
||||
|
||||
if new_user:
|
||||
user = User.objects.create_user(
|
||||
username=details.username,
|
||||
email=details.email,
|
||||
password=None,
|
||||
)
|
||||
user.profile = request.user.profile
|
||||
user.save()
|
||||
|
||||
gh_user.user = user
|
||||
gh_user.github_id = details.id
|
||||
|
||||
gh_user.access_token = access_token
|
||||
gh_user.save()
|
||||
|
||||
login(request, gh_user.user)
|
||||
|
||||
return gh_user
|
||||
39
backend/coreapp/middleware.py
Normal file
39
backend/coreapp/middleware.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
|
||||
from .models import User, Profile
|
||||
import logging
|
||||
|
||||
def set_user_profile(get_response):
|
||||
"""
|
||||
Makes sure that `request.user.profile` is always available, even for anonymous users.
|
||||
"""
|
||||
|
||||
def middleware(request: HttpRequest):
|
||||
profile = None
|
||||
|
||||
if not request.user.is_anonymous:
|
||||
try:
|
||||
profile = request.user.profile
|
||||
except User.profile.RelatedObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
if not profile:
|
||||
try:
|
||||
profile = Profile.objects.get(id=request.session.get("anonymous_profile_id"))
|
||||
except Profile.DoesNotExist:
|
||||
profile = Profile()
|
||||
|
||||
if not request.user.is_anonymous:
|
||||
profile.user = request.user
|
||||
|
||||
profile.save()
|
||||
request.session["anonymous_profile_id"] = profile.id
|
||||
logging.debug(f"New anonymous profile: {profile}")
|
||||
|
||||
profile.last_request_date = now()
|
||||
profile.save()
|
||||
|
||||
return get_response(request)
|
||||
|
||||
return middleware
|
||||
67
backend/coreapp/migrations/0004_auto_20210831_1811.py
Normal file
67
backend/coreapp/migrations/0004_auto_20210831_1811.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Generated by Django 3.2.6 on 2021-08-31 09:11
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('coreapp', '0003_profile_github'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GitHubUser',
|
||||
fields=[
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='github', serialize=False, to='auth.user')),
|
||||
('github_id', models.PositiveIntegerField(editable=False, unique=True)),
|
||||
('access_token', models.CharField(max_length=100)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='avatar_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='github_access_token',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='github_load_time',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='github_user_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='username',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='creation_date',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='last_request_date',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='user',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -1,10 +1,6 @@
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from django.db import models
|
||||
|
||||
from github3api import GitHubAPI
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
def gen_scratch_id() -> str:
|
||||
ret = get_random_string(length=5)
|
||||
@@ -14,65 +10,24 @@ def gen_scratch_id() -> str:
|
||||
|
||||
return ret
|
||||
|
||||
class GitHubUserChangeException(Exception):
|
||||
"""
|
||||
The github_access_token is for a different GitHub user than is connected to this Profile.
|
||||
Assign it to a new Profile instead.
|
||||
"""
|
||||
|
||||
class GitHubUserHasExistingProfileException(Exception):
|
||||
"""
|
||||
The github_access_token is for a GitHub user who already has a Profile; use that instead.
|
||||
"""
|
||||
|
||||
def __init__(self, profile: "Profile"):
|
||||
super()
|
||||
self.profile = profile
|
||||
|
||||
class Profile(models.Model):
|
||||
github_access_token = models.TextField(blank=True)
|
||||
github_load_time = models.DateTimeField(auto_now_add=True)
|
||||
creation_date = models.DateTimeField(auto_now_add=True)
|
||||
last_request_date = models.DateTimeField(auto_now_add=True)
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="profile",
|
||||
null=True,
|
||||
)
|
||||
|
||||
# Fields taken from GitHub - don't modify these outside of load_fields_from_github
|
||||
github_user_id = models.TextField(null=True)
|
||||
username = models.TextField(null=True)
|
||||
name = models.TextField(null=True)
|
||||
avatar_url = models.TextField(null=True)
|
||||
def is_anonymous(self):
|
||||
return self.user is None
|
||||
|
||||
def load_fields_from_github(self, always=False) -> bool:
|
||||
if not self.github_access_token:
|
||||
return False
|
||||
|
||||
time = self.github_load_time
|
||||
delta_time = now() - time
|
||||
|
||||
if always or delta_time > timedelta(days=1):
|
||||
gh = GitHubAPI(bearer_token=self.github_access_token)
|
||||
data = gh.get("/user")
|
||||
|
||||
existing_profile = Profile.objects.filter(github_user_id=data["id"]).first()
|
||||
if existing_profile and existing_profile.id != self.id:
|
||||
raise GitHubUserHasExistingProfileException(existing_profile)
|
||||
|
||||
github_user_id = str(data["id"])
|
||||
|
||||
if self.github_user_id is not None and github_user_id != self.github_user_id:
|
||||
raise GitHubUserChangeException()
|
||||
|
||||
self.github_load_time = now()
|
||||
self.github_user_id = github_user_id
|
||||
self.username = data["login"]
|
||||
self.name = data["name"]
|
||||
self.avatar_url = data["avatar_url"]
|
||||
|
||||
logging.debug(f"Loaded fields from GitHub user @{self.username} into profile {self.id}")
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return self.username if self.username else str(self.id)
|
||||
if self.user:
|
||||
return self.user.username
|
||||
else:
|
||||
return str(self.id)
|
||||
|
||||
class Asm(models.Model):
|
||||
hash = models.CharField(max_length=64, primary_key=True)
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
from coreapp.models import Profile, Scratch
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import serializers
|
||||
from rest_framework.request import Request
|
||||
from typing import Union, Optional
|
||||
|
||||
class ProfileSerializer(serializers.ModelSerializer[Profile]):
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ["id", "username", "name", "avatar_url"]
|
||||
from .models import Profile, Scratch
|
||||
from .github import GitHubUser
|
||||
|
||||
def serialize_user(request: Request, user: Union[User, Profile]):
|
||||
if isinstance(user, Profile):
|
||||
user: User = user.user
|
||||
|
||||
github: Optional[GitHubUser] = None
|
||||
try:
|
||||
github = user.github
|
||||
except User.github.RelatedObjectDoesNotExist:
|
||||
pass
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if user.is_anonymous:
|
||||
return {
|
||||
"is_you": user == request.user,
|
||||
"is_anonymous": True,
|
||||
"id": user.id,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"is_you": user == request.user,
|
||||
"is_anonymous": False,
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"name": github.details().name if github else user.username,
|
||||
"avatar_url": github.details().avatar_url if github else None,
|
||||
"github_api_url": github.github_api_url() if github else None,
|
||||
"github_html_url": github.details().html_url if github else None,
|
||||
}
|
||||
|
||||
class UserField(serializers.RelatedField):
|
||||
def to_representation(self, user: Union[User, Profile]):
|
||||
return serialize_user(self.context["request"], user)
|
||||
|
||||
class ScratchCreateSerializer(serializers.Serializer[None]):
|
||||
compiler = serializers.CharField(allow_blank=True, required=False)
|
||||
@@ -16,15 +50,6 @@ class ScratchCreateSerializer(serializers.Serializer[None]):
|
||||
# TODO: `context` should be renamed; it conflicts with Field.context
|
||||
context = serializers.CharField(allow_blank=True) # type: ignore
|
||||
|
||||
|
||||
class ScratchMetadataSerializer(serializers.ModelSerializer[Scratch]):
|
||||
owner = ProfileSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Scratch
|
||||
fields = ["slug", "owner"]
|
||||
|
||||
|
||||
class ScratchSerializer(serializers.ModelSerializer[Scratch]):
|
||||
class Meta:
|
||||
model = Scratch
|
||||
@@ -38,12 +63,15 @@ class ScratchSerializer(serializers.ModelSerializer[Scratch]):
|
||||
|
||||
return scratch
|
||||
|
||||
|
||||
# XXX: ideally we would just use ScratchSerializer, but adding owner and parent breaks creation
|
||||
class ScratchWithMetadataSerializer(serializers.ModelSerializer[Scratch]):
|
||||
owner = ProfileSerializer()
|
||||
parent = ScratchMetadataSerializer()
|
||||
owner = UserField(read_only=True)
|
||||
parent = serializers.HyperlinkedRelatedField(
|
||||
read_only=True,
|
||||
view_name="scratch-detail",
|
||||
lookup_field="slug",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Scratch
|
||||
fields = ["slug", "compiler", "cc_opts", "target_assembly", "source_code", "context", "owner", "parent"]
|
||||
fields = ["slug", "compiler", "cc_opts", "source_code", "context", "owner", "parent"]
|
||||
|
||||
@@ -4,10 +4,10 @@ from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('compilers', views.compilers, name='compilers'),
|
||||
path('scratch', views.scratch, name='scratch'),
|
||||
path('scratch/<slug:slug>', views.scratch, name='scratch'),
|
||||
path('scratch', views.scratch, name='scratch'), # TODO make this into its own view
|
||||
path('scratch/<slug:slug>', views.scratch, name='scratch-detail'),
|
||||
path('scratch/<slug:slug>/compile', views.compile, name='compile_scratch'),
|
||||
path('scratch/<slug:slug>/fork', views.fork, name='fork_scratch'),
|
||||
path('user', views.user_current, name="user_current"),
|
||||
path('user/<slug:username>', views.user, name="user"),
|
||||
path('user', views.CurrentUser.as_view()),
|
||||
path('users/<slug:username>', views.user, name="user-detail"),
|
||||
]
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from typing import Optional
|
||||
from coreapp.asm_diff_wrapper import AsmDifferWrapper
|
||||
from coreapp.m2c_wrapper import M2CWrapper
|
||||
from coreapp.compiler_wrapper import CompilerWrapper
|
||||
from coreapp.serializers import ScratchCreateSerializer, ScratchSerializer, ScratchWithMetadataSerializer, ProfileSerializer
|
||||
from coreapp.serializers import ScratchCreateSerializer, ScratchSerializer, ScratchWithMetadataSerializer, serialize_user
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.request import Request
|
||||
import logging
|
||||
|
||||
import hashlib
|
||||
import requests
|
||||
from github3api import GitHubAPI
|
||||
|
||||
from .models import Profile, Asm, Scratch, GitHubUserChangeException, GitHubUserHasExistingProfileException
|
||||
from .models import User, Profile, Asm, Scratch
|
||||
from .github import GitHubUser
|
||||
from coreapp.models import gen_scratch_id
|
||||
|
||||
|
||||
@@ -46,12 +49,8 @@ def scratch(request, slug=None):
|
||||
|
||||
if not db_scratch.owner:
|
||||
# Give ownership to this profile
|
||||
profile = Profile.objects.filter(id=request.session.get("profile", None)).first()
|
||||
|
||||
if not profile:
|
||||
profile = Profile()
|
||||
profile.save()
|
||||
request.session["profile"] = profile.id
|
||||
print(request.user)
|
||||
profile = request.user.profile
|
||||
|
||||
logging.debug(f"Granting ownership of scratch {db_scratch} to {profile}")
|
||||
|
||||
@@ -59,8 +58,7 @@ def scratch(request, slug=None):
|
||||
db_scratch.save()
|
||||
|
||||
return Response({
|
||||
"scratch": ScratchWithMetadataSerializer(db_scratch).data,
|
||||
"is_yours": db_scratch.owner.id == request.session.get("profile", None),
|
||||
"scratch": ScratchWithMetadataSerializer(db_scratch, context={ "request": request }).data,
|
||||
})
|
||||
|
||||
elif request.method == "POST":
|
||||
@@ -140,7 +138,7 @@ def scratch(request, slug=None):
|
||||
|
||||
db_scratch = get_object_or_404(Scratch, slug=slug)
|
||||
|
||||
if db_scratch.owner and db_scratch.owner.id != request.session.get("profile", None):
|
||||
if db_scratch.owner and db_scratch.owner != request.user.profile:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# TODO validate
|
||||
@@ -215,86 +213,36 @@ def fork(request, slug):
|
||||
parent=parent_scratch,
|
||||
)
|
||||
new_scratch.save()
|
||||
return Response(ScratchSerializer(new_scratch).data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
ScratchSerializer(new_scratch, context={ "request": request }).data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
def user_current(request, slug=None):
|
||||
class CurrentUser(APIView):
|
||||
"""
|
||||
Get the logged-in user, or sign in with GitHub
|
||||
View to access the current user profile.
|
||||
"""
|
||||
|
||||
profile = Profile.objects.filter(id=request.session.get("profile", None)).first()
|
||||
def get(self, request: Request):
|
||||
return Response({
|
||||
"user": serialize_user(request, request.user),
|
||||
})
|
||||
|
||||
if not profile:
|
||||
profile = Profile()
|
||||
profile.save()
|
||||
request.session["profile"] = profile.id
|
||||
def post(self, request: Request):
|
||||
"""
|
||||
Login if the 'code' parameter is provided. Log out otherwise.
|
||||
"""
|
||||
|
||||
# sign in
|
||||
if request.method == "POST":
|
||||
if not settings.GITHUB_CLIENT_ID or not settings.GITHUB_CLIENT_SECRET:
|
||||
return Response({
|
||||
"error": "GitHub sign-in not configured"
|
||||
}, status=status.HTTP_501_NOT_IMPLEMENTED)
|
||||
|
||||
required_params = ["code"]
|
||||
|
||||
for param in required_params:
|
||||
if param not in request.data:
|
||||
return Response({"error": f"Missing parameter: {param}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
logging.debug("Attempting GitHub oauth login")
|
||||
|
||||
response = requests.post(
|
||||
"https://github.com/login/oauth/access_token",
|
||||
json={
|
||||
"client_id": settings.GITHUB_CLIENT_ID,
|
||||
"client_secret": settings.GITHUB_CLIENT_SECRET,
|
||||
"code": request.data["code"],
|
||||
},
|
||||
headers={ 'Accept': 'application/json' },
|
||||
).json()
|
||||
|
||||
error = response.get("error")
|
||||
if error == None:
|
||||
access_token = response["access_token"]
|
||||
|
||||
profile.github_access_token = access_token
|
||||
|
||||
try:
|
||||
assert profile.load_fields_from_github(always=True)
|
||||
logging.debug("Connected existing profile to new GitHub user")
|
||||
except GitHubUserChangeException:
|
||||
# The token was for a different user than the one associated with the current profile,
|
||||
# so make a new profile for this one.
|
||||
profile = Profile()
|
||||
profile.github_access_token = access_token
|
||||
assert profile.load_fields_from_github(always=True)
|
||||
logging.debug("Connected new profile to new GitHub user")
|
||||
except GitHubUserHasExistingProfileException as e:
|
||||
profile = e.profile
|
||||
|
||||
# This isn't strictly necessary, but we might as well use the renewed access token.
|
||||
profile.github_access_token = access_token
|
||||
assert profile.load_fields_from_github(always=True)
|
||||
logging.debug("Swapped to existing profile for existing GitHub user")
|
||||
|
||||
profile.save()
|
||||
request.session["profile"] = profile.id
|
||||
elif error == "bad_verification_code":
|
||||
return Response({
|
||||
"error": "Invalid or expired GitHub OAuth verification code",
|
||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
if "code" in request.data:
|
||||
GitHubUser.login(request, request.data["code"])
|
||||
else:
|
||||
raise Exception(f"Unknown GitHub login error: {error} - {response['error_description']}")
|
||||
logout(request)
|
||||
|
||||
else:
|
||||
profile.load_fields_from_github()
|
||||
profile.save()
|
||||
profile = Profile()
|
||||
profile.save()
|
||||
request.user.profile = profile
|
||||
|
||||
return Response({
|
||||
"user": ProfileSerializer(profile).data,
|
||||
})
|
||||
return self.get(request)
|
||||
|
||||
@api_view(["GET"])
|
||||
def user(request, username):
|
||||
@@ -302,5 +250,6 @@ def user(request, username):
|
||||
Gets a user's basic data
|
||||
"""
|
||||
|
||||
user = get_object_or_404(Profile, username=username)
|
||||
return Response(ProfileSerializer(user).data)
|
||||
return Response({
|
||||
"user": serialize_user(request, get_object_or_404(User, username=username)),
|
||||
})
|
||||
|
||||
@@ -19,6 +19,8 @@ env = environ.Env(
|
||||
STATIC_URL=(str, '/static/'),
|
||||
STATIC_ROOT=(str, BASE_DIR / 'static'),
|
||||
USE_SANDBOX_JAIL=(bool, True),
|
||||
GITHUB_CLIENT_ID=(str, ""),
|
||||
GITHUB_CLIENT_SECRET=(str, ""),
|
||||
)
|
||||
|
||||
env_file = BASE_DIR / ".." / ".env"
|
||||
@@ -49,8 +51,9 @@ MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
#'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'coreapp.middleware.set_user_profile',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { h } from "preact"
|
||||
import { MarkGithubIcon, ChevronRightIcon } from "@primer/octicons-react"
|
||||
|
||||
export default function Unimplemented({ issue }: { issue: string }) {
|
||||
return <div style={{ color: "#ffffff88" }}>
|
||||
<p style={{ paddingTop: "0.5em", paddingBottom: "0.25em" }}>
|
||||
There's meant to be more here, but it's not implemented yet.
|
||||
</p>
|
||||
<span style={{ padding: "0.5em", paddingLeft: "0" }}>
|
||||
Think you could help?
|
||||
</span>
|
||||
|
||||
{issue ? <a class="button" href={`https://github.com/ethteck/decomp.me/issues/${issue}`}>
|
||||
<MarkGithubIcon /> See the GitHub issue <ChevronRightIcon />
|
||||
</a> : <a class="button" href="https://github.com/ethteck/decomp.me">
|
||||
<MarkGithubIcon /> Contribute to decomp.me on GitHub
|
||||
</a>}
|
||||
</div>
|
||||
}
|
||||
@@ -43,7 +43,11 @@ export class ResponseError extends Error {
|
||||
}
|
||||
|
||||
export async function get(url: string, cache = false) {
|
||||
const response = await fetch(API_BASE + url, {
|
||||
if (url.startsWith("/")) {
|
||||
url = API_BASE + url
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...commonOpts,
|
||||
cache: cache ? "default" : "reload",
|
||||
})
|
||||
@@ -56,11 +60,15 @@ export async function get(url: string, cache = false) {
|
||||
}
|
||||
|
||||
export async function post(url: string, json: Json) {
|
||||
if (url.startsWith("/")) {
|
||||
url = API_BASE + url
|
||||
}
|
||||
|
||||
const body: string = JSON.stringify(json)
|
||||
|
||||
console.info("POST", url, JSON.parse(body))
|
||||
|
||||
const response = await fetch(API_BASE + url, {
|
||||
const response = await fetch(url, {
|
||||
...commonOpts,
|
||||
method: "POST",
|
||||
body,
|
||||
@@ -78,11 +86,15 @@ export async function post(url: string, json: Json) {
|
||||
}
|
||||
|
||||
export async function patch(url: string, json: Json) {
|
||||
if (url.startsWith("/")) {
|
||||
url = API_BASE + url
|
||||
}
|
||||
|
||||
const body = JSON.stringify(json)
|
||||
|
||||
console.info("PATCH", url, JSON.parse(body))
|
||||
|
||||
const response = await fetch(API_BASE + url, {
|
||||
const response = await fetch(url, {
|
||||
...commonOpts,
|
||||
method: "PATCH",
|
||||
body,
|
||||
@@ -104,11 +116,22 @@ export async function patch(url: string, json: Json) {
|
||||
}
|
||||
|
||||
export interface AnonymousUser {
|
||||
id: number,
|
||||
is_you: boolean,
|
||||
is_anonymous: true,
|
||||
}
|
||||
|
||||
export interface FullUser extends AnonymousUser {
|
||||
export interface User {
|
||||
is_you: boolean,
|
||||
is_anonymous: false,
|
||||
|
||||
id: number,
|
||||
username: string,
|
||||
name: string,
|
||||
avatar_url: string,
|
||||
avatar_url: string | null,
|
||||
github_api_url: string | null,
|
||||
github_html_url: string | null,
|
||||
}
|
||||
|
||||
export function isAnonUser(user: User | AnonymousUser): user is AnonymousUser {
|
||||
return user.is_anonymous
|
||||
}
|
||||
|
||||
@@ -16,14 +16,15 @@ import { useLocalStorage, useSize } from "../hooks"
|
||||
import UserLink from "../user/UserLink"
|
||||
|
||||
import styles from "./Scratch.module.css"
|
||||
import useSWR from "swr"
|
||||
|
||||
function nameScratch({ owner }: { owner: api.FullUser }, isYours = false): string {
|
||||
if (isYours) {
|
||||
return "Your scratch"
|
||||
function nameScratch({ owner }: { owner: api.User }): string {
|
||||
if (owner?.is_you) {
|
||||
return "your scratch"
|
||||
} else if (owner?.name) {
|
||||
return `${owner?.name}'s scratch`
|
||||
} else {
|
||||
return "Unknown scratch"
|
||||
return "unknown scratch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +35,12 @@ export default function Scratch() {
|
||||
const [currentRequest, setCurrentRequest] = useState("loading")
|
||||
const [showWarnings, setShowWarnings] = useLocalStorage("logShowWarnings", false) // TODO: pass as compile flag '-wall'?
|
||||
const [compiler, setCompiler] = useState<CompilerOptsT>(null)
|
||||
const isCompilerChosen = !(compiler && compiler.compiler === "")
|
||||
const isCompilerChosen = compiler?.compiler !== ""
|
||||
const [cCode, setCCode] = useState(null)
|
||||
const [cContext, setCContext] = useState(null)
|
||||
const [diff, setDiff] = useState(null)
|
||||
const [log, setLog] = useState(null)
|
||||
const [isYours, setIsYours] = useState(false)
|
||||
const [owner, setOwner] = useState<api.FullUser>(undefined) // XXX: type should really be AnonymousUser
|
||||
const [owner, setOwner] = useState<api.User>(undefined)
|
||||
const [parentScratch, setParentScratch] = useState(null)
|
||||
const [savedCompiler, setSavedCompiler] = useState(compiler)
|
||||
const [savedCCode, setSavedCCode] = useState(cCode)
|
||||
@@ -52,12 +52,12 @@ export default function Scratch() {
|
||||
const hasUnsavedChanges = savedCCode !== cCode || savedCContext !== cContext || JSON.stringify(savedCompiler) !== JSON.stringify(compiler)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = nameScratch({ owner }, isYours)
|
||||
document.title = nameScratch({ owner })
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
document.title += " (unsaved changes)"
|
||||
}
|
||||
}, [isYours, owner, hasUnsavedChanges])
|
||||
}, [owner, hasUnsavedChanges])
|
||||
|
||||
const compile = async () => {
|
||||
if (compiler === null || cCode === null || cContext === null) {
|
||||
@@ -90,7 +90,7 @@ export default function Scratch() {
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!isYours) {
|
||||
if (!owner?.is_you) {
|
||||
// Implicitly fork
|
||||
return fork()
|
||||
}
|
||||
@@ -127,9 +127,8 @@ export default function Scratch() {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { scratch, is_yours } = await api.get(`/scratch/${slug}`)
|
||||
const { scratch } = await api.get(`/scratch/${slug}`)
|
||||
|
||||
setIsYours(is_yours)
|
||||
setOwner(scratch.owner)
|
||||
setParentScratch(scratch.parent)
|
||||
setCompiler({
|
||||
@@ -200,18 +199,21 @@ export default function Scratch() {
|
||||
<div class={styles.sectionHeader}>
|
||||
Source
|
||||
<span class={styles.grow} />
|
||||
<button class={isCompiling ? styles.compiling : ""} onClick={compile} disabled={!isCompilerChosen}>
|
||||
<SyncIcon size={16} /> Compile
|
||||
</button>
|
||||
{isYours && <button onClick={save}>
|
||||
<UploadIcon size={16} /> Save
|
||||
{hasUnsavedChanges && "*"}
|
||||
</button>}
|
||||
<button onClick={fork}>
|
||||
<RepoForkedIcon size={16} /> Fork
|
||||
</button>
|
||||
|
||||
<CompilerButton disabled={!isCompilerChosen} value={compiler} onChange={setCompiler} />
|
||||
{isCompilerChosen && <>
|
||||
<button class={isCompiling ? styles.compiling : ""} onClick={compile} disabled={!isCompilerChosen}>
|
||||
<SyncIcon size={16} /> Compile
|
||||
</button>
|
||||
{owner?.is_you && <button onClick={save}>
|
||||
<UploadIcon size={16} /> Save
|
||||
{hasUnsavedChanges && "*"}
|
||||
</button>}
|
||||
<button onClick={fork}>
|
||||
<RepoForkedIcon size={16} /> Fork
|
||||
</button>
|
||||
|
||||
<CompilerButton disabled={!isCompilerChosen} value={compiler} onChange={setCompiler} />
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<div class={styles.metadata}>
|
||||
@@ -222,9 +224,7 @@ export default function Scratch() {
|
||||
|
||||
{parentScratch && <div>
|
||||
Fork of
|
||||
<Link to={`/scratch/${parentScratch.slug}`}>
|
||||
{nameScratch(parentScratch)}
|
||||
</Link>
|
||||
<ScratchLink apiUrl={parentScratch} />
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -326,3 +326,16 @@ function ChooseACompiler({ onCommit }) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ScratchLink({ apiUrl }: { apiUrl: string }) {
|
||||
const { data } = useSWR(apiUrl, api.get)
|
||||
const scratch = data?.scratch
|
||||
|
||||
if (!scratch) {
|
||||
return <span />
|
||||
}
|
||||
|
||||
return <Link to={`/scratch/${scratch.slug}`}>
|
||||
{nameScratch(scratch)}
|
||||
</Link>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "preact"
|
||||
import { MarkGithubIcon } from "@primer/octicons-react"
|
||||
import { useSWRConfig } from "swr"
|
||||
const { GITHUB_CLIENT_ID } = import.meta.env
|
||||
|
||||
// https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps
|
||||
@@ -8,8 +9,25 @@ const SCOPES = ["public_repo"]
|
||||
const LOGIN_URL = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&scope=${SCOPES.join("%20")}`
|
||||
|
||||
export default function GitHubLoginButton() {
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const showLoginWindow = (evt: MouseEvent) => {
|
||||
const win = window.open(LOGIN_URL, "Sign in with GitHub", "resizable,scrollbars,status")
|
||||
evt.preventDefault()
|
||||
|
||||
win.addEventListener("close", () => {
|
||||
mutate("/user")
|
||||
})
|
||||
}
|
||||
|
||||
if (GITHUB_CLIENT_ID) {
|
||||
return <a class="button" href={LOGIN_URL}>
|
||||
return <a
|
||||
class="button"
|
||||
href={LOGIN_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={showLoginWindow}
|
||||
>
|
||||
<MarkGithubIcon size={16} /> Sign in with GitHub
|
||||
</a>
|
||||
} else {
|
||||
|
||||
@@ -12,7 +12,7 @@ import styles from "./LoginPage.module.css"
|
||||
export default function LoginPage() {
|
||||
const { searchParams } = new URL(document.location.href)
|
||||
const code = searchParams.get("code")
|
||||
const next = searchParams.get("next") || "/"
|
||||
const next = searchParams.get("next")
|
||||
|
||||
const history = useHistory()
|
||||
const [error, setError] = useState(null)
|
||||
@@ -21,9 +21,13 @@ export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
setError(null)
|
||||
api.post("/user", { code }).then(({ user }: { user: api.FullUser }) => {
|
||||
mutate("/user", { user })
|
||||
history.replace(next)
|
||||
api.post("/user", { code }).then(({ user }: { user: api.User }) => {
|
||||
if (next) {
|
||||
mutate("/user", { user })
|
||||
history.replace(next)
|
||||
} else {
|
||||
window.close()
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
setError(error)
|
||||
|
||||
@@ -7,11 +7,11 @@ import GitHubLoginButton from "./GitHubLoginButton"
|
||||
import UserLink from "./UserLink"
|
||||
|
||||
export type Props = {
|
||||
onChange?: (user: api.AnonymousUser) => void,
|
||||
onChange?: (user: api.AnonymousUser | api.User) => void,
|
||||
}
|
||||
|
||||
export default function LoginState({ onChange }: Props) {
|
||||
const { data, error } = useSWR("/user", api.get)
|
||||
const { data, error } = useSWR<{ user: api.AnonymousUser | api.User }>("/user", api.get)
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && data?.user) {
|
||||
@@ -24,8 +24,8 @@ export default function LoginState({ onChange }: Props) {
|
||||
} else if (!data?.user) {
|
||||
// Loading...
|
||||
return <div />
|
||||
} else if (data?.user?.username) {
|
||||
return <UserLink username={data.user.username} />
|
||||
} else if (data?.user && !api.isAnonUser(data.user) && data.user.username) {
|
||||
return <UserLink username={data.user.username} hideYou={true} />
|
||||
} else {
|
||||
return <GitHubLoginButton />
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import styles from "./UserLink.module.css"
|
||||
|
||||
export type Props = {
|
||||
username: string,
|
||||
hideYou?: boolean,
|
||||
}
|
||||
|
||||
export default function UserCard({ username }: Props) {
|
||||
const { data: user, error } = useSWR<api.FullUser>(`/user/${username}`, api.get)
|
||||
export default function UserCard({ username, hideYou }: Props) {
|
||||
const { data, error } = useSWR<{ user: api.User }>(`/users/${username}`, api.get)
|
||||
const user = data?.user
|
||||
|
||||
if (user) {
|
||||
return <Link
|
||||
@@ -19,8 +21,8 @@ export default function UserCard({ username }: Props) {
|
||||
title={`@${user.username}`}
|
||||
className={styles.user}
|
||||
>
|
||||
<img class={styles.avatar} src={user.avatar_url} alt="User avatar" />
|
||||
<span>{user.name}</span>
|
||||
{user.avatar_url && <img class={styles.avatar} src={user.avatar_url} alt="User avatar" />}
|
||||
<span>{user.name} {!hideYou && user.is_you && <i>(you)</i>}</span>
|
||||
</Link>
|
||||
} else if (error) {
|
||||
// TODO: handle error
|
||||
@@ -28,7 +30,7 @@ export default function UserCard({ username }: Props) {
|
||||
} else {
|
||||
// TODO: loading state
|
||||
return <div className={styles.user}>
|
||||
<span>@{username}</span>
|
||||
<span>{username}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,15 @@
|
||||
}
|
||||
|
||||
.username {
|
||||
opacity: 0.5;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
color: #888;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
border-bottom: 1px dotted;
|
||||
.username a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,65 @@
|
||||
import { h, Fragment } from "preact"
|
||||
import { useState, useEffect } from "preact/hooks"
|
||||
import { useEffect } from "preact/hooks"
|
||||
import { useParams } from "react-router-dom"
|
||||
import useSWR from "swr"
|
||||
import useSWR, { useSWRConfig } from "swr"
|
||||
import { MarkGithubIcon } from "@primer/octicons-react"
|
||||
|
||||
import * as api from "../api"
|
||||
import Nav from "../Nav"
|
||||
import Unimplemented from "../Unimplemented"
|
||||
|
||||
import styles from "./UserPage.module.css"
|
||||
|
||||
export default function UserPage() {
|
||||
const { mutate } = useSWRConfig()
|
||||
const { username } = useParams<{ username: string }>()
|
||||
const { data: user, error } = useSWR<api.FullUser>(`/user/${username}`, api.get)
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<api.AnonymousUser>(null)
|
||||
const isCurrentUser = currentUser?.id === user?.id
|
||||
const { data, error } = useSWR<{ user: api.User }>(`/users/${username}`, api.get)
|
||||
const user = data?.user
|
||||
|
||||
useEffect(() => {
|
||||
document.title = user?.name ? `${user.name} on decomp.me` : `${username} on decomp.me`
|
||||
}, [username, user?.name])
|
||||
|
||||
const signOut = () => {
|
||||
api.post("/user", {})
|
||||
.then(({ user }: { user: api.AnonymousUser }) => {
|
||||
mutate("/user", { user })
|
||||
mutate(`/users/${username}`)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <>
|
||||
<Nav onUserChange={setCurrentUser} />
|
||||
<Nav />
|
||||
<main class={styles.pageContainer}>
|
||||
<section class={styles.userRow}>
|
||||
<img
|
||||
{user.avatar_url && <img
|
||||
class={styles.avatar}
|
||||
src={user.avatar_url}
|
||||
alt="User avatar"
|
||||
/>
|
||||
/>}
|
||||
<h1 class={styles.name}>
|
||||
<div>{user.name} </div>
|
||||
<a href={`https://github.com/${user.username}`} class={styles.username}>
|
||||
{user.username} {isCurrentUser && "(you)"}
|
||||
</a>
|
||||
<div>{user.name} {user.is_you && <i>(you)</i>}</div>
|
||||
<div class={styles.username}>
|
||||
@{user.username}
|
||||
|
||||
{user.github_html_url && <a href={user.github_html_url}>
|
||||
<MarkGithubIcon size={24} />
|
||||
</a>}
|
||||
</div>
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{/*<section>
|
||||
<h2>Scratches</h2>
|
||||
<ScratchList user={user} />
|
||||
</section>
|
||||
</section>*/}
|
||||
|
||||
{user.is_you && <section>
|
||||
<button class="red" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</section>}
|
||||
</main>
|
||||
</>
|
||||
} else if (error) {
|
||||
@@ -63,11 +81,9 @@ export default function UserPage() {
|
||||
}
|
||||
}
|
||||
|
||||
export function ScratchList({ user }: { user: api.FullUser }) {
|
||||
// TODO: needs backend
|
||||
void user
|
||||
|
||||
/*
|
||||
// TODO: needs backend
|
||||
/*
|
||||
export function ScratchList({ user }: { user: api.User }) {
|
||||
const { data: scratches, error } = useSWR<api.Scratch[]>(`/user/${user.username}/scratches`, api.get)
|
||||
|
||||
if (scratches) {
|
||||
@@ -77,7 +93,5 @@ export function ScratchList({ user }: { user: api.FullUser }) {
|
||||
</li>)}
|
||||
</ul>
|
||||
}
|
||||
*/
|
||||
|
||||
return <Unimplemented issue="105" />
|
||||
}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user