Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in
Toggle navigation
C
coco
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ops
coco
Commits
3148be06
Commit
3148be06
authored
Nov 08, 2017
by
ibuler
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Remove sdk from coco
parent
7b530dee
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
8 additions
and
596 deletions
+8
-596
app.py
coco/app.py
+1
-1
auth.py
coco/auth.py
+0
-131
exception.py
coco/exception.py
+0
-9
httpd.py
coco/httpd.py
+3
-1
interactive.py
coco/interactive.py
+3
-1
sdk.py
coco/sdk.py
+0
-452
utils.py
coco/utils.py
+1
-1
No files found.
coco/app.py
View file @
3148be06
...
...
@@ -2,12 +2,12 @@ import os
import
time
import
threading
import
logging
from
jms.service
import
AppService
from
.config
import
Config
from
.sshd
import
SSHServer
from
.httpd
import
HttpServer
from
.logging
import
create_logger
from
.sdk
import
AppService
__version__
=
'0.4.0'
...
...
coco/auth.py
deleted
100644 → 0
View file @
7b530dee
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
import
os
import
logging
from
.
import
utils
from
.exception
import
LoadAccessKeyError
class
AccessKeyAuth
(
object
):
def
__init__
(
self
,
access_key
):
self
.
id
=
access_key
.
id
self
.
secret
=
access_key
.
secret
def
sign_request
(
self
,
req
):
req
.
headers
[
'Date'
]
=
utils
.
http_date
()
signature
=
utils
.
make_signature
(
self
.
secret
)
req
.
headers
[
'Authorization'
]
=
"Sign {0}:{1}"
.
format
(
self
.
id
,
signature
)
return
req
class
AccessKey
(
object
):
def
__init__
(
self
,
id
=
None
,
secret
=
None
):
self
.
id
=
id
self
.
secret
=
secret
@staticmethod
def
clean
(
value
,
sep
=
':'
,
silent
=
False
):
try
:
id
,
secret
=
value
.
split
(
sep
)
except
(
AttributeError
,
ValueError
)
as
e
:
if
not
silent
:
raise
LoadAccessKeyError
(
e
)
return
''
,
''
else
:
return
id
,
secret
@classmethod
def
load_from_val
(
cls
,
val
,
**
kwargs
):
id
,
secret
=
cls
.
clean
(
val
,
**
kwargs
)
return
cls
(
id
=
id
,
secret
=
secret
)
@classmethod
def
load_from_env
(
cls
,
env
,
**
kwargs
):
value
=
os
.
environ
.
get
(
env
)
id
,
secret
=
cls
.
clean
(
value
,
**
kwargs
)
return
cls
(
id
=
id
,
secret
=
secret
)
@classmethod
def
load_from_f
(
cls
,
f
,
**
kwargs
):
value
=
''
if
isinstance
(
f
,
str
)
and
os
.
path
.
isfile
(
f
):
f
=
open
(
f
)
if
hasattr
(
f
,
'read'
):
for
line
in
f
:
if
line
and
not
line
.
strip
()
.
startswith
(
'#'
):
value
=
line
.
strip
()
break
f
.
close
()
id
,
secret
=
cls
.
clean
(
value
,
**
kwargs
)
return
cls
(
id
=
id
,
secret
=
secret
)
def
save_to_f
(
self
,
f
,
silent
=
False
):
if
isinstance
(
f
,
str
):
f
=
open
(
f
,
'w'
)
try
:
f
.
write
(
str
(
'{0}:{1}'
.
format
(
self
.
id
,
self
.
secret
)))
except
IOError
as
e
:
logging
.
error
(
'Save access key error: {}'
.
format
(
e
))
if
not
silent
:
raise
finally
:
f
.
close
()
def
__bool__
(
self
):
return
bool
(
self
.
id
and
self
.
secret
)
def
__str__
(
self
):
return
'{0}:{1}'
.
format
(
self
.
id
,
self
.
secret
)
def
__repr__
(
self
):
return
'{0}:{1}'
.
format
(
self
.
id
,
self
.
secret
)
class
AppAccessKey
(
AccessKey
):
"""使用Access key来认证"""
def
__init__
(
self
,
id
=
None
,
secret
=
None
):
super
()
.
__init__
(
id
=
id
,
secret
=
secret
)
self
.
app
=
None
def
set_app
(
self
,
app
):
self
.
app
=
app
@property
def
_key_env
(
self
):
return
self
.
app
.
config
[
'ACCESS_KEY_ENV'
]
@property
def
_key_val
(
self
):
return
self
.
app
.
config
[
'ACCESS_KEY'
]
@property
def
_key_file
(
self
):
return
self
.
app
.
config
[
'ACCESS_KEY_FILE'
]
def
load_from_conf_env
(
self
,
sep
=
':'
,
silent
=
False
):
return
super
()
.
load_from_env
(
self
.
_key_env
,
sep
=
sep
,
silent
=
silent
)
def
load_from_conf_val
(
self
,
sep
=
':'
,
silent
=
False
):
return
super
()
.
load_from_val
(
self
.
_key_val
,
sep
=
sep
,
silent
=
silent
)
def
load_from_conf_file
(
self
,
sep
=
':'
,
silent
=
False
):
return
super
()
.
load_from_f
(
self
.
_key_file
,
sep
=
sep
,
silent
=
silent
)
def
load
(
self
,
**
kwargs
):
"""Should return access_key_id, access_key_secret"""
for
method
in
[
self
.
load_from_conf_env
,
self
.
load_from_conf_val
,
self
.
load_from_conf_file
]:
try
:
return
method
(
**
kwargs
)
except
LoadAccessKeyError
:
continue
return
None
def
save_to_file
(
self
):
return
super
()
.
save_to_f
(
self
.
_key_file
)
\ No newline at end of file
coco/exception.py
View file @
3148be06
...
...
@@ -5,13 +5,4 @@ class PermissionFailed(Exception):
pass
class
LoadAccessKeyError
(
Exception
):
pass
class
RequestError
(
Exception
):
pass
class
ResponseError
(
Exception
):
pass
coco/httpd.py
View file @
3148be06
...
...
@@ -9,7 +9,9 @@ import tornado.httpclient
import
tornado.ioloop
import
tornado.gen
from
.models
import
User
,
Request
,
Client
,
WSProxy
# Todo: Remove for future
from
jms.models
import
User
from
.models
import
Request
,
Client
,
WSProxy
from
.interactive
import
InteractiveServer
...
...
coco/interactive.py
View file @
3148be06
...
...
@@ -3,11 +3,13 @@ import logging
import
socket
import
threading
# Todo remove
from
jms.models
import
Asset
,
SystemUser
from
.
import
char
from
.utils
import
TtyIOParser
,
wrap_with_line_feed
as
wr
,
\
wrap_with_primary
as
primary
,
wrap_with_warning
as
warning
from
.forward
import
ProxyServer
from
.models
import
Asset
,
SystemUser
from
.session
import
Session
logger
=
logging
.
getLogger
(
__file__
)
...
...
coco/sdk.py
deleted
100644 → 0
View file @
7b530dee
# -*- coding: utf-8 -*-
#
import
os
import
json
import
base64
import
logging
import
sys
import
paramiko
import
requests
import
time
from
requests.structures
import
CaseInsensitiveDict
from
cachetools
import
cached
,
TTLCache
from
.auth
import
AppAccessKey
,
AccessKeyAuth
from
.utils
import
sort_assets
,
PKey
,
timestamp_to_datetime_str
from
.exception
import
RequestError
,
ResponseError
from
.models
import
User
,
Asset
_USER_AGENT
=
'jms-sdk-py'
CACHED_TTL
=
os
.
environ
.
get
(
'CACHED_TTL'
,
30
)
logger
=
logging
.
getLogger
(
__file__
)
API_URL_MAPPING
=
{
'terminal-register'
:
'/api/applications/v1/terminal/'
,
'terminal-heatbeat'
:
'/api/applications/v1/terminal/heatbeat/'
,
'send-proxy-log'
:
'/api/audits/v1/proxy-log/receive/'
,
'finish-proxy-log'
:
'/api/audits/v1/proxy-log/
%
s/'
,
'send-command-log'
:
'/api/audits/v1/command-log/'
,
'send-record-log'
:
'/api/audits/v1/record-log/'
,
'user-auth'
:
'/api/users/v1/auth/'
,
'user-assets'
:
'/api/perms/v1/user/
%
s/assets/'
,
'user-asset-groups'
:
'/api/perms/v1/user/
%
s/asset-groups/'
,
'user-asset-groups-assets'
:
'/api/perms/v1/user/my/asset-groups-assets/'
,
'assets-of-group'
:
'/api/perms/v1/user/my/asset-group/
%
s/assets/'
,
'my-profile'
:
'/api/users/v1/profile/'
,
'system-user-auth-info'
:
'/api/assets/v1/system-user/
%
s/auth-info/'
,
'validate-user-asset-permission'
:
'/api/perms/v1/asset-permission/user/validate/'
,
}
class
Request
(
object
):
methods
=
{
'get'
:
requests
.
get
,
'post'
:
requests
.
post
,
'patch'
:
requests
.
patch
,
'put'
:
requests
.
put
,
'delete'
:
requests
.
delete
,
}
def
__init__
(
self
,
url
,
method
=
'get'
,
data
=
None
,
params
=
None
,
headers
=
None
,
content_type
=
'application/json'
):
self
.
url
=
url
self
.
method
=
method
self
.
params
=
params
or
{}
if
not
isinstance
(
headers
,
dict
):
headers
=
{}
self
.
headers
=
CaseInsensitiveDict
(
headers
)
self
.
headers
[
'Content-Type'
]
=
content_type
if
data
is
None
:
data
=
{}
self
.
data
=
json
.
dumps
(
data
)
def
do
(
self
):
result
=
self
.
methods
.
get
(
self
.
method
)(
url
=
self
.
url
,
headers
=
self
.
headers
,
data
=
self
.
data
,
params
=
self
.
params
)
return
result
class
AppRequests
(
object
):
def
__init__
(
self
,
endpoint
,
auth
=
None
):
self
.
auth
=
auth
self
.
endpoint
=
endpoint
@staticmethod
def
clean_result
(
resp
):
if
resp
.
status_code
>=
500
:
raise
ResponseError
(
"Response code is {0.status_code}: {0.text}"
.
format
(
resp
))
try
:
_
=
resp
.
json
()
except
json
.
JSONDecodeError
:
raise
ResponseError
(
"Response json couldn't be decode: {0.text}"
.
format
(
resp
))
else
:
return
resp
def
do
(
self
,
api_name
=
None
,
pk
=
None
,
method
=
'get'
,
use_auth
=
True
,
data
=
None
,
params
=
None
,
content_type
=
'application/json'
):
if
api_name
in
API_URL_MAPPING
:
path
=
API_URL_MAPPING
.
get
(
api_name
)
if
pk
and
'
%
s'
in
path
:
path
=
path
%
pk
else
:
path
=
'/'
url
=
self
.
endpoint
.
rstrip
(
'/'
)
+
path
req
=
Request
(
url
,
method
=
method
,
data
=
data
,
params
=
params
,
content_type
=
content_type
)
if
use_auth
:
if
not
self
.
auth
:
raise
RequestError
(
'Authentication required'
)
else
:
self
.
auth
.
sign_request
(
req
)
try
:
resp
=
req
.
do
()
except
(
requests
.
ConnectionError
,
requests
.
ConnectTimeout
)
as
e
:
raise
RequestError
(
"Connect endpoint {} error: {}"
.
format
(
self
.
endpoint
,
e
))
return
self
.
clean_result
(
resp
)
def
get
(
self
,
*
args
,
**
kwargs
):
kwargs
[
'method'
]
=
'get'
return
self
.
do
(
*
args
,
**
kwargs
)
def
post
(
self
,
*
args
,
**
kwargs
):
kwargs
[
'method'
]
=
'post'
return
self
.
do
(
*
args
,
**
kwargs
)
def
put
(
self
,
*
args
,
**
kwargs
):
kwargs
[
'method'
]
=
'put'
return
self
.
do
(
*
args
,
**
kwargs
)
def
patch
(
self
,
*
args
,
**
kwargs
):
kwargs
[
'method'
]
=
'patch'
return
self
.
do
(
*
args
,
**
kwargs
)
class
AppService
:
access_key_class
=
AppAccessKey
def
__init__
(
self
,
app
):
self
.
app
=
app
self
.
access_key
=
None
self
.
requests
=
AppRequests
(
app
.
config
[
'CORE_HOST'
])
def
initial
(
self
):
self
.
load_access_key
()
self
.
set_auth
()
self
.
valid_auth
()
def
load_access_key
(
self
):
# Must be get access key if not register it
self
.
access_key
=
self
.
access_key_class
()
self
.
access_key
.
set_app
(
self
.
app
)
self
.
access_key
=
self
.
access_key
.
load
()
if
self
.
access_key
is
None
:
logger
.
info
(
"No access key found, register it"
)
self
.
register_and_save
()
def
set_auth
(
self
):
self
.
requests
.
auth
=
AccessKeyAuth
(
self
.
access_key
)
def
valid_auth
(
self
):
delay
=
1
while
delay
<
300
:
if
self
.
heartbeat
()
is
None
:
msg
=
"Access key is not valid or need admin "
\
"accepted, waiting
%
d s"
%
delay
logger
.
info
(
msg
)
delay
+=
3
time
.
sleep
(
3
)
else
:
break
if
delay
>=
300
:
logger
.
info
(
"Start timeout"
)
sys
.
exit
()
def
register_and_save
(
self
):
self
.
register
()
self
.
save_access_key
()
def
save_access_key
(
self
):
self
.
access_key
.
save_to_file
()
def
register
(
self
):
try
:
resp
=
self
.
requests
.
post
(
'terminal-register'
,
data
=
{
'name'
:
self
.
app
.
name
},
use_auth
=
False
)
except
(
RequestError
,
ResponseError
)
as
e
:
logger
.
error
(
e
)
return
if
resp
.
status_code
==
201
:
access_key
=
resp
.
json
()[
'access_key'
]
access_key_id
=
access_key
[
'id'
]
access_key_secret
=
access_key
[
'secret'
]
self
.
access_key
=
self
.
access_key_class
(
id
=
access_key_id
,
secret
=
access_key_secret
)
self
.
access_key
.
set_app
(
self
.
app
)
logger
.
info
(
'Register app success:
%
s'
%
access_key_id
,)
elif
resp
.
status_code
==
409
:
msg
=
'{} exist already, register failed'
.
format
(
self
.
app
.
name
)
logging
.
error
(
msg
)
sys
.
exit
()
else
:
logging
.
error
(
'Register terminal {} failed unknown: {}'
.
format
(
self
.
app
.
name
,
resp
.
json
()))
sys
.
exit
()
def
heartbeat
(
self
):
"""和Jumpserver维持心跳, 当Terminal断线后,jumpserver可以知晓
Todo: Jumpserver发送的任务也随heatbeat返回, 并执行,如 断开某用户
"""
try
:
resp
=
self
.
requests
.
post
(
'terminal-heatbeat'
,
use_auth
=
True
)
except
(
ResponseError
,
RequestError
):
return
None
if
resp
.
status_code
==
201
:
return
True
else
:
return
None
def
check_user_credential
(
self
,
username
,
password
=
""
,
pubkey
=
""
,
remote_addr
=
"8.8.8.8"
,
login_type
=
'ST'
):
data
=
{
'username'
:
username
,
'password'
:
password
,
'public_key'
:
pubkey
,
'remote_addr'
:
remote_addr
,
'login_type'
:
login_type
,
}
try
:
resp
=
self
.
requests
.
post
(
'user-auth'
,
data
=
data
,
use_auth
=
False
)
except
(
ResponseError
,
RequestError
):
return
None
if
resp
.
status_code
==
200
:
user
=
User
.
from_json
(
resp
.
json
()[
"user"
])
return
user
else
:
return
None
def
check_user_cookie
(
self
,
session_id
,
csrf_token
):
pass
def
validate_user_asset_permission
(
self
,
user_id
,
asset_id
,
system_user_id
):
"""验证用户是否有登录该资产的权限"""
params
=
{
'user_id'
:
user_id
,
'asset_id'
:
asset_id
,
'system_user_id'
:
system_user_id
,
}
r
,
content
=
self
.
requests
.
get
(
'validate-user-asset-permission'
,
use_auth
=
True
,
params
=
params
)
if
r
.
status_code
==
200
:
return
True
else
:
return
False
def
get_system_user_auth_info
(
self
,
system_user
):
"""获取系统用户的认证信息: 密码, ssh私钥"""
r
,
content
=
self
.
requests
.
get
(
'system-user-auth-info'
,
pk
=
system_user
[
'id'
])
if
r
.
status_code
==
200
:
password
=
content
[
'password'
]
or
''
private_key_string
=
content
[
'private_key'
]
or
''
if
private_key_string
and
private_key_string
.
find
(
'PRIVATE KEY'
):
private_key
=
PKey
.
from_string
(
private_key_string
)
else
:
private_key
=
None
if
isinstance
(
private_key
,
paramiko
.
PKey
)
\
and
len
(
private_key_string
.
split
(
'
\n
'
))
>
2
:
private_key_log_msg
=
private_key_string
.
split
(
'
\n
'
)[
1
]
else
:
private_key_log_msg
=
'None'
logging
.
debug
(
'Get system user
%
s password:
%
s*** key:
%
s***'
%
(
system_user
[
'username'
],
password
[:
4
],
private_key_log_msg
))
return
password
,
private_key
else
:
logging
.
warning
(
'Get system user
%
s password or private key failed'
%
system_user
[
'username'
])
return
None
,
None
def
send_proxy_log
(
self
,
data
):
"""
:param data: 格式如下
data = {
"user": "username",
"asset": "name",
"system_user": "web",
"login_type": "ST",
"was_failed": 0,
"date_start": timestamp,
}
"""
assert
isinstance
(
data
.
get
(
'date_start'
),
(
int
,
float
))
data
[
'date_start'
]
=
timestamp_to_datetime_str
(
data
[
'date_start'
])
data
[
'is_failed'
]
=
1
if
data
.
get
(
'is_failed'
)
else
0
r
,
content
=
self
.
requests
.
post
(
'send-proxy-log'
,
data
=
data
,
use_auth
=
True
)
if
r
.
status_code
!=
201
:
logging
.
warning
(
'Send proxy log failed:
%
s'
%
content
)
return
None
else
:
return
content
[
'id'
]
def
finish_proxy_log
(
self
,
data
):
""" 退出登录资产后, 需要汇报结束 时间等
:param data: 格式如下
data = {
"proxy_log_id": 123123,
"date_finished": timestamp,
}
"""
assert
isinstance
(
data
.
get
(
'date_finished'
),
(
int
,
float
))
data
[
'date_finished'
]
=
timestamp_to_datetime_str
(
data
[
'date_finished'
])
data
[
'is_failed'
]
=
1
if
data
.
get
(
'is_failed'
)
else
0
data
[
'is_finished'
]
=
1
proxy_log_id
=
data
.
get
(
'proxy_log_id'
)
or
0
r
,
content
=
self
.
requests
.
patch
(
'finish-proxy-log'
,
pk
=
proxy_log_id
,
data
=
data
)
if
r
.
status_code
!=
200
:
logging
.
warning
(
'Finish proxy log failed:
%
s'
%
proxy_log_id
)
return
False
return
True
def
send_command_log
(
self
,
data
):
"""用户输入命令后发送到Jumpserver保存审计
:param data: 格式如下
data = [{
"proxy_log_id": 22,
"user": "admin",
"asset": "localhost",
"system_user": "web",
"command_no": 1,
"command": "ls",
"output": cmd_output, ## base64.b64encode(output),
"timestamp": timestamp,
},..]
"""
assert
isinstance
(
data
,
(
dict
,
list
))
if
isinstance
(
data
,
dict
):
data
=
[
data
]
for
d
in
data
:
if
not
d
.
get
(
'output'
):
continue
output
=
d
[
'output'
]
.
encode
(
'utf-8'
,
'ignore'
)
d
[
'output'
]
=
base64
.
b64encode
(
output
)
.
decode
(
"utf-8"
)
result
,
content
=
self
.
requests
.
post
(
'send-command-log'
,
data
=
data
)
if
result
.
status_code
!=
201
:
logging
.
warning
(
'Send command log failed:
%
s'
%
content
)
return
False
return
True
def
send_record_log
(
self
,
data
):
"""将输入输出发送给Jumpserver, 用来录像回放
:param data: 格式如下
data = [{
"proxy_log_id": 22,
"output": "backend server output, either input or output",
"timestamp": timestamp,
}, ...]
"""
assert
isinstance
(
data
,
(
dict
,
list
))
if
isinstance
(
data
,
dict
):
data
=
[
data
]
for
d
in
data
:
if
d
.
get
(
'output'
)
and
isinstance
(
d
[
'output'
],
str
):
d
[
'output'
]
=
d
[
'output'
]
.
encode
(
'utf-8'
)
d
[
'output'
]
=
base64
.
b64encode
(
d
[
'output'
])
result
,
content
=
self
.
requests
.
post
(
'send-record-log'
,
data
=
data
)
if
result
.
status_code
!=
201
:
logging
.
warning
(
'Send record log failed:
%
s'
%
content
)
return
False
return
True
@cached
(
TTLCache
(
maxsize
=
100
,
ttl
=
60
))
def
get_user_assets
(
self
,
user
):
"""获取用户被授权的资产列表
[{'hostname': 'x', 'ip': 'x', ...,
'system_users_granted': [{'id': 1, 'username': 'x',..}]
]
"""
try
:
resp
=
self
.
requests
.
get
(
'user-assets'
,
pk
=
user
.
id
,
use_auth
=
True
)
except
(
RequestError
,
ResponseError
):
return
[]
if
resp
.
status_code
==
200
:
assets
=
Asset
.
from_multi_json
(
resp
.
json
())
else
:
return
[]
assets
=
sort_assets
(
assets
,
self
.
app
.
config
[
"ASSET_LIST_SORT_BY"
])
return
assets
@cached
(
TTLCache
(
maxsize
=
100
,
ttl
=
60
))
def
get_user_asset_groups
(
self
,
user
):
"""获取用户授权的资产组列表
[{'name': 'x', 'comment': 'x', 'assets_amount': 2}, ..]
"""
try
:
resp
=
self
.
requests
.
get
(
'user-asset-groups'
,
pk
=
user
.
id
,
use_auth
=
True
)
except
(
ResponseError
,
RequestError
):
return
[]
if
resp
.
status_code
==
200
:
asset_groups
=
content
else
:
asset_groups
=
[]
asset_groups
=
[
asset_group
for
asset_group
in
asset_groups
]
return
to_dotmap
(
asset_groups
)
@cached
(
TTLCache
(
maxsize
=
100
,
ttl
=
60
))
def
get_user_asset_groups_assets
(
self
,
user
):
"""获取用户授权的资产组列表及下面的资产
[{'name': 'x', 'comment': 'x', 'assets': []}, ..]
"""
r
,
content
=
self
.
requests
.
get
(
'user-asset-groups-assets'
,
pk
=
user
[
'id'
],
use_auth
=
True
)
if
r
.
status_code
==
200
:
asset_groups_assets
=
content
else
:
asset_groups_assets
=
[]
return
to_dotmap
(
asset_groups_assets
)
@cached
(
TTLCache
(
maxsize
=
100
,
ttl
=
60
))
def
get_assets_in_group
(
self
,
asset_group_id
):
"""获取用户在该资产组下的资产, 并非该资产组下的所有资产,而是授权了的
返回资产列表, 和获取资产格式一致
:param asset_group_id: 资产组id
"""
r
,
content
=
self
.
requests
.
get
(
'assets-of-group'
,
use_auth
=
True
,
pk
=
asset_group_id
)
if
r
.
status_code
==
200
:
assets
=
content
else
:
assets
=
[]
for
asset
in
assets
:
asset
[
'system_users'
]
=
\
[
system_user
for
system_user
in
asset
.
get
(
'system_users_granted'
)]
assets
=
sort_assets
(
assets
)
return
to_dotmap
([
asset
for
asset
in
assets
])
coco/utils.py
View file @
3148be06
...
...
@@ -253,7 +253,7 @@ def wrap_with_title(text):
def
b64encode_as_string
(
data
):
return
to_string
(
base64
.
b64encode
(
data
)
)
return
base64
.
b64encode
(
data
)
.
decode
(
"utf-8"
)
def
split_string_int
(
s
):
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment