Add RobotTokenSHA256 middleware, /api/robot and frontend entropy calc (#512)

* Add RobotTokenSHA256 middleware for in-the-fly robot generation/login

* Add RobotView, fix middleware, upgrade frontend

* Token header as base91

* Add OAS schema of RobotView

* Use RobotView on new fetchRobot(), mimick old fetchRobot() functionality

* Upgrade websockets for token based authentication

* Small fixes

* Add frontend token entropy checks, add token on route /robot/<token>

* Rename admin panel

* Collect phrases
This commit is contained in:
Reckless_Satoshi 2023-05-05 10:12:38 +00:00 committed by GitHub
parent b47e9565a8
commit e6ddcf9e4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 855 additions and 404 deletions

View File

@ -1,3 +1,6 @@
# Coordinator Alias (Same as longAlias)
COORDINATOR_ALIAS="Local Dev"
# LND directory to read TLS cert and macaroon
LND_DIR='/lnd/'
MACAROON_PATH='data/chain/bitcoin/testnet/admin.macaroon'

View File

@ -2,7 +2,6 @@ import ast
import math
from datetime import timedelta
import gnupg
from decouple import config
from django.contrib.auth.models import User
from django.db.models import Q, Sum
@ -88,56 +87,6 @@ class Logics:
return True, None, None
def validate_pgp_keys(pub_key, enc_priv_key):
"""Validates PGP valid keys. Formats them in a way understandable by the frontend"""
gpg = gnupg.GPG()
# Standarize format with linux linebreaks '\n'. Windows users submitting their own keys have '\r\n' breaking communication.
enc_priv_key = enc_priv_key.replace("\r\n", "\n")
pub_key = pub_key.replace("\r\n", "\n")
# Try to import the public key
import_pub_result = gpg.import_keys(pub_key)
if not import_pub_result.imported == 1:
# If a robot is deleted and it is rebuilt with the same pubKey, the key will not be imported again
# so we assert that the import error is "Not actually changed"
if "Not actually changed" not in import_pub_result.results[0]["text"]:
return (
False,
{
"bad_request": "Your PGP public key does not seem valid.\n"
+ f"Stderr: {str(import_pub_result.stderr)}\n"
+ f"ReturnCode: {str(import_pub_result.returncode)}\n"
+ f"Summary: {str(import_pub_result.summary)}\n"
+ f"Results: {str(import_pub_result.results)}\n"
+ f"Imported: {str(import_pub_result.imported)}\n"
},
None,
None,
)
# Exports the public key again for uniform formatting.
pub_key = gpg.export_keys(import_pub_result.fingerprints[0])
# Try to import the encrypted private key (without passphrase)
import_priv_result = gpg.import_keys(enc_priv_key)
if not import_priv_result.sec_imported == 1:
if "Not actually changed" not in import_priv_result.results[0]["text"]:
return (
False,
{
"bad_request": "Your PGP encrypted private key does not seem valid.\n"
+ f"Stderr: {str(import_priv_result.stderr)}\n"
+ f"ReturnCode: {str(import_priv_result.returncode)}\n"
+ f"Summary: {str(import_priv_result.summary)}\n"
+ f"Results: {str(import_priv_result.results)}\n"
+ f"Sec Imported: {str(import_priv_result.sec_imported)}\n"
},
None,
None,
)
return True, None, pub_key, enc_priv_key
@classmethod
def validate_order_size(cls, order):
"""Validates if order size in Sats is within limits at t0"""

View File

@ -11014,7 +11014,7 @@ nouns = [
"Zostera",
"Zosterops",
"Zouave",
"Zu/is",
"Zuis",
"Zubr",
"Zucchini",
"Zuche",

View File

@ -467,6 +467,23 @@ class UserViewSchema:
"description": "Whether the user prefers stealth invoices",
},
"found": {"type": "string", "description": "Welcome back message"},
"tg_enabled": {
"type": "boolean",
"description": "The robot has telegram notifications enabled",
},
"tg_token": {
"type": "string",
"description": "Token to enable telegram with /start <tg_token>",
},
"tg_bot_name": {
"type": "string",
"description": "Name of the coordinator's telegram bot",
},
"last_login": {
"type": "string",
"format": "date-time",
"description": "Last time seen",
},
"active_order_id": {
"type": "integer",
"description": "Active order id if present",
@ -623,6 +640,114 @@ class BookViewSchema:
}
class RobotViewSchema:
get = {
"summary": "Get robot info",
"description": textwrap.dedent(
"""
DEPRECATED: Use `/robot` GET.
Get robot info 🤖
An authenticated request (has the token's sha256 hash encoded as base 91 in the Authorization header) will be
returned the information about the state of a robot.
Make sure you generate your token using cryptographically secure methods. [Here's]() the function the Javascript
client uses to generate the tokens. Since the server only receives the hash of the
token, it is responsibility of the client to create a strong token. Check
[here](https://github.com/Reckless-Satoshi/robosats/blob/main/frontend/src/utils/token.js)
to see how the Javascript client creates a random strong token and how it validates entropy is optimal for tokens
created by the user at will.
`public_key` - PGP key associated with the user (Armored ASCII format)
`encrypted_private_key` - Private PGP key. This is only stored on the backend for later fetching by
the frontend and the key can't really be used by the server since it's protected by the token
that only the client knows. Will be made an optional parameter in a future release.
On the Javascript client, It's passphrase is set to be the secret token generated.
A gpg key can be created by:
```shell
gpg --full-gen-key
```
it's public key can be exported in ascii armored format with:
```shell
gpg --export --armor <key-id | email | name>
```
and it's private key can be exported in ascii armored format with:
```shell
gpg --export-secret-keys --armor <key-id | email | name>
```
"""
),
"responses": {
200: {
"type": "object",
"properties": {
"encrypted_private_key": {
"type": "string",
"description": "Armored ASCII PGP private key block",
},
"nickname": {
"type": "string",
"description": "Username generated (Robot name)",
},
"public_key": {
"type": "string",
"description": "Armored ASCII PGP public key block",
},
"wants_stealth": {
"type": "boolean",
"default": False,
"description": "Whether the user prefers stealth invoices",
},
"found": {
"type": "boolean",
"description": "Robot had been created in the past. Only if the robot was created +5 mins ago.",
},
"tg_enabled": {
"type": "boolean",
"description": "The robot has telegram notifications enabled",
},
"tg_token": {
"type": "string",
"description": "Token to enable telegram with /start <tg_token>",
},
"tg_bot_name": {
"type": "string",
"description": "Name of the coordinator's telegram bot",
},
"active_order_id": {
"type": "integer",
"description": "Active order id if present",
},
"last_order_id": {
"type": "integer",
"description": "Last order id if present",
},
},
},
},
"examples": [
OpenApiExample(
"Successfully retrieved robot",
value={
"nickname": "SatoshiNakamoto21",
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n......\n......",
"encrypted_private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n......\n......",
"wants_stealth": True,
},
status_codes=[200],
),
],
}
class InfoViewSchema:
get = {
"summary": "Get info",

View File

@ -12,6 +12,7 @@ from .views import (
OrderView,
PriceView,
RewardView,
RobotView,
StealthView,
TickView,
UserView,
@ -26,6 +27,7 @@ urlpatterns = [
OrderView.as_view({"get": "get", "post": "take_update_confirm_dispute_cancel"}),
),
path("user/", UserView.as_view()),
path("robot/", RobotView.as_view()),
path("book/", BookView.as_view()),
path("info/", InfoView.as_view()),
path("price/", PriceView.as_view()),

View File

@ -2,9 +2,11 @@ import json
import logging
import os
import gnupg
import numpy as np
import requests
import ring
from base91 import decode, encode
from decouple import config
from api.models import Order
@ -147,18 +149,6 @@ def get_robosats_commit():
return commit_hash
robosats_version_cache = {}
@ring.dict(robosats_commit_cache, expire=99999)
def get_robosats_version():
with open("version.json") as f:
version_dict = json.load(f)
return version_dict
premium_percentile = {}
@ -238,3 +228,75 @@ def compute_avg_premium(queryset):
else:
weighted_median_premium = 0.0
return weighted_median_premium, total_volume
def validate_pgp_keys(pub_key, enc_priv_key):
"""Validates PGP valid keys. Formats them in a way understandable by the frontend"""
gpg = gnupg.GPG()
# Standardize format with linux linebreaks '\n'. Windows users submitting their own keys have '\r\n' breaking communication.
enc_priv_key = enc_priv_key.replace("\r\n", "\n").replace("\\", "\n")
pub_key = pub_key.replace("\r\n", "\n").replace("\\", "\n")
# Try to import the public key
import_pub_result = gpg.import_keys(pub_key)
if not import_pub_result.imported == 1:
# If a robot is deleted and it is rebuilt with the same pubKey, the key will not be imported again
# so we assert that the import error is "Not actually changed"
if "Not actually changed" not in import_pub_result.results[0]["text"]:
return (
False,
{
"bad_request": "Your PGP public key does not seem valid.\n"
+ f"Stderr: {str(import_pub_result.stderr)}\n"
+ f"ReturnCode: {str(import_pub_result.returncode)}\n"
+ f"Summary: {str(import_pub_result.summary)}\n"
+ f"Results: {str(import_pub_result.results)}\n"
+ f"Imported: {str(import_pub_result.imported)}\n"
},
None,
None,
)
# Exports the public key again for uniform formatting.
pub_key = gpg.export_keys(import_pub_result.fingerprints[0])
# Try to import the encrypted private key (without passphrase)
import_priv_result = gpg.import_keys(enc_priv_key)
if not import_priv_result.sec_imported == 1:
if "Not actually changed" not in import_priv_result.results[0]["text"]:
return (
False,
{
"bad_request": "Your PGP encrypted private key does not seem valid.\n"
+ f"Stderr: {str(import_priv_result.stderr)}\n"
+ f"ReturnCode: {str(import_priv_result.returncode)}\n"
+ f"Summary: {str(import_priv_result.summary)}\n"
+ f"Results: {str(import_priv_result.results)}\n"
+ f"Sec Imported: {str(import_priv_result.sec_imported)}\n"
},
None,
None,
)
return True, None, pub_key, enc_priv_key
def base91_to_hex(base91_str: str) -> str:
bytes_data = decode(base91_str)
return bytes_data.hex()
def hex_to_base91(hex_str: str) -> str:
hex_bytes = bytes.fromhex(hex_str)
base91_str = encode(hex_bytes)
return base91_str
def is_valid_token(token: str) -> bool:
num_chars = len(token)
if not 38 < num_chars < 41:
return False
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"'
return all(c in charset for c in token)

View File

@ -12,7 +12,12 @@ from django.db.models import Q, Sum
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import status, viewsets
from rest_framework.authentication import (
SessionAuthentication, # DEPRECATE session authentication
)
from rest_framework.authentication import TokenAuthentication
from rest_framework.generics import CreateAPIView, ListAPIView, UpdateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from robohash import Robohash
@ -30,6 +35,7 @@ from api.oas_schemas import (
OrderViewSchema,
PriceViewSchema,
RewardViewSchema,
RobotViewSchema,
StealthViewSchema,
TickViewSchema,
UserViewSchema,
@ -51,7 +57,7 @@ from api.utils import (
compute_premium_percentile,
get_lnd_version,
get_robosats_commit,
get_robosats_version,
validate_pgp_keys,
)
from chat.models import Message
from control.models import AccountingDay, BalanceLog
@ -72,9 +78,12 @@ avatar_path.mkdir(parents=True, exist_ok=True)
class MakerView(CreateAPIView):
serializer_class = MakeOrderSerializer
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
@extend_schema(**MakerViewSchema.post)
def post(self, request):
serializer = self.serializer_class(data=request.data)
if not request.user.is_authenticated:
@ -178,6 +187,8 @@ class MakerView(CreateAPIView):
class OrderView(viewsets.ViewSet):
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
serializer_class = UpdateOrderSerializer
lookup_url_kwarg = "order_id"
@ -617,13 +628,60 @@ class OrderView(viewsets.ViewSet):
return self.get(request)
class RobotView(APIView):
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
@extend_schema(**RobotViewSchema.get)
def get(self, request, format=None):
"""
Respond with Nickname, pubKey, privKey.
"""
user = request.user
context = {}
context["nickname"] = user.username
context["public_key"] = user.robot.public_key
context["encrypted_private_key"] = user.robot.encrypted_private_key
context["earned_rewards"] = user.robot.earned_rewards
context["wants_stealth"] = user.robot.wants_stealth
context["last_login"] = user.last_login
# Adds/generate telegram token and whether it is enabled
context = {**context, **Telegram.get_context(user)}
# return active order or last made order if any
has_no_active_order, _, order = Logics.validate_already_maker_or_taker(
request.user
)
if not has_no_active_order:
context["active_order_id"] = order.id
else:
last_order = Order.objects.filter(
Q(maker=request.user) | Q(taker=request.user)
).last()
if last_order:
context["last_order_id"] = last_order.id
# Robot was found, only if created +5 mins ago
if user.date_joined < (timezone.now() - timedelta(minutes=5)):
context["found"] = True
return Response(context, status=status.HTTP_200_OK)
class UserView(APIView):
"""
Deprecated. UserView will be completely replaced by the smaller RobotView in
combination with the RobotTokenSHA256 middleware (on-the-fly robot generation)
"""
NickGen = NickGenerator(
lang="English", use_adv=False, use_adj=True, use_noun=True, max_num=999
)
serializer_class = UserGenSerializer
@extend_schema(**UserViewSchema.post)
def post(self, request, format=None):
"""
Get a new user derived from a high entropy token
@ -715,7 +773,7 @@ class UserView(APIView):
bad_keys_context,
public_key,
encrypted_private_key,
) = Logics.validate_pgp_keys(public_key, encrypted_private_key)
) = validate_pgp_keys(public_key, encrypted_private_key)
if not valid:
return Response(bad_keys_context, status.HTTP_400_BAD_REQUEST)
@ -922,7 +980,7 @@ class InfoView(ListAPIView):
context["lifetime_volume"] = round(lifetime_volume, 8)
context["lnd_version"] = get_lnd_version()
context["robosats_running_commit_hash"] = get_robosats_commit()
context["version"] = get_robosats_version()
context["version"] = settings.VERSION
context["alternative_site"] = config("ALTERNATIVE_SITE")
context["alternative_name"] = config("ALTERNATIVE_NAME")
context["node_alias"] = config("NODE_ALIAS")
@ -945,18 +1003,15 @@ class InfoView(ListAPIView):
class RewardView(CreateAPIView):
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
serializer_class = ClaimRewardSerializer
@extend_schema(**RewardViewSchema.post)
def post(self, request):
serializer = self.serializer_class(data=request.data)
if not request.user.is_authenticated:
return Response(
{"bad_request": "Woops! It seems you do not have a robot avatar"},
status.HTTP_400_BAD_REQUEST,
)
if not serializer.is_valid():
return Response(status=status.HTTP_400_BAD_REQUEST)
@ -1052,6 +1107,8 @@ class HistoricalView(ListAPIView):
class StealthView(UpdateAPIView):
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
serializer_class = StealthSerializer
@ -1059,12 +1116,6 @@ class StealthView(UpdateAPIView):
def put(self, request):
serializer = self.serializer_class(data=request.data)
if not request.user.is_authenticated:
return Response(
{"bad_request": "Woops! It seems you do not have a robot avatar"},
status.HTTP_400_BAD_REQUEST,
)
if not serializer.is_valid():
return Response(status=status.HTTP_400_BAD_REQUEST)

View File

@ -5,6 +5,11 @@ from channels.layers import get_channel_layer
from django.contrib.auth.models import User
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.authentication import (
SessionAuthentication, # DEPRECATE session Authentication
)
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from api.models import Order
@ -15,6 +20,9 @@ from chat.serializers import ChatSerializer, PostMessageSerializer
class ChatView(viewsets.ViewSet):
serializer_class = PostMessageSerializer
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
lookup_url_kwarg = ["order_id", "offset"]
queryset = Message.objects.filter(

View File

@ -1,3 +0,0 @@
# from django.db import models
# Create your models here.

View File

@ -21,6 +21,7 @@
"@mui/x-date-pickers": "^6.0.4",
"@nivo/core": "^0.80.0",
"@nivo/line": "^0.80.0",
"base-ex": "^0.7.5",
"country-flag-icons": "^1.4.25",
"date-fns": "^2.28.0",
"file-replace-loader": "^1.4.0",
@ -5165,6 +5166,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base-ex": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/base-ex/-/base-ex-0.7.5.tgz",
"integrity": "sha512-tJhPMqiXc6GTrMZusgZIJvYV9wFZ5lReDUsmm4AIIznAFm2ZD8i1/bAlgTkkR2elxjdtHAGFoKaALXDaJBv4yA=="
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -18920,6 +18926,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"base-ex": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/base-ex/-/base-ex-0.7.5.tgz",
"integrity": "sha512-tJhPMqiXc6GTrMZusgZIJvYV9wFZ5lReDUsmm4AIIznAFm2ZD8i1/bAlgTkkR2elxjdtHAGFoKaALXDaJBv4yA=="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",

View File

@ -59,6 +59,7 @@
"@mui/x-date-pickers": "^6.0.4",
"@nivo/core": "^0.80.0",
"@nivo/line": "^0.80.0",
"base-ex": "^0.7.5",
"country-flag-icons": "^1.4.25",
"date-fns": "^2.28.0",
"file-replace-loader": "^1.4.0",

View File

@ -80,7 +80,6 @@ const BookPage = (): JSX.Element => {
<Dialog open={openMaker} onClose={() => setOpenMaker(false)}>
<Box sx={{ maxWidth: '18em', padding: '0.5em' }}>
<MakerForm
hasRobot={robot.avatarLoaded}
onOrderCreated={(id) => {
navigate('/order/' + id);
}}

View File

@ -77,7 +77,7 @@ const Main: React.FC = () => {
<MainBox navbarHeight={navbarHeight}>
<Routes>
{['/robot/:refCode?', '/', ''].map((path, index) => {
{['/robot/:token?', '/', ''].map((path, index) => {
return (
<Route
path={path}

View File

@ -100,7 +100,6 @@ const MakerPage = (): JSX.Element => {
onOrderCreated={(id) => {
navigate('/order/' + id);
}}
hasRobot={robot.avatarLoaded}
disableRequest={matches.length > 0 && !showMatches}
collapseAll={showMatches}
onSubmit={() => setShowMatches(matches.length > 0)}

View File

@ -58,7 +58,7 @@ const OrderPage = (): JSX.Element => {
escrow_duration: order.escrow_duration,
bond_size: order.bond_size,
};
apiClient.post(baseUrl, '/api/make/', body).then((data: any) => {
apiClient.post(baseUrl, '/api/make/', body, robot.tokenSHA256).then((data: any) => {
if (data.bad_request) {
setBadOrder(data.bad_request);
} else if (data.id) {

View File

@ -11,12 +11,10 @@ import {
LinearProgress,
Link,
Typography,
useTheme,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import { Page } from '../NavBar';
import { Robot } from '../../models';
import { Casino, Bolt, Check, Storefront, AddBox, School } from '@mui/icons-material';
import RobotAvatar from '../../components/RobotAvatar';
@ -31,7 +29,7 @@ interface OnboardingProps {
inputToken: string;
setInputToken: (state: string) => void;
getGenerateRobot: (token: string) => void;
badRequest: string | undefined;
badToken: string;
baseUrl: string;
}
@ -41,7 +39,7 @@ const Onboarding = ({
inputToken,
setInputToken,
setRobot,
badRequest,
badToken,
getGenerateRobot,
baseUrl,
}: OnboardingProps): JSX.Element => {
@ -102,7 +100,7 @@ const Onboarding = ({
inputToken={inputToken}
setInputToken={setInputToken}
setRobot={setRobot}
badRequest={badRequest}
badToken={badToken}
robot={robot}
onPressEnter={() => null}
/>

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Grid, Typography, useTheme } from '@mui/material';
import { Button, Grid, Typography } from '@mui/material';
import { Robot } from '../../models';
import TokenInput from './TokenInput';
import Key from '@mui/icons-material/Key';
@ -10,6 +10,7 @@ interface RecoveryProps {
setRobot: (state: Robot) => void;
setView: (state: 'welcome' | 'onboarding' | 'recovery' | 'profile') => void;
inputToken: string;
badToken: string;
setInputToken: (state: string) => void;
getGenerateRobot: (token: string) => void;
}
@ -18,21 +19,16 @@ const Recovery = ({
robot,
setRobot,
inputToken,
badToken,
setView,
setInputToken,
getGenerateRobot,
}: RecoveryProps): JSX.Element => {
const { t } = useTranslation();
const recoveryDisabled = () => {
return !(inputToken.length > 20);
};
const onClickRecover = () => {
if (recoveryDisabled()) {
} else {
getGenerateRobot(inputToken);
setView('profile');
}
getGenerateRobot(inputToken);
setView('profile');
};
return (
@ -56,16 +52,11 @@ const Recovery = ({
label={t('Paste token here')}
robot={robot}
onPressEnter={onClickRecover}
badRequest={''}
badToken={badToken}
/>
</Grid>
<Grid item>
<Button
variant='contained'
size='large'
disabled={recoveryDisabled()}
onClick={onClickRecover}
>
<Button variant='contained' size='large' disabled={!!badToken} onClick={onClickRecover}>
<Key /> <div style={{ width: '0.5em' }} />
{t('Recover')}
</Button>

View File

@ -27,12 +27,9 @@ interface RobotProfileProps {
setView: (state: 'welcome' | 'onboarding' | 'recovery' | 'profile') => void;
getGenerateRobot: (token: string, slot?: number) => void;
inputToken: string;
setCurrentOrder: (state: number) => void;
logoutRobot: () => void;
inputToken: string;
setInputToken: (state: string) => void;
baseUrl: string;
badRequest: string;
width: number;
}
@ -42,10 +39,8 @@ const RobotProfile = ({
inputToken,
getGenerateRobot,
setInputToken,
setCurrentOrder,
logoutRobot,
setView,
badRequest,
baseUrl,
width,
}: RobotProfileProps): JSX.Element => {
@ -227,7 +222,6 @@ const RobotProfile = ({
label={t('Store your token safely')}
setInputToken={setInputToken}
setRobot={setRobot}
badRequest={badRequest}
robot={robot}
onPressEnter={() => null}
/>

View File

@ -14,7 +14,7 @@ interface TokenInputProps {
inputToken: string;
autoFocusTarget?: 'textfield' | 'copyButton' | 'none';
onPressEnter: () => void;
badRequest: string | undefined;
badToken?: string;
setInputToken: (state: string) => void;
showCopy?: boolean;
label?: string;
@ -30,7 +30,7 @@ const TokenInput = ({
onPressEnter,
autoFocusTarget = 'textfield',
inputToken,
badRequest,
badToken = '',
loading = false,
setInputToken,
}: TokenInputProps): JSX.Element => {
@ -46,7 +46,7 @@ const TokenInput = ({
} else {
return (
<TextField
error={!!badRequest}
error={inputToken.length > 20 ? !!badToken : false}
disabled={!editable}
required={true}
label={label || undefined}
@ -55,7 +55,7 @@ const TokenInput = ({
fullWidth={fullWidth}
sx={{ borderColor: 'primary' }}
variant={editable ? 'outlined' : 'filled'}
helperText={badRequest}
helperText={badToken}
size='medium'
onChange={(e) => setInputToken(e.target.value)}
onKeyPress={(e) => {

View File

@ -20,18 +20,19 @@ import Recovery from './Recovery';
import { TorIcon } from '../../components/Icons';
import { genKey } from '../../pgp';
import { AppContext, UseAppStoreType } from '../../contexts/AppContext';
import { validateTokenEntropy } from '../../utils';
const RobotPage = (): JSX.Element => {
const { robot, setRobot, setCurrentOrder, fetchRobot, torStatus, windowSize, baseUrl } =
const { robot, setRobot, fetchRobot, torStatus, windowSize, baseUrl } =
useContext<UseAppStoreType>(AppContext);
const { t } = useTranslation();
const params = useParams();
const refCode = params.refCode;
const url_token = params.token;
const width = Math.min(windowSize.width * 0.8, 28);
const maxHeight = windowSize.height * 0.85 - 3;
const theme = useTheme();
const [badRequest, setBadRequest] = useState<string | undefined>(undefined);
const [badToken, setBadToken] = useState<string>('');
const [inputToken, setInputToken] = useState<string>('');
const [view, setView] = useState<'welcome' | 'onboarding' | 'recovery' | 'profile'>(
robot.token ? 'profile' : 'welcome',
@ -41,26 +42,35 @@ const RobotPage = (): JSX.Element => {
if (robot.token) {
setInputToken(robot.token);
}
if (robot.nickname == null && robot.token) {
const token = url_token ?? robot.token;
if (robot.nickname == null && token) {
if (window.NativeRobosats === undefined || torStatus == '"Done"') {
fetchRobot({ action: 'generate', setBadRequest });
getGenerateRobot(token);
setView('profile');
}
}
}, [torStatus]);
useEffect(() => {
if (inputToken.length < 20) {
setBadToken(t('The token is too short'));
} else if (!validateTokenEntropy(inputToken).hasEnoughEntropy) {
setBadToken(t('Not enough entropy, make it more complex'));
} else {
setBadToken('');
}
}, [inputToken]);
const getGenerateRobot = (token: string, slot?: number) => {
setInputToken(token);
genKey(token).then(function (key) {
fetchRobot({
action: 'generate',
newKeys: {
pubKey: key.publicKeyArmored,
encPrivKey: key.encryptedPrivateKeyArmored,
},
newToken: token,
slot,
refCode,
setBadRequest,
});
});
};
@ -138,7 +148,7 @@ const RobotPage = (): JSX.Element => {
setView={setView}
robot={robot}
setRobot={setRobot}
badRequest={badRequest}
badToken={badToken}
inputToken={inputToken}
setInputToken={setInputToken}
getGenerateRobot={getGenerateRobot}
@ -151,9 +161,6 @@ const RobotPage = (): JSX.Element => {
setView={setView}
robot={robot}
setRobot={setRobot}
setCurrentOrder={setCurrentOrder}
badRequest={badRequest}
getGenerateRobot={getGenerateRobot}
logoutRobot={logoutRobot}
width={width}
inputToken={inputToken}
@ -168,11 +175,10 @@ const RobotPage = (): JSX.Element => {
setView={setView}
robot={robot}
setRobot={setRobot}
badRequest={badRequest}
badToken={badToken}
inputToken={inputToken}
setInputToken={setInputToken}
getGenerateRobot={getGenerateRobot}
baseUrl={baseUrl}
/>
) : null}
</Paper>

View File

@ -73,10 +73,6 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop
setWeblnEnabled(webln !== undefined);
}, []);
const copyReferralCodeHandler = () => {
systemClient.copyToClipboard(`http://${host}/robot/${robot.referralCode}`);
};
const handleWeblnInvoiceClicked = async (e: any) => {
e.preventDefault();
if (robot.earnedRewards) {
@ -94,9 +90,14 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop
setShowRewardsSpinner(true);
apiClient
.post(baseUrl, '/api/reward/', {
invoice: rewardInvoice,
})
.post(
baseUrl,
'/api/reward/',
{
invoice: rewardInvoice,
},
robot.tokenSHA256,
)
.then((data: any) => {
setBadInvoice(data.bad_invoice ?? '');
setShowRewardsSpinner(false);
@ -109,7 +110,7 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop
const setStealthInvoice = (wantsStealth: boolean) => {
apiClient
.put(baseUrl, '/api/stealth/', { wantsStealth })
.put(baseUrl, '/api/stealth/', { wantsStealth }, robot.tokenSHA256)
.then((data) => setRobot({ ...robot, stealthInvoices: data?.wantsStealth }));
};
@ -268,29 +269,6 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop
</ListItemText>
</ListItem>
<ListItem>
<ListItemIcon>
<PersonAddAltIcon />
</ListItemIcon>
<ListItemText secondary={t('Share to earn 100 Sats per trade')}>
<TextField
label={t('Your referral link')}
value={host + '/robot/' + robot.referralCode}
size='small'
InputProps={{
endAdornment: (
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!') || ''}>
<IconButton onClick={copyReferralCodeHandler}>
<ContentCopy />
</IconButton>
</Tooltip>
),
}}
/>
</ListItemText>
</ListItem>
<ListItem>
<ListItemIcon>
<EmojiEventsIcon />

View File

@ -1,4 +1,3 @@
import { Paper } from '@mui/material';
import React, { Component } from 'react';
interface ErrorBoundaryProps {
@ -28,13 +27,13 @@ export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBo
this.setState({ hasError: true, error, errorInfo });
setTimeout(() => {
window.location.reload();
}, 10000);
}, 30000);
}
render() {
if (this.state.hasError) {
return (
<div style={{ overflow: 'auto', height: '100%', width: '100%', background: 'white' }}>
<h2>Something is borked! Restarting app in 10 seconds...</h2>
<h2>Something is borked! Restarting app in 30 seconds...</h2>
<p>
<b>Error:</b> {this.state.error.name}
</p>

View File

@ -50,7 +50,6 @@ interface MakerFormProps {
onReset?: () => void;
submitButtonLabel?: string;
onOrderCreated?: (id: number) => void;
hasRobot?: boolean;
}
const MakerForm = ({
@ -61,9 +60,8 @@ const MakerForm = ({
onReset = () => {},
submitButtonLabel = 'Create Order',
onOrderCreated = () => null,
hasRobot = true,
}: MakerFormProps): JSX.Element => {
const { fav, setFav, limits, fetchLimits, info, maker, setMaker, baseUrl } =
const { fav, setFav, limits, fetchLimits, info, maker, setMaker, baseUrl, robot } =
useContext<UseAppStoreType>(AppContext);
const { t } = useTranslation();
@ -251,7 +249,7 @@ const MakerForm = ({
escrow_duration: maker.escrowDuration,
bond_size: maker.bondSize,
};
apiClient.post(baseUrl, '/api/make/', body).then((data: object) => {
apiClient.post(baseUrl, '/api/make/', body, robot.tokenSHA256).then((data: object) => {
setBadRequest(data.bad_request);
if (data.id) {
onOrderCreated(data.id);
@ -466,7 +464,7 @@ const MakerForm = ({
open={openDialogs}
onClose={() => setOpenDialogs(false)}
onClickDone={handleCreateOrder}
hasRobot={hasRobot}
hasRobot={robot.avatarLoaded}
/>
<Collapse in={limits.list.length == 0}>
<div style={{ display: limits.list.length == 0 ? '' : 'none' }}>

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
@ -21,16 +21,16 @@ import Countdown from 'react-countdown';
import currencies from '../../../static/assets/currencies.json';
import { apiClient } from '../../services/api';
import { Order } from '../../models';
import { Order, Info } from '../../models';
import { ConfirmationDialog } from '../Dialogs';
import { LoadingButton } from '@mui/lab';
import { computeSats, pn } from '../../utils';
import { computeSats } from '../../utils';
import { AppContext, UseAppStoreType } from '../../contexts/AppContext';
interface TakeButtonProps {
order: Order;
setOrder: (state: Order) => void;
baseUrl: string;
hasRobot: boolean;
info: Info;
}
@ -40,9 +40,10 @@ interface OpenDialogsProps {
}
const closeAll = { inactiveMaker: false, confirmation: false };
const TakeButton = ({ order, setOrder, baseUrl, hasRobot, info }: TakeButtonProps): JSX.Element => {
const TakeButton = ({ order, setOrder, baseUrl, info }: TakeButtonProps): JSX.Element => {
const { t } = useTranslation();
const theme = useTheme();
const { robot } = useContext<UseAppStoreType>(AppContext);
const [takeAmount, setTakeAmount] = useState<string>('');
const [badRequest, setBadRequest] = useState<string>('');
@ -277,10 +278,15 @@ const TakeButton = ({ order, setOrder, baseUrl, hasRobot, info }: TakeButtonProp
const takeOrder = function () {
setLoadingTake(true);
apiClient
.post(baseUrl, '/api/order/?order_id=' + order.id, {
action: 'take',
amount: order.currency == 1000 ? takeAmount / 100000000 : takeAmount,
})
.post(
baseUrl,
'/api/order/?order_id=' + order.id,
{
action: 'take',
amount: order.currency == 1000 ? takeAmount / 100000000 : takeAmount,
},
robot.tokenSHA256,
)
.then((data) => {
setLoadingTake(false);
if (data.bad_request) {
@ -313,7 +319,7 @@ const TakeButton = ({ order, setOrder, baseUrl, hasRobot, info }: TakeButtonProp
setLoadingTake(true);
setOpen(closeAll);
}}
hasRobot={hasRobot}
hasRobot={robot.avatarLoaded}
/>
<InactiveMakerDialog />
</Box>

View File

@ -14,7 +14,7 @@ interface Props {
text: string;
}
const StringAsIcons: React.FC = ({ othersText, verbose, size, text }: Props) => {
const StringAsIcons: React.FC = ({ othersText, verbose, size, text = '' }: Props) => {
const { t } = useTranslation();
const parsedText = useMemo(() => {

View File

@ -14,6 +14,7 @@ import MessageCard from '../MessageCard';
import ChatHeader from '../ChatHeader';
import { EncryptedChatMessage, ServerMessage } from '..';
import ChatBottom from '../ChatBottom';
import { sha256 } from 'js-sha256';
interface Props {
orderId: number;
@ -92,19 +93,24 @@ const EncryptedSocketChat: React.FC<Props> = ({
}, [serverMessages]);
const connectWebsocket = () => {
websocketClient.open(`ws://${window.location.host}/ws/chat/${orderId}/`).then((connection) => {
setConnection(connection);
setConnected(true);
websocketClient
.open(
`ws://${window.location.host}/ws/chat/${orderId}/?token_sha256_hex=${sha256(robot.token)}`,
)
.then((connection) => {
setConnection(connection);
setConnected(true);
connection.send({
message: robot.pubKey,
nick: userNick,
connection.send({
message: robot.pubKey,
nick: userNick,
authorization: `Token ${robot.tokenSHA256}`,
});
connection.onMessage((message) => setServerMessages((prev) => [...prev, message]));
connection.onClose(() => setConnected(false));
connection.onError(() => setConnected(false));
});
connection.onMessage((message) => setServerMessages((prev) => [...prev, message]));
connection.onClose(() => setConnected(false));
connection.onError(() => setConnected(false));
});
};
const createJsonFile: () => object = () => {
@ -135,6 +141,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
connection.send({
message: `-----SERVE HISTORY-----`,
nick: userNick,
authorization: `Token ${robot.tokenSHA256}`,
});
}
// If we receive an encrypted message
@ -206,6 +213,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
connection.send({
message: value,
nick: userNick,
authorization: `Token ${robot.tokenSHA256}`,
});
setValue('');
}
@ -221,6 +229,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
connection.send({
message: encryptedMessage.toString().split('\n').join('\\'),
nick: userNick,
authorization: `Token ${robot.tokenSHA256}`,
});
}
})

View File

@ -76,7 +76,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
const loadMessages: () => void = () => {
apiClient
.get(baseUrl, `/api/chat/?order_id=${orderId}&offset=${lastIndex}`)
.get(baseUrl, `/api/chat/?order_id=${orderId}&offset=${lastIndex}`, robot.tokenSHA256)
.then((results: any) => {
if (results) {
setPeerConnected(results.peer_connected);
@ -167,11 +167,16 @@ const EncryptedTurtleChat: React.FC<Props> = ({
// If input string contains '#' send unencrypted and unlogged message
else if (value.substring(0, 1) == '#') {
apiClient
.post(baseUrl, `/api/chat/`, {
PGP_message: value,
order_id: orderId,
offset: lastIndex,
})
.post(
baseUrl,
`/api/chat/`,
{
PGP_message: value,
order_id: orderId,
offset: lastIndex,
},
robot.tokenSHA256,
)
.then((response) => {
if (response != null) {
if (response.messages) {
@ -192,11 +197,16 @@ const EncryptedTurtleChat: React.FC<Props> = ({
encryptMessage(value, robot.pubKey, peerPubKey, robot.encPrivKey, robot.token)
.then((encryptedMessage) => {
apiClient
.post(baseUrl, `/api/chat/`, {
PGP_message: encryptedMessage.toString().split('\n').join('\\'),
order_id: orderId,
offset: lastIndex,
})
.post(
baseUrl,
`/api/chat/`,
{
PGP_message: encryptedMessage.toString().split('\n').join('\\'),
order_id: orderId,
offset: lastIndex,
},
robot.tokenSHA256,
)
.then((response) => {
if (response != null) {
setPeerConnected(response.peer_connected);

View File

@ -161,15 +161,20 @@ const TradeBox = ({
rating,
}: SubmitActionProps) {
apiClient
.post(baseUrl, '/api/order/?order_id=' + order.id, {
action,
invoice,
routing_budget_ppm,
address,
mining_fee_rate,
statement,
rating,
})
.post(
baseUrl,
'/api/order/?order_id=' + order.id,
{
action,
invoice,
routing_budget_ppm,
address,
mining_fee_rate,
statement,
rating,
},
robot.tokenSHA256,
)
.catch(() => {
setOpen(closeAll);
setLoadingButtons({ ...noLoadingButtons });

View File

@ -18,13 +18,13 @@ import {
} from '../models';
import { apiClient } from '../services/api';
import { systemClient } from '../services/System';
import { checkVer, getHost, tokenStrength } from '../utils';
import { checkVer, getHost, hexToBase91, validateTokenEntropy } from '../utils';
import { sha256 } from 'js-sha256';
import defaultCoordinators from '../../static/federation.json';
import { createTheme, Theme } from '@mui/material/styles';
import i18n from '../i18n/Web';
import { systemClient } from '../services/System';
const getWindowSize = function (fontSize: number) {
// returns window size in EM units
@ -63,12 +63,10 @@ export interface SlideDirection {
}
export interface fetchRobotProps {
action?: 'login' | 'generate' | 'refresh';
newKeys?: { encPrivKey: string; pubKey: string } | null;
newToken?: string | null;
refCode?: string | null;
slot?: number | null;
setBadRequest?: (state: string) => void;
newKeys?: { encPrivKey: string; pubKey: string };
newToken?: string;
slot?: number;
isRefresh?: boolean;
}
export type TorStatus = 'NOTINIT' | 'STARTING' | '"Done"' | 'DONE';
@ -297,7 +295,9 @@ export const useAppStore = () => {
const fetchOrder = function () {
if (currentOrder != undefined) {
apiClient.get(baseUrl, '/api/order/?order_id=' + currentOrder).then(orderReceived);
apiClient
.get(baseUrl, '/api/order/?order_id=' + currentOrder, robot.tokenSHA256)
.then(orderReceived);
}
};
@ -307,97 +307,87 @@ export const useAppStore = () => {
};
const fetchRobot = function ({
action = 'login',
newKeys = null,
newToken = null,
refCode = null,
slot = null,
setBadRequest = () => {},
}: fetchRobotProps) {
const oldRobot = robot;
newToken,
newKeys,
slot,
isRefresh = false,
}: fetchRobotProps): void {
const token = newToken ?? robot.token ?? '';
const { hasEnoughEntropy, bitsEntropy, shannonEntropy } = validateTokenEntropy(token);
if (!hasEnoughEntropy) {
return;
}
const tokenSHA256 = hexToBase91(sha256(token));
const targetSlot = slot ?? currentSlot;
const token = newToken ?? oldRobot.token;
if (action != 'refresh') {
setRobot(new Robot());
}
setBadRequest('');
const encPrivKey = newKeys?.encPrivKey ?? robot.encPrivKey ?? '';
const pubKey = newKeys?.pubKey ?? robot.pubKey ?? '';
const requestBody = {};
if (action == 'login' || action == 'refresh') {
requestBody.token_sha256 = sha256(token);
} else if (action == 'generate' && token != null) {
const strength = tokenStrength(token);
requestBody.token_sha256 = sha256(token);
requestBody.unique_values = strength.uniqueValues;
requestBody.counts = strength.counts;
requestBody.length = token.length;
requestBody.ref_code = refCode;
requestBody.public_key = newKeys?.pubKey ?? oldRobot.pubKey;
requestBody.encrypted_private_key = newKeys?.encPrivKey ?? oldRobot.encPrivKey;
}
// On first authenticated request, pubkey and privkey must be in header cookies
systemClient.setCookie('public_key', pubKey.split('\n').join('\\'));
systemClient.setCookie('encrypted_private_key', encPrivKey.split('\n').join('\\'));
apiClient.post(baseUrl, '/api/user/', requestBody).then((data: any) => {
let newRobot = robot;
if (currentOrder === undefined) {
setCurrentOrder(
data.active_order_id
? data.active_order_id
: data.last_order_id
? data.last_order_id
: null,
);
}
if (data.bad_request) {
setBadRequest(data.bad_request);
newRobot = {
...oldRobot,
loading: false,
nickname: data.nickname ?? oldRobot.nickname,
activeOrderId: data.active_order_id ?? null,
referralCode: data.referral_code ?? oldRobot.referralCode,
earnedRewards: data.earned_rewards ?? oldRobot.earnedRewards,
lastOrderId: data.last_order_id ?? oldRobot.lastOrderId,
stealthInvoices: data.wants_stealth ?? robot.stealthInvoices,
tgEnabled: data.tg_enabled,
tgBotName: data.tg_bot_name,
tgToken: data.tg_token,
found: false,
if (!isRefresh) {
setRobot((robot) => {
return {
...robot,
loading: true,
avatarLoaded: false,
};
} else {
newRobot = {
...oldRobot,
});
}
apiClient
.get(baseUrl, '/api/robot/', tokenSHA256)
.then((data: any) => {
const newRobot = {
avatarLoaded: isRefresh ? robot.avatarLoaded : false,
nickname: data.nickname,
token,
tokenSHA256,
loading: false,
activeOrderId: data.active_order_id ?? null,
lastOrderId: data.last_order_id ?? null,
referralCode: data.referral_code,
earnedRewards: data.earned_rewards ?? 0,
stealthInvoices: data.wants_stealth,
tgEnabled: data.tg_enabled,
tgBotName: data.tg_bot_name,
tgToken: data.tg_token,
found: data?.found,
bitsEntropy: data.token_bits_entropy,
shannonEntropy: data.token_shannon_entropy,
last_login: data.last_login,
bitsEntropy,
shannonEntropy,
pubKey: data.public_key,
encPrivKey: data.encrypted_private_key,
copiedToken: !!data.found,
};
if (currentOrder === undefined) {
setCurrentOrder(
data.active_order_id
? data.active_order_id
: data.last_order_id
? data.last_order_id
: null,
);
}
setRobot(newRobot);
garage.updateRobot(newRobot, targetSlot);
setCurrentSlot(targetSlot);
systemClient.setItem('robot_token', token);
}
});
})
.finally(() => {
systemClient.deleteCookie('public_key');
systemClient.deleteCookie('encrypted_private_key');
});
};
useEffect(() => {
if (baseUrl != '' && page != 'robot') {
if (open.profile && robot.avatarLoaded) {
fetchRobot({ action: 'refresh' }); // refresh/update existing robot
fetchRobot({ isRefresh: true }); // refresh/update existing robot
} else if (!robot.avatarLoaded && robot.token && robot.encPrivKey && robot.pubKey) {
fetchRobot({ action: 'generate' }); // create new robot with existing token and keys (on network and coordinator change)
fetchRobot({}); // create new robot with existing token and keys (on network and coordinator change)
}
}
}, [open.profile, baseUrl]);

View File

@ -2,6 +2,7 @@ class Robot {
constructor(garageRobot?: Robot) {
if (garageRobot != null) {
this.token = garageRobot?.token ?? undefined;
this.tokenSHA256 = garageRobot?.tokenSHA256 ?? undefined;
this.pubKey = garageRobot?.pubKey ?? undefined;
this.encPrivKey = garageRobot?.encPrivKey ?? undefined;
}
@ -9,20 +10,21 @@ class Robot {
public nickname?: string;
public token?: string;
public pubKey?: string;
public encPrivKey?: string;
public bitsEntropy?: number;
public shannonEntropy?: number;
public tokenSHA256?: string;
public pubKey?: string;
public encPrivKey?: string;
public stealthInvoices: boolean = true;
public activeOrderId?: number;
public lastOrderId?: number;
public earnedRewards: number = 0;
public referralCode: string = '';
public tgEnabled: boolean = false;
public tgBotName: string = 'unknown';
public tgToken: string = 'unknown';
public loading: boolean = false;
public found: boolean = false;
public last_login: string = '';
public avatarLoaded: boolean = false;
public copiedToken: boolean = false;
}

View File

@ -50,7 +50,7 @@ class SystemWebClient implements SystemClient {
};
public deleteCookie: (key: string) => void = (key) => {
document.cookie = `${name}= ; expires = Thu, 01 Jan 1970 00:00:00 GMT`;
document.cookie = `${key}= ;path=/; expires = Thu, 01 Jan 1970 00:00:00 GMT`;
};
// Local storage

View File

@ -5,21 +5,27 @@ class ApiNativeClient implements ApiClient {
private assetsCache: { [path: string]: string } = {};
private assetsPromises: { [path: string]: Promise<string | undefined> } = {};
private readonly getHeaders: () => HeadersInit = () => {
private readonly getHeaders: (tokenSHA256?: string) => HeadersInit = (tokenSHA256) => {
let headers = {
'Content-Type': 'application/json',
};
const robotToken = systemClient.getItem('robot_token');
if (robotToken) {
const sessionid = systemClient.getCookie('sessionid');
// const csrftoken = systemClient.getCookie('csrftoken');
if (tokenSHA256) {
headers = {
...headers,
...{
// 'X-CSRFToken': csrftoken,
Cookie: `sessionid=${sessionid}`, // ;csrftoken=${csrftoken}
Authorization: `Token ${tokenSHA256.substring(0, 40)}`,
},
};
}
const encrypted_private_key = systemClient.getCookie('encrypted_private_key');
const public_key = systemClient.getCookie('public_key');
if (encrypted_private_key && public_key) {
headers = {
...headers,
...{
Cookie: `public_key=${public_key};encrypted_private_key=${encrypted_private_key}`,
},
};
}
@ -45,44 +51,47 @@ class ApiNativeClient implements ApiClient {
return await new Promise((res, _rej) => res({}));
};
public delete: (baseUrl: string, path: string) => Promise<object | undefined> = async (
baseUrl,
path,
) => {
public delete: (
baseUrl: string,
path: string,
tokenSHA256?: string,
) => Promise<object | undefined> = async (baseUrl, path, tokenSHA256) => {
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'delete',
baseUrl,
path,
headers: this.getHeaders(),
headers: this.getHeaders(tokenSHA256),
}).then(this.parseResponse);
};
public post: (baseUrl: string, path: string, body: object) => Promise<object | undefined> =
async (baseUrl, path, body) => {
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'post',
baseUrl,
path,
body,
headers: this.getHeaders(),
}).then(this.parseResponse);
};
public get: (baseUrl: string, path: string) => Promise<object | undefined> = async (
baseUrl,
path,
) => {
public post: (
baseUrl: string,
path: string,
body: object,
tokenSHA256?: string,
) => Promise<object | undefined> = async (baseUrl, path, body, tokenSHA256) => {
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'get',
type: 'post',
baseUrl,
path,
headers: this.getHeaders(),
body,
headers: this.getHeaders(tokenSHA256),
}).then(this.parseResponse);
};
public get: (baseUrl: string, path: string, tokenSHA256?: string) => Promise<object | undefined> =
async (baseUrl, path, tokenSHA256) => {
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'get',
baseUrl,
path,
headers: this.getHeaders(tokenSHA256),
}).then(this.parseResponse);
};
public fileImageUrl: (baseUrl: string, path: string) => Promise<string | undefined> = async (
baseUrl,
path,

View File

@ -1,22 +1,32 @@
import { ApiClient } from '..';
import { systemClient } from '../../System';
class ApiWebClient implements ApiClient {
private readonly getHeaders: () => HeadersInit = () => {
return {
private readonly getHeaders: (tokenSHA256?: string) => HeadersInit = (tokenSHA256) => {
let headers = {
'Content-Type': 'application/json',
// 'X-CSRFToken': systemClient.getCookie('csrftoken') || '',
};
if (tokenSHA256) {
headers = {
...headers,
...{
Authorization: `Token ${tokenSHA256.substring(0, 40)}`,
},
};
}
return headers;
};
public post: (baseUrl: string, path: string, body: object) => Promise<object> = async (
baseUrl,
path,
body,
) => {
public post: (
baseUrl: string,
path: string,
body: object,
tokenSHA256?: string,
) => Promise<object> = async (baseUrl, path, body, tokenSHA256) => {
const requestOptions = {
method: 'POST',
headers: this.getHeaders(),
headers: this.getHeaders(tokenSHA256),
body: JSON.stringify(body),
};
@ -25,14 +35,15 @@ class ApiWebClient implements ApiClient {
);
};
public put: (baseUrl: string, path: string, body: object) => Promise<object> = async (
baseUrl,
path,
body,
) => {
public put: (
baseUrl: string,
path: string,
body: object,
tokenSHA256?: string,
) => Promise<object> = async (baseUrl, path, body, tokenSHA256) => {
const requestOptions = {
method: 'PUT',
headers: this.getHeaders(),
headers: this.getHeaders(tokenSHA256),
body: JSON.stringify(body),
};
return await fetch(baseUrl + path, requestOptions).then(
@ -40,18 +51,28 @@ class ApiWebClient implements ApiClient {
);
};
public delete: (baseUrl: string, path: string) => Promise<object> = async (baseUrl, path) => {
public delete: (baseUrl: string, path: string, tokenSHA256?: string) => Promise<object> = async (
baseUrl,
path,
tokenSHA256,
) => {
const requestOptions = {
method: 'DELETE',
headers: this.getHeaders(),
headers: this.getHeaders(tokenSHA256),
};
return await fetch(baseUrl + path, requestOptions).then(
async (response) => await response.json(),
);
};
public get: (baseUrl: string, path: string) => Promise<object> = async (baseUrl, path) => {
return await fetch(baseUrl + path).then(async (response) => await response.json());
public get: (baseUrl: string, path: string, tokenSHA256?: string) => Promise<object> = async (
baseUrl,
path,
tokenSHA256,
) => {
return await fetch(baseUrl + path, { headers: this.getHeaders(tokenSHA256) }).then(
async (response) => await response.json(),
);
};
}

View File

@ -2,10 +2,20 @@ import ApiWebClient from './ApiWebClient';
import ApiNativeClient from './ApiNativeClient';
export interface ApiClient {
post: (baseUrl: string, path: string, body: object) => Promise<object | undefined>;
put: (baseUrl: string, path: string, body: object) => Promise<object | undefined>;
get: (baseUrl: string, path: string) => Promise<object | undefined>;
delete: (baseUrl: string, path: string) => Promise<object | undefined>;
post: (
baseUrl: string,
path: string,
body: object,
tokenSHA256?: string,
) => Promise<object | undefined>;
put: (
baseUrl: string,
path: string,
body: object,
tokenSHA256?: string,
) => Promise<object | undefined>;
get: (baseUrl: string, path: string, tokenSHA256?: string) => Promise<object | undefined>;
delete: (baseUrl: string, path: string, tokenSHA256?: string) => Promise<object | undefined>;
fileImageUrl?: (baseUrl: string, path: string) => Promise<string | undefined>;
}

View File

@ -0,0 +1,10 @@
import { Base91 } from 'base-ex';
export default function hexToBase85(hex: string): string {
const hexes = hex.match(/.{1,2}/g);
if (!hexes) return '';
const byteArray = hexes.map((byte) => parseInt(byte, 16));
const b91 = new Base91();
const base91string = b91.encode(new Uint8Array(byteArray));
return base91string;
}

View File

@ -2,11 +2,12 @@ export { default as checkVer } from './checkVer';
export { default as filterOrders } from './filterOrders';
export { default as getHost } from './getHost';
export { default as hexToRgb } from './hexToRgb';
export { default as hexToBase91 } from './hexToBase91';
export { default as matchMedian } from './match';
export { default as pn } from './prettyNumbers';
export { amountToString } from './prettyNumbers';
export { default as saveAsJson } from './saveFile';
export { default as statusBadgeColor } from './statusBadgeColor';
export { genBase62Token, tokenStrength } from './token';
export { genBase62Token, validateTokenEntropy } from './token';
export { default as getWebln } from './webln';
export { default as computeSats } from './computeSats';

View File

@ -1,19 +0,0 @@
// sort of cryptographically strong function to generate Base62 token client-side
export function genBase62Token(length) {
return window
.btoa(
Array.from(window.crypto.getRandomValues(new Uint8Array(length * 2)))
.map((b) => String.fromCharCode(b))
.join(''),
)
.replace(/[+/]/g, '')
.substring(0, length);
}
export function tokenStrength(token) {
const characters = token.split('').reduce(function (obj, s) {
obj[s] = (obj[s] || 0) + 1;
return obj;
}, {});
return { uniqueValues: Object.keys(characters).length, counts: Object.values(characters) };
}

View File

@ -0,0 +1,45 @@
// sort of cryptographically strong function to generate Base62 token client-side
export function genBase62Token(length: number): string {
return window
.btoa(
Array.from(window.crypto.getRandomValues(new Uint8Array(length * 2)))
.map((b) => String.fromCharCode(b))
.join(''),
)
.replace(/[+/]/g, '')
.substring(0, length);
}
interface TokenEntropy {
hasEnoughEntropy: boolean;
bitsEntropy: number;
shannonEntropy: number;
}
export function validateTokenEntropy(token: string): TokenEntropy {
const charCounts: Record<string, number> = {};
const len = token.length;
let shannonEntropy = 0;
// Count number of occurrences of each character
for (let i = 0; i < len; i++) {
const char = token.charAt(i);
if (charCounts[char]) {
charCounts[char]++;
} else {
charCounts[char] = 1;
}
}
// Calculate the entropy
Object.keys(charCounts).forEach((char) => {
const probability = charCounts[char] / len;
shannonEntropy -= probability * Math.log2(probability);
});
const uniqueChars = Object.keys(charCounts).length;
const bitsEntropy = Math.log2(Math.pow(uniqueChars, len));
const hasEnoughEntropy = bitsEntropy > 128 && shannonEntropy > 4;
return { hasEnoughEntropy, bitsEntropy, shannonEntropy };
}

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Aprèn RoboSats",
"See profile": "Veure perfil",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connectant a TOR",
"Connection encrypted and anonymized using TOR.": "Connexió xifrada i anònima mitjançant TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "Això garanteix la màxima privadesa, però és possible que sentis que l'aplicació es comporta lenta. Si es perd la connexió, reinicia l'aplicació.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram activat",
"Enable Telegram Notifications": "Notificar en Telegram",
"Use stealth invoices": "Factures ofuscades",
"Share to earn 100 Sats per trade": "Comparteix per a guanyar 100 Sats por intercanvi",
"Your referral link": "El teu enllaç de referits",
"Your earned rewards": "Les teves recompenses guanyades",
"Claim": "Retirar",
"Invoice for {{amountSats}} Sats": "Factura per {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Více o RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram povolen",
"Enable Telegram Notifications": "Povolit Telegram notifikace",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Sdílej a získej za každý obchod 100 Satů",
"Your referral link": "Tvůj referral odkaz",
"Your earned rewards": "Tvé odměny",
"Claim": "Vybrat",
"Invoice for {{amountSats}} Sats": "Invoice pro {{amountSats}} Satů",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Lerne RoboSats kennen",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram aktiviert",
"Enable Telegram Notifications": "Telegram-Benachrichtigungen aktivieren",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Teilen, um 100 Sats pro Handel zu verdienen",
"Your referral link": "Dein Empfehlungslink",
"Your earned rewards": "Deine verdienten Belohnungen",
"Claim": "Erhalten",
"Invoice for {{amountSats}} Sats": "Invoice für {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Learn RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram enabled",
"Enable Telegram Notifications": "Enable Telegram Notifications",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Share to earn 100 Sats per trade",
"Your referral link": "Your referral link",
"Your earned rewards": "Your earned rewards",
"Claim": "Claim",
"Invoice for {{amountSats}} Sats": "Invoice for {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Aprende RoboSats",
"See profile": "Ver perfil",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Conectando con TOR",
"Connection encrypted and anonymized using TOR.": "Conexión encriptada y anonimizada usando TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "Esto asegura máxima privacidad, aunque quizá observe algo de lentitud. Si se corta la conexión, reinicie la aplicación.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram activado",
"Enable Telegram Notifications": "Notificar en Telegram",
"Use stealth invoices": "Facturas sigilosas",
"Share to earn 100 Sats per trade": "Comparte para ganar 100 Sats por intercambio",
"Your referral link": "Tu enlace de referido",
"Your earned rewards": "Tus recompensas ganadas",
"Claim": "Reclamar",
"Invoice for {{amountSats}} Sats": "Factura de {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Ikasi RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram baimendua",
"Enable Telegram Notifications": "Baimendu Telegram Jakinarazpenak",
"Use stealth invoices": "Erabili ezkutuko fakturak",
"Share to earn 100 Sats per trade": "Partekatu 100 Sat trukeko irabazteko",
"Your referral link": "Zure erreferentziako esteka",
"Your earned rewards": "Irabazitako sariak",
"Claim": "Eskatu",
"Invoice for {{amountSats}} Sats": "{{amountSats}} Sateko fakura",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Learn RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram activé",
"Enable Telegram Notifications": "Activer les notifications Telegram",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Partagez pour gagner 100 Sats par transaction",
"Your referral link": "Votre lien de parrainage",
"Your earned rewards": "Vos récompenses gagnées",
"Claim": "Réclamer",
"Invoice for {{amountSats}} Sats": "Facture pour {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Impara RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram attivato",
"Enable Telegram Notifications": "Attiva notifiche Telegram",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Condividi e guadagna 100 Sats con ogni transazione",
"Your referral link": "Il tuo link di riferimento",
"Your earned rewards": "La tua ricompensa",
"Claim": "Riscatta",
"Invoice for {{amountSats}} Sats": "Ricevuta per {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Robosatsを学ぶ",
"See profile": "プロフィール",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Torネットワークに接続中",
"Connection encrypted and anonymized using TOR.": "接続はTORを使って暗号化され、匿名化されています。",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "これにより最大限のプライバシーが確保されますが、アプリの動作が遅いと感じることがあります。接続が切断された場合は、アプリを再起動してください。",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegramが有効になりました",
"Enable Telegram Notifications": "Telegram通知を有効にする",
"Use stealth invoices": "ステルス・インボイスを使用する",
"Share to earn 100 Sats per trade": "共有して取引ごとに100 Satsを稼ぐ",
"Your referral link": "あなたの紹介リンク",
"Your earned rewards": "あなたの獲得報酬",
"Claim": "請求する",
"Invoice for {{amountSats}} Sats": " {{amountSats}} Satsのインボイス",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Learn RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram włączony",
"Enable Telegram Notifications": "Włącz powiadomienia telegramu",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Udostępnij, aby zarobić 100 Sats na transakcję",
"Your referral link": "Twój link referencyjny",
"Your earned rewards": "Twoje zarobione nagrody",
"Claim": "Prawo",
"Invoice for {{amountSats}} Sats": "Faktura za {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Aprender sobre o RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram ativado",
"Enable Telegram Notifications": "Habilitar notificações do Telegram",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Compartilhe para ganhar 100 Sats por negociação",
"Your referral link": "Seu link de referência",
"Your earned rewards": "Suas recompensas ganhas",
"Claim": "Reinvindicar",
"Invoice for {{amountSats}} Sats": "Invoice para {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Изучить RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram включен",
"Enable Telegram Notifications": "Включить уведомления Telegram",
"Use stealth invoices": "Использовать стелс инвойсы",
"Share to earn 100 Sats per trade": "Поделись, чтобы заработать 100 Сатоши за сделку",
"Your referral link": "Ваша реферальная ссылка",
"Your earned rewards": "Ваши заработанные награды",
"Claim": "Запросить",
"Invoice for {{amountSats}} Sats": "Инвойс на {{amountSats}} Сатоши",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "Learn RoboSats",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram aktiverat",
"Enable Telegram Notifications": "Aktivera Telegram-notiser",
"Use stealth invoices": "Use stealth invoices",
"Share to earn 100 Sats per trade": "Dela för att tjäna 100 sats per trade",
"Your referral link": "Din referrallänk",
"Your earned rewards": "Du fick belöningar",
"Claim": "Claim",
"Invoice for {{amountSats}} Sats": "Faktura för {{amountSats}} sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "เรียนรู้การใช้งาน",
"See profile": "See profile",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "Connecting to TOR",
"Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.",
@ -252,8 +254,6 @@
"Telegram enabled": "เปิดใช้ Telegram แล้ว",
"Enable Telegram Notifications": "เปิดใช้การแจ้งเตือนผ่านทาง Telegram",
"Use stealth invoices": "ใช้งาน stealth invoices",
"Share to earn 100 Sats per trade": "Share เพื่อรับ 100 Sats ต่อการซื้อขาย",
"Your referral link": "ลิ้งค์ referral ของคุณ",
"Your earned rewards": "รางวัลที่ตุณได้รับ",
"Claim": "รับรางวัล",
"Invoice for {{amountSats}} Sats": "Invoice สำหรับ {{amountSats}} Sats",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "学习 RoboSats",
"See profile": "查看个人资料",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "正在连线 TOR",
"Connection encrypted and anonymized using TOR.": "连接已用 TOR 加密和匿名化。",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "这确保最高的隐秘程度,但你可能会觉得应用程序运作缓慢。如果丢失连接,请重启应用。",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram 已开启",
"Enable Telegram Notifications": "开启 Telegram 通知",
"Use stealth invoices": "使用隐形发票",
"Share to earn 100 Sats per trade": "分享并于每笔交易获得100聪的奖励",
"Your referral link": "你的推荐链接",
"Your earned rewards": "你获得的奖励",
"Claim": "领取",
"Invoice for {{amountSats}} Sats": "{{amountSats}} 聪的发票",

View File

@ -40,6 +40,8 @@
"Learn RoboSats": "學習 RoboSats",
"See profile": "查看個人資料",
"#6": "Phrases in basic/RobotPage/index.tsx",
"The token is too short": "The token is too short",
"Not enough entropy, make it more complex": "Not enough entropy, make it more complex",
"Connecting to TOR": "正在連線 TOR",
"Connection encrypted and anonymized using TOR.": "連接已用 TOR 加密和匿名化。",
"This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "這確保最高的隱密程度,但你可能會覺得應用程序運作緩慢。如果丟失連接,請重啟應用。",
@ -252,8 +254,6 @@
"Telegram enabled": "Telegram 已開啟",
"Enable Telegram Notifications": "開啟 Telegram 通知",
"Use stealth invoices": "使用隱形發票",
"Share to earn 100 Sats per trade": "分享並於每筆交易獲得100聰的獎勵",
"Your referral link": "你的推薦鏈接",
"Your earned rewards": "你獲得的獎勵",
"Claim": "領取",
"Invoice for {{amountSats}} Sats": "{{amountSats}} 聰的發票",

View File

@ -6,7 +6,7 @@ urlpatterns = [
path("", basic),
path("create/", basic),
path("robot/", basic),
path("robot/<refCode>", basic),
path("robot/<token>", basic),
path("offers/", basic),
path("order/<int:orderId>", basic),
path("settings/", basic),

View File

@ -33,3 +33,4 @@ isort==5.12.0
flake8==6.0.0
pyflakes==3.0.1
django-cors-headers==3.14.0
base91==1.0.1

View File

@ -1,3 +1,26 @@
import hashlib
from pathlib import Path
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
from django.db import IntegrityError
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import AuthenticationFailed
from robohash import Robohash
from api.nick_generator.nick_generator import NickGenerator
from api.utils import base91_to_hex, hex_to_base91, is_valid_token, validate_pgp_keys
NickGen = NickGenerator(
lang="English", use_adv=False, use_adj=True, use_noun=True, max_num=999
)
avatar_path = Path(settings.AVATAR_ROOT)
avatar_path.mkdir(parents=True, exist_ok=True)
class DisableCSRFMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
@ -6,3 +29,140 @@ class DisableCSRFMiddleware(object):
setattr(request, "_dont_enforce_csrf_checks", True)
response = self.get_response(request)
return response
class RobotTokenSHA256AuthenticationMiddleWare:
"""
Builds on django-rest-framework Token Authentication.
The robot token SHA256 is taken from the header. The token SHA256 must
be encoded as Base91 of 39 or 40 characters in length. This is the max length of
django DRF token keys.
If the token exists, the requests passes through. If the token is valid and new,
a new user/robot is created (PGP keys are required in the request body).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
token_sha256_b91 = request.META.get("HTTP_AUTHORIZATION", "").replace(
"Token ", ""
)
if not token_sha256_b91:
# Unauthenticated request
response = self.get_response(request)
return response
if not is_valid_token(token_sha256_b91):
raise AuthenticationFailed(
"Robot token SHA256 was provided in the header. However it is not a valid 39 or 40 characters Base91 string."
)
# Check if it is an existing robot.
try:
Token.objects.get(key=token_sha256_b91)
except Token.DoesNotExist:
# If we get here the user does not have a robot on this coordinator
# Let's create a new user & robot on-the-fly.
# The first ever request to a coordinator must include cookies for the public key (and encrypted priv key as of now).
public_key = request.COOKIES.get("public_key")
encrypted_private_key = request.COOKIES.get("encrypted_private_key", "")
if not public_key or not encrypted_private_key:
raise AuthenticationFailed(
"On the first request to a RoboSats coordinator, you must provide as well a valid public and encrypted private PGP keys"
)
(
valid,
bad_keys_context,
public_key,
encrypted_private_key,
) = validate_pgp_keys(public_key, encrypted_private_key)
if not valid:
raise AuthenticationFailed(bad_keys_context)
# Hash the token_sha256, only 1 iteration.
# This is the second SHA256 of the user token, aka RoboSats ID
token_sha256 = base91_to_hex(token_sha256_b91)
hash = hashlib.sha256(token_sha256.encode("utf-8")).hexdigest()
# Generate nickname deterministically
nickname = NickGen.short_from_SHA256(hash, max_length=18)[0]
# DEPRECATE. Using Try and Except only as a temporary measure.
# This will allow existing robots to be added upgraded with a token.key
# After v0.5.0, only the following should remain
# `user = User.objects.create_user(username=nickname, password=None)`
try:
user = User.objects.create_user(username=nickname, password=None)
except IntegrityError:
# UNIQUE constrain failed, user exist. Get it.
user = User.objects.get(username=nickname)
# Django rest_framework authtokens are limited to 40 characters.
# We use base91 so we can store the full entropy in the field.
Token.objects.create(key=token_sha256_b91, user=user)
# Add PGP keys to the new user
if not user.robot.public_key:
user.robot.public_key = public_key
if not user.robot.encrypted_private_key:
user.robot.encrypted_private_key = encrypted_private_key
# Generate avatar. Does not replace if existing.
image_path = avatar_path.joinpath(nickname + ".webp")
if not image_path.exists():
rh = Robohash(hash)
rh.assemble(roboset="set1", bgset="any") # for backgrounds ON
with open(image_path, "wb") as f:
rh.img.save(f, format="WEBP", quality=80)
image_small_path = avatar_path.joinpath(nickname + ".small.webp")
with open(image_small_path, "wb") as f:
resized_img = rh.img.resize((80, 80))
resized_img.save(f, format="WEBP", quality=80)
user.robot.avatar = "static/assets/avatars/" + nickname + ".webp"
user.save()
response = self.get_response(request)
return response
# Authenticate WebSockets connections using DRF tokens
@database_sync_to_async
def get_user(token_key):
try:
token = Token.objects.get(key=token_key)
return token.user
except Token.DoesNotExist:
return AnonymousUser()
class TokenAuthMiddleware(BaseMiddleware):
def __init__(self, inner):
super().__init__(inner)
async def __call__(self, scope, receive, send):
try:
token_key = (
dict((x.split("=") for x in scope["query_string"].decode().split("&")))
).get("token_sha256_hex", None)
token_key = hex_to_base91(token_key)
print(token_key)
except ValueError:
token_key = None
scope["user"] = (
AnonymousUser() if token_key is None else await get_user(token_key)
)
return await super().__call__(scope, receive, send)

View File

@ -6,6 +6,7 @@ from decouple import config
from django.core.asgi import get_asgi_application
import chat.routing
from robosats.middleware import TokenAuthMiddleware
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "robosats.settings")
# Initialize Django ASGI application early to ensure the AppRegistry
@ -14,9 +15,11 @@ django_asgi_app = get_asgi_application()
protocols = {}
protocols["websocket"] = AuthMiddlewareStack(
URLRouter(
chat.routing.websocket_urlpatterns,
# add api.routing.websocket_urlpatterns when Order page works with websocket
TokenAuthMiddleware(
URLRouter(
chat.routing.websocket_urlpatterns,
# add api.routing.websocket_urlpatterns when Order page works with websocket
)
)
)

View File

@ -11,6 +11,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
import json
import os
import textwrap
from pathlib import Path
@ -34,6 +35,9 @@ DEBUG = False
STATIC_URL = "static/"
STATIC_ROOT = "/usr/src/static/"
# RoboSats version
with open("version.json") as f:
VERSION = json.load(f)
# SECURITY WARNING: don't run with debug turned on in production!
if config("DEVELOPMENT", default=False):
@ -92,6 +96,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"corsheaders",
"rest_framework",
"rest_framework.authtoken",
"django_celery_beat",
"django_celery_results",
"import_export",
@ -105,10 +110,13 @@ INSTALLED_APPS = [
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
],
}
SPECTACULAR_SETTINGS = {
"TITLE": "RoboSats REST API v0",
"TITLE": "RoboSats REST API",
"DESCRIPTION": textwrap.dedent(
"""
REST API Documentation for [RoboSats](https://learn.robosats.com) - A Simple and Private LN P2P Exchange
@ -123,7 +131,7 @@ SPECTACULAR_SETTINGS = {
"""
),
"VERSION": "0.1.0",
"VERSION": f"{VERSION['major']}.{VERSION['minor']}.{VERSION['patch']}",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
@ -145,9 +153,9 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# "django.middleware.csrf.CsrfViewMiddleware",
"robosats.middleware.DisableCSRFMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"robosats.middleware.RobotTokenSHA256AuthenticationMiddleWare",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",

View File

@ -13,12 +13,21 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from decouple import config
from django.conf import settings
from django.contrib import admin
from django.urls import include, path
VERSION = settings.VERSION
urlpatterns = [
path("admin/", admin.site.urls),
path("coordinator/", admin.site.urls),
path("api/", include("api.urls")),
# path('chat/', include('chat.urls')),
path("", include("frontend.urls")),
]
admin.site.site_header = f"RoboSats Coordinator: {config('COORDINATOR_ALIAS', cast=str, default='NoAlias')} (v{VERSION['major']}.{VERSION['minor']}.{VERSION['patch']})"
admin.site.index_title = "Coordinator administration"
admin.site.site_title = "RoboSats Coordinator Admin"