add token loader, .flask_sqlalchemy, jsonencode(), base2048
This commit is contained in:
parent
dfa4309216
commit
9f75d983ba
11 changed files with 462 additions and 42 deletions
|
|
@ -1,5 +1,12 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
- Add auth loaders i.e. `sqlalchemy.require_auth_base()`, `flask_sqlalchemy`
|
||||||
|
- Improve JSON handling in `flask_restx`
|
||||||
|
- Add base2048 (i.e. BIP-39) codec
|
||||||
|
- Add `split_bits()` and `join_bits()`
|
||||||
|
|
||||||
## 0.2.3
|
## 0.2.3
|
||||||
|
|
||||||
- Bug fixes in `classtools` and `sqlalchemy`
|
- Bug fixes in `classtools` and `sqlalchemy`
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ flask = [
|
||||||
"Flask>=2.0.0",
|
"Flask>=2.0.0",
|
||||||
"Flask-RestX"
|
"Flask-RestX"
|
||||||
]
|
]
|
||||||
|
flask_sqlalchemy = [
|
||||||
|
"Flask-SqlAlchemy"
|
||||||
|
]
|
||||||
peewee = [
|
peewee = [
|
||||||
"peewee>=3.0.0, <4.0"
|
"peewee>=3.0.0, <4.0"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,20 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .iding import Siq, SiqCache, SiqType, SiqGen
|
from .iding import Siq, SiqCache, SiqType, SiqGen
|
||||||
from .codecs import StringCase, cb32encode, cb32decode, jsonencode
|
from .codecs import StringCase, cb32encode, cb32decode, jsonencode, want_bytes, want_str, b2048encode, b2048decode
|
||||||
from .bits import count_ones, mask_shift
|
from .bits import count_ones, mask_shift, split_bits, join_bits
|
||||||
from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource
|
from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource
|
||||||
from .functools import deprecated, not_implemented
|
from .functools import deprecated, not_implemented
|
||||||
from .classtools import Wanted, Incomplete
|
from .classtools import Wanted, Incomplete
|
||||||
from .itertools import makelist, kwargs_prefix
|
from .itertools import makelist, kwargs_prefix
|
||||||
from .i18n import I18n, JsonI18n, TomlI18n
|
from .i18n import I18n, JsonI18n, TomlI18n
|
||||||
|
|
||||||
__version__ = "0.2.3"
|
__version__ = "0.3.0-dev21"
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',
|
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',
|
||||||
'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource', 'DictConfigSource',
|
'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource', 'DictConfigSource',
|
||||||
'deprecated', 'not_implemented', 'Wanted', 'Incomplete', 'jsonencode',
|
'deprecated', 'not_implemented', 'Wanted', 'Incomplete', 'jsonencode',
|
||||||
'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift'
|
'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift',
|
||||||
|
'want_bytes', 'want_str', 'version', 'b2048encode', 'split_bits', 'join_bits', 'b2048decode'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ This software is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
def mask_shift(n: int, mask: int) -> int:
|
def mask_shift(n: int, mask: int) -> int:
|
||||||
'''
|
'''
|
||||||
Select the bits from n chosen by mask, least significant first.
|
Select the bits from n chosen by mask, least significant first.
|
||||||
|
|
@ -45,4 +47,53 @@ def count_ones(n: int) -> int:
|
||||||
n >>= 1
|
n >>= 1
|
||||||
return ones
|
return ones
|
||||||
|
|
||||||
__all__ = ('count_ones', 'mask_shift')
|
def split_bits(buf: bytes, nbits: int) -> list[int]:
|
||||||
|
'''
|
||||||
|
Split a bytestring into chunks of equal size, and interpret each chunk as an unsigned integer.
|
||||||
|
|
||||||
|
XXX DOES NOT WORK DO NOT USE!!!!!!!!
|
||||||
|
'''
|
||||||
|
mem = memoryview(buf)
|
||||||
|
chunk_size = nbits // math.gcd(nbits, 8)
|
||||||
|
est_len = math.ceil(len(buf) * 8 / nbits)
|
||||||
|
mask_n = chunk_size * 8 // nbits
|
||||||
|
numbers = []
|
||||||
|
|
||||||
|
off = 0
|
||||||
|
while off < len(buf):
|
||||||
|
chunk = mem[off:off+chunk_size].tobytes()
|
||||||
|
if len(chunk) < chunk_size:
|
||||||
|
chunk = chunk + b'\0' * (chunk_size - len(chunk))
|
||||||
|
num = int.from_bytes(chunk, 'big')
|
||||||
|
for j in range(mask_n):
|
||||||
|
numbers.append(mask_shift(num, ((1 << nbits) - 1) << ((mask_n - 1 - j) * nbits) ))
|
||||||
|
off += chunk_size
|
||||||
|
assert sum(numbers[est_len:]) == 0, str(f'{chunk_size=} {len(numbers)=} {est_len=} {numbers[est_len:]=}')
|
||||||
|
return numbers[:est_len]
|
||||||
|
|
||||||
|
|
||||||
|
def join_bits(l: list[int], nbits: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Concatenate a list of integers into a bytestring.
|
||||||
|
"""
|
||||||
|
chunk_size = nbits // math.gcd(nbits, 8)
|
||||||
|
chunk = 0
|
||||||
|
mask_n = chunk_size * 8 // nbits
|
||||||
|
ou = b''
|
||||||
|
|
||||||
|
chunk, j = 0, mask_n - 1
|
||||||
|
for num in l:
|
||||||
|
chunk |= num << nbits * j
|
||||||
|
if j <= 0:
|
||||||
|
ou += chunk.to_bytes(chunk_size, 'big')
|
||||||
|
chunk, j = 0, mask_n - 1
|
||||||
|
else:
|
||||||
|
j -= 1
|
||||||
|
else:
|
||||||
|
if chunk != 0:
|
||||||
|
ou += chunk.to_bytes(chunk_size, 'big')
|
||||||
|
return ou
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('count_ones', 'mask_shift', 'split_bits', 'join_bits')
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,36 @@ import base64
|
||||||
import datetime
|
import datetime
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from suou.functools import not_implemented
|
from .bits import split_bits, join_bits
|
||||||
|
from .functools import deprecated
|
||||||
|
|
||||||
|
# yes, I know ItsDangerous implements that as well, but remember
|
||||||
|
# what happened with werkzeug.safe_str_cmp()?
|
||||||
|
# see also: https://gitlab.com/wcorrales/quart-csrf/-/issues/1
|
||||||
|
|
||||||
|
def want_bytes(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> bytes:
|
||||||
|
"""
|
||||||
|
Force a string into its bytes representation.
|
||||||
|
|
||||||
|
By default, UTF-8 encoding is assumed.
|
||||||
|
"""
|
||||||
|
if isinstance(s, str):
|
||||||
|
s = s.encode(encoding, errors)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def want_str(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> str:
|
||||||
|
"""
|
||||||
|
Convert a bytestring into a text string.
|
||||||
|
|
||||||
|
By default, UTF-8 encoding is assumed.
|
||||||
|
"""
|
||||||
|
if isinstance(s, bytes):
|
||||||
|
s = s.decode(encoding, errors)
|
||||||
|
return s
|
||||||
|
|
||||||
B32_TO_CROCKFORD = str.maketrans(
|
B32_TO_CROCKFORD = str.maketrans(
|
||||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||||
|
|
@ -33,49 +59,157 @@ CROCKFORD_TO_B32 = str.maketrans(
|
||||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||||
'=')
|
'=')
|
||||||
|
|
||||||
|
BIP39_WORD_LIST = """
|
||||||
|
abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action
|
||||||
|
actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport
|
||||||
|
aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor
|
||||||
|
ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch
|
||||||
|
arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume
|
||||||
|
asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome
|
||||||
|
awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle
|
||||||
|
beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid
|
||||||
|
bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb
|
||||||
|
bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring
|
||||||
|
brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus
|
||||||
|
business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon
|
||||||
|
capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution
|
||||||
|
cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry
|
||||||
|
chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk
|
||||||
|
clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color
|
||||||
|
column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral
|
||||||
|
core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek
|
||||||
|
crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current
|
||||||
|
curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide
|
||||||
|
decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive
|
||||||
|
describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma
|
||||||
|
dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin
|
||||||
|
domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb
|
||||||
|
dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight
|
||||||
|
either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact
|
||||||
|
end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal
|
||||||
|
equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite
|
||||||
|
exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face
|
||||||
|
faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee
|
||||||
|
feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag
|
||||||
|
flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum
|
||||||
|
forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain
|
||||||
|
galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost
|
||||||
|
giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla
|
||||||
|
gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt
|
||||||
|
guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height
|
||||||
|
hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital
|
||||||
|
host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal
|
||||||
|
illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform
|
||||||
|
inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island
|
||||||
|
isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen
|
||||||
|
keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language
|
||||||
|
laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens
|
||||||
|
leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely
|
||||||
|
long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal
|
||||||
|
man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix
|
||||||
|
matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh
|
||||||
|
message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile
|
||||||
|
model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie
|
||||||
|
much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck
|
||||||
|
need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable
|
||||||
|
note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office
|
||||||
|
often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient
|
||||||
|
original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda
|
||||||
|
panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican
|
||||||
|
pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe
|
||||||
|
pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion
|
||||||
|
position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print
|
||||||
|
priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull
|
||||||
|
pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote
|
||||||
|
rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel
|
||||||
|
rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind
|
||||||
|
remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion
|
||||||
|
reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance
|
||||||
|
roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute
|
||||||
|
same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen
|
||||||
|
script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session
|
||||||
|
settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug
|
||||||
|
shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt
|
||||||
|
skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer
|
||||||
|
social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak
|
||||||
|
special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel
|
||||||
|
stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove
|
||||||
|
strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun
|
||||||
|
sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim
|
||||||
|
swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant
|
||||||
|
tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time
|
||||||
|
tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch
|
||||||
|
tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick
|
||||||
|
trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist
|
||||||
|
two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until
|
||||||
|
unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve
|
||||||
|
van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view
|
||||||
|
village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want
|
||||||
|
warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale
|
||||||
|
what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf
|
||||||
|
woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo
|
||||||
|
""".split()
|
||||||
|
|
||||||
|
BIP39_DECODE_MATRIX = {v[:4]: i for i, v in enumerate(BIP39_WORD_LIST)}
|
||||||
|
|
||||||
def cb32encode(val: bytes) -> str:
|
def cb32encode(val: bytes) -> str:
|
||||||
'''
|
'''
|
||||||
Encode bytes in Crockford Base32.
|
Encode bytes in Crockford Base32.
|
||||||
'''
|
'''
|
||||||
return base64.b32encode(val).decode('ascii').translate(B32_TO_CROCKFORD)
|
return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD)
|
||||||
|
|
||||||
|
|
||||||
def cb32decode(val: bytes | str) -> str:
|
def cb32decode(val: bytes | str) -> str:
|
||||||
'''
|
'''
|
||||||
Decode bytes from Crockford Base32.
|
Decode bytes from Crockford Base32.
|
||||||
'''
|
'''
|
||||||
if isinstance(val, str):
|
return base64.b32decode(want_bytes(val).upper().translate(CROCKFORD_TO_B32) + b'=' * ((5 - len(val) % 5) % 5))
|
||||||
val = val.encode('ascii')
|
|
||||||
return base64.b32decode(val.upper().translate(CROCKFORD_TO_B32) + b'=' * ((5 - len(val) % 5) % 5))
|
|
||||||
|
|
||||||
def b32lencode(val: bytes) -> str:
|
def b32lencode(val: bytes) -> str:
|
||||||
'''
|
'''
|
||||||
Encode bytes as a lowercase base32 string, with trailing '=' stripped.
|
Encode bytes as a lowercase base32 string, with trailing '=' stripped.
|
||||||
'''
|
'''
|
||||||
return base64.b32encode(val).decode('ascii').rstrip('=').lower()
|
return want_str(base64.b32encode(val)).rstrip('=').lower()
|
||||||
|
|
||||||
def b32ldecode(val: bytes | str) -> bytes:
|
def b32ldecode(val: bytes | str) -> bytes:
|
||||||
'''
|
'''
|
||||||
Decode a lowercase base32 encoded byte sequence. Padding is managed automatically.
|
Decode a lowercase base32 encoded byte sequence. Padding is managed automatically.
|
||||||
'''
|
'''
|
||||||
if isinstance(val, str):
|
return base64.b32decode(want_bytes(val).upper() + b'=' * ((5 - len(val) % 5) % 5))
|
||||||
val = val.encode('ascii')
|
|
||||||
return base64.b32decode(val.upper() + b'=' * ((5 - len(val) % 5) % 5))
|
|
||||||
|
|
||||||
def b64encode(val: bytes, *, strip: bool = True) -> str:
|
def b64encode(val: bytes, *, strip: bool = True) -> str:
|
||||||
'''
|
'''
|
||||||
Wrapper around base64.urlsafe_b64encode() which also strips trailing '=' and leading 'A'.
|
Wrapper around base64.urlsafe_b64encode() which also strips trailing '=' and leading 'A'.
|
||||||
'''
|
'''
|
||||||
b = base64.urlsafe_b64encode(val).decode('ascii')
|
b = want_str(base64.urlsafe_b64encode(val))
|
||||||
return b.lstrip('A').rstrip('=') if strip else b
|
return b.lstrip('A').rstrip('=') if strip else b
|
||||||
|
|
||||||
def b64decode(val: bytes | str) -> bytes:
|
def b64decode(val: bytes | str) -> bytes:
|
||||||
'''
|
'''
|
||||||
Wrapper around base64.urlsafe_b64decode() which deals with padding.
|
Wrapper around base64.urlsafe_b64decode() which deals with padding.
|
||||||
'''
|
'''
|
||||||
if isinstance(val, str):
|
return base64.urlsafe_b64decode(want_bytes(val).replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4))
|
||||||
val = val.encode('ascii')
|
|
||||||
return base64.urlsafe_b64decode(val.replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4))
|
def b2048encode(val: bytes) -> str:
|
||||||
|
'''
|
||||||
|
Encode a bytestring using the BIP-39 wordlist.
|
||||||
|
'''
|
||||||
|
return ' '.join(BIP39_WORD_LIST[x] for x in split_bits(val, 11))
|
||||||
|
|
||||||
|
|
||||||
|
def b2048decode(val: bytes | str, *, strip = True) -> bytes:
|
||||||
|
"""
|
||||||
|
Decode a BIP-39 encoded string into bytes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
words = [BIP39_DECODE_MATRIX[x[:4]] for x in re.sub(r'[^a-z]+', ' ', want_str(val).lower()).split()]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError('illegal character')
|
||||||
|
b = join_bits(words, 11)
|
||||||
|
if strip:
|
||||||
|
assert b[math.ceil(len(words) * 11 / 8):].rstrip(b'\0') == b''
|
||||||
|
b = b[:math.ceil(len(words) * 11 / 8)]
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
def _json_default(func = None) -> Callable[Any, str | list | dict]:
|
def _json_default(func = None) -> Callable[Any, str | list | dict]:
|
||||||
def default_converter(obj: Any) -> str | list | dict:
|
def default_converter(obj: Any) -> str | list | dict:
|
||||||
|
|
@ -89,10 +223,12 @@ def _json_default(func = None) -> Callable[Any, str | list | dict]:
|
||||||
|
|
||||||
def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] = (',', ':'), default: Callable | None = None, **kwargs) -> str:
|
def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] = (',', ':'), default: Callable | None = None, **kwargs) -> str:
|
||||||
'''
|
'''
|
||||||
JSON encoder with stricter and smarter defaults.
|
json.dumps() but with stricter and smarter defaults, i.e. no whitespace in separators, and encoding dates as ISO strings.
|
||||||
'''
|
'''
|
||||||
return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs)
|
return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs)
|
||||||
|
|
||||||
|
jsondecode = deprecated('just use json.loads()')(json.loads)
|
||||||
|
|
||||||
class StringCase(enum.Enum):
|
class StringCase(enum.Enum):
|
||||||
"""
|
"""
|
||||||
Enum values used by regex validators and storage converters.
|
Enum values used by regex validators and storage converters.
|
||||||
|
|
@ -106,7 +242,7 @@ class StringCase(enum.Enum):
|
||||||
AS_IS = 0
|
AS_IS = 0
|
||||||
LOWER = FORCE_LOWER = 1
|
LOWER = FORCE_LOWER = 1
|
||||||
UPPER = FORCE_UPPER = 2
|
UPPER = FORCE_UPPER = 2
|
||||||
## difference between above and below is in storage and representation.
|
## difference between above and below is in storage and representation
|
||||||
IGNORE_LOWER = IGNORE = 3
|
IGNORE_LOWER = IGNORE = 3
|
||||||
IGNORE_UPPER = 4
|
IGNORE_UPPER = 4
|
||||||
|
|
||||||
|
|
@ -128,5 +264,5 @@ class StringCase(enum.Enum):
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode'
|
'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode'
|
||||||
'StringCase'
|
'StringCase', 'want_bytes', 'want_str', 'jsondecode'
|
||||||
)
|
)
|
||||||
|
|
@ -14,7 +14,8 @@ This software is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, g, request
|
from typing import Any
|
||||||
|
from flask import Flask, current_app, g, request
|
||||||
from .i18n import I18n
|
from .i18n import I18n
|
||||||
from .configparse import ConfigOptions
|
from .configparse import ConfigOptions
|
||||||
|
|
||||||
|
|
@ -57,6 +58,14 @@ def add_i18n(app: Flask, i18n: I18n, var_name: str = 'T', *,
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
__all__ = ('add_context_from_config', 'add_i18n')
|
def get_flask_conf(key: str, default = None, *, app: Flask | None = None) -> Any:
|
||||||
|
'''
|
||||||
|
Get a Flask configuration value
|
||||||
|
'''
|
||||||
|
if not app:
|
||||||
|
app = current_app
|
||||||
|
return app.config.get(key, default)
|
||||||
|
|
||||||
|
__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,44 @@ This software is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Mapping
|
from typing import Any, Mapping
|
||||||
|
import warnings
|
||||||
|
from flask import current_app, make_response
|
||||||
from flask_restx import Api as _Api
|
from flask_restx import Api as _Api
|
||||||
|
|
||||||
|
from .codecs import jsonencode
|
||||||
|
|
||||||
|
|
||||||
|
def output_json(data, code, headers=None):
|
||||||
|
"""Makes a Flask response with a JSON encoded body.
|
||||||
|
|
||||||
|
The difference with flask_restx.representations handler of the
|
||||||
|
same name is suou.codecs.jsonencode() being used in place of plain json.dumps().
|
||||||
|
|
||||||
|
Opinionated: some RESTX_JSON settings are ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings: dict = current_app.config.get("RESTX_JSON", {}).copy()
|
||||||
|
settings.pop('indent', 0)
|
||||||
|
settings.pop('separators', 0)
|
||||||
|
except TypeError:
|
||||||
|
warnings.warn('illegal value for RESTX_JSON', UserWarning)
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
# always end the json dumps with a new line
|
||||||
|
# see https://github.com/mitsuhiko/flask/pull/1262
|
||||||
|
dumped = jsonencode(data, **settings) + "\n"
|
||||||
|
|
||||||
|
resp = make_response(dumped, code)
|
||||||
|
resp.headers.extend(headers or {})
|
||||||
|
return resp
|
||||||
|
|
||||||
class Api(_Api):
|
class Api(_Api):
|
||||||
"""
|
"""
|
||||||
Fix Api() class by remapping .message to .error
|
Improved flask_restx.Api() with better defaults.
|
||||||
|
|
||||||
|
Notably, all JSON is whitespace-free and .message is remapped to .error
|
||||||
"""
|
"""
|
||||||
def handle_error(self, e):
|
def handle_error(self, e):
|
||||||
res = super().handle_error(e)
|
res = super().handle_error(e)
|
||||||
|
|
@ -27,5 +59,9 @@ class Api(_Api):
|
||||||
res['error'] = res['message']
|
res['error'] = res['message']
|
||||||
del res['message']
|
del res['message']
|
||||||
return res
|
return res
|
||||||
|
def __init__(self, *a, **ka):
|
||||||
|
super().__init__(*a, **ka)
|
||||||
|
self.representations['application/json'] = output_json
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('Api',)
|
__all__ = ('Api',)
|
||||||
66
src/suou/flask_sqlalchemy.py
Normal file
66
src/suou/flask_sqlalchemy.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""
|
||||||
|
Utilities for Flask-SQLAlchemy binding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Copyright (c) 2025 Sakuragasaki46.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
See LICENSE for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
This software is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any, Callable, Never
|
||||||
|
|
||||||
|
from flask import abort, request
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session
|
||||||
|
|
||||||
|
from .sqlalchemy import require_auth_base
|
||||||
|
|
||||||
|
class FlaskAuthSrc(AuthSrc):
|
||||||
|
'''
|
||||||
|
|
||||||
|
'''
|
||||||
|
db: SQLAlchemy
|
||||||
|
def __init__(self, db: SQLAlchemy):
|
||||||
|
super().__init__()
|
||||||
|
self.db = db
|
||||||
|
def get_session(self) -> Session:
|
||||||
|
return self.db.session
|
||||||
|
def get_token(self):
|
||||||
|
return request.authorization.token
|
||||||
|
|
||||||
|
def invalid_exc(self, msg: str = 'validation failed') -> Never:
|
||||||
|
abort(400, msg)
|
||||||
|
def required_exc(self):
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]:
|
||||||
|
"""
|
||||||
|
Make an auth_required() decorator for Flask views.
|
||||||
|
|
||||||
|
This looks for a token in the Authorization header, validates it, loads the
|
||||||
|
appropriate object, and injects it as the user= parameter.
|
||||||
|
|
||||||
|
cls is a SQLAlchemy table.
|
||||||
|
db is a flask_sqlalchemy.SQLAlchemy() binding.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
auth_required = require_auth(User, db)
|
||||||
|
|
||||||
|
@route('/admin')
|
||||||
|
@auth_required(validators=[lambda x: x.is_administrator])
|
||||||
|
def super_secret_stuff(user):
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
return partial(require_auth_base, cls=cls, src=FlaskAuthSrc(db))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('require_auth', )
|
||||||
|
|
@ -73,7 +73,7 @@ def connect_reconnect(db):
|
||||||
|
|
||||||
return db
|
return db
|
||||||
|
|
||||||
## END async helperss
|
## END async helpers for Peewee
|
||||||
|
|
||||||
class RegexCharField(CharField):
|
class RegexCharField(CharField):
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,17 @@ This software is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from abc import ABC
|
||||||
|
from base64 import b64decode
|
||||||
|
from typing import Any, Callable
|
||||||
from itsdangerous import TimestampSigner
|
from itsdangerous import TimestampSigner
|
||||||
|
|
||||||
from suou.iding import Siq
|
from .codecs import want_str
|
||||||
|
from .iding import Siq
|
||||||
|
|
||||||
class UserSigner(TimestampSigner):
|
class UserSigner(TimestampSigner):
|
||||||
"""
|
"""
|
||||||
Instance itsdangerous.TimestampSigner() from a user ID.
|
itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities.
|
||||||
|
|
||||||
XXX UNTESTED!!!
|
|
||||||
"""
|
"""
|
||||||
user_id: int
|
user_id: int
|
||||||
def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs):
|
def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs):
|
||||||
|
|
@ -30,4 +32,21 @@ class UserSigner(TimestampSigner):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
def token(self) -> str:
|
def token(self) -> str:
|
||||||
return self.sign(Siq(self.user_id).to_base64()).decode('ascii')
|
return self.sign(Siq(self.user_id).to_base64()).decode('ascii')
|
||||||
|
@classmethod
|
||||||
|
def split_token(cls, /, token: str | bytes) :
|
||||||
|
a, b, c = want_str(token).rsplit('.', 2)
|
||||||
|
return b64decode(a), b, b64decode(c)
|
||||||
|
|
||||||
|
|
||||||
|
class HasSigner(ABC):
|
||||||
|
'''
|
||||||
|
Abstract base class for INTERNAL USE.
|
||||||
|
'''
|
||||||
|
signer: Callable[Any, UserSigner]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __instancehook__(cls, obj) -> bool:
|
||||||
|
return callable(getattr(obj, 'signer', None))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('UserSigner', )
|
||||||
|
|
@ -16,33 +16,39 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable, Iterable, Never, TypeVar
|
||||||
import warnings
|
import warnings
|
||||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, text
|
from sqlalchemy import CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, select, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, declarative_base as _declarative_base
|
from sqlalchemy.orm import DeclarativeBase, Session, declarative_base as _declarative_base
|
||||||
|
|
||||||
from suou.itertools import kwargs_prefix
|
from .itertools import kwargs_prefix, makelist
|
||||||
|
from .signing import HasSigner, UserSigner
|
||||||
from .signing import UserSigner
|
|
||||||
from .codecs import StringCase
|
from .codecs import StringCase
|
||||||
from .functools import deprecated
|
from .functools import deprecated
|
||||||
from .iding import SiqType, SiqCache
|
from .iding import SiqType, SiqCache
|
||||||
from .classtools import Incomplete, Wanted
|
from .classtools import Incomplete, Wanted
|
||||||
|
|
||||||
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
# SIQs are 14 bytes long. Storage is padded for alignment
|
# SIQs are 14 bytes long. Storage is padded for alignment
|
||||||
# Not to be confused with SiqType.
|
# Not to be confused with SiqType.
|
||||||
IdType = LargeBinary(16)
|
IdType = LargeBinary(16)
|
||||||
|
|
||||||
|
|
||||||
def sql_escape(s: str, /, dialect: str) -> str:
|
def sql_escape(s: str, /, dialect: Dialect) -> str:
|
||||||
"""
|
"""
|
||||||
Escape a value for SQL embedding, using SQLAlchemy's literal processors.
|
Escape a value for SQL embedding, using SQLAlchemy's literal processors.
|
||||||
Requires a dialect argument.
|
Requires a dialect argument.
|
||||||
|
|
||||||
|
XXX this function is not mature yet, do not use
|
||||||
"""
|
"""
|
||||||
if isinstance(s, str):
|
if isinstance(s, str):
|
||||||
return String().literal_processor(dialect=dialect)(s)
|
return String().literal_processor(dialect=dialect)(s)
|
||||||
raise TypeError('invalid data type')
|
raise TypeError('invalid data type')
|
||||||
|
|
||||||
|
|
||||||
def id_column(typ: SiqType, *, primary_key: bool = True):
|
def id_column(typ: SiqType, *, primary_key: bool = True):
|
||||||
"""
|
"""
|
||||||
Marks a column which contains a SIQ.
|
Marks a column which contains a SIQ.
|
||||||
|
|
@ -109,9 +115,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete
|
||||||
Requires a master secret (taken from Base.metadata), a user id (visible in the token)
|
Requires a master secret (taken from Base.metadata), a user id (visible in the token)
|
||||||
and a user secret.
|
and a user secret.
|
||||||
"""
|
"""
|
||||||
if isinstance(id_attr, Incomplete):
|
if isinstance(id_attr, Column):
|
||||||
raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.')
|
|
||||||
elif isinstance(id_attr, Column):
|
|
||||||
id_val = id_attr
|
id_val = id_attr
|
||||||
elif isinstance(id_attr, str):
|
elif isinstance(id_attr, str):
|
||||||
id_val = Wanted(id_attr)
|
id_val = Wanted(id_attr)
|
||||||
|
|
@ -126,6 +130,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete
|
||||||
return my_signer
|
return my_signer
|
||||||
return Incomplete(Wanted(token_signer_factory))
|
return Incomplete(Wanted(token_signer_factory))
|
||||||
|
|
||||||
|
|
||||||
def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]:
|
def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]:
|
||||||
"""
|
"""
|
||||||
Return an owner ID/signature column pair, for authenticated values.
|
Return an owner ID/signature column pair, for authenticated values.
|
||||||
|
|
@ -136,6 +141,7 @@ def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None =
|
||||||
sig_col = Column(sig_type or LargeBinary(sig_length), nullable = nullable, **sig_ka)
|
sig_col = Column(sig_type or LargeBinary(sig_length), nullable = nullable, **sig_ka)
|
||||||
return (id_col, sig_col)
|
return (id_col, sig_col)
|
||||||
|
|
||||||
|
|
||||||
def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
|
def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
|
||||||
"""
|
"""
|
||||||
Return a SIS-compliant age representation, i.e. a date and accuracy pair.
|
Return a SIS-compliant age representation, i.e. a date and accuracy pair.
|
||||||
|
|
@ -154,7 +160,93 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
|
||||||
return (date_col, acc_col)
|
return (date_col, acc_col)
|
||||||
|
|
||||||
|
|
||||||
|
def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]:
|
||||||
|
"""
|
||||||
|
Return a table's column given its name.
|
||||||
|
|
||||||
|
XXX does it belong outside any scopes?
|
||||||
|
"""
|
||||||
|
if isinstance(col, Incomplete):
|
||||||
|
raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.')
|
||||||
|
elif isinstance(col, Column):
|
||||||
|
return col
|
||||||
|
elif isinstance(col, str):
|
||||||
|
return getattr(cls, col)
|
||||||
|
else:
|
||||||
|
raise TypeError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSrc(metaclass=ABCMeta):
|
||||||
|
'''
|
||||||
|
AuthSrc object required for require_auth_base().
|
||||||
|
|
||||||
|
This is an abstract class and is NOT usable directly.
|
||||||
|
'''
|
||||||
|
def required_exc(self) -> Never:
|
||||||
|
raise ValueError('required field missing')
|
||||||
|
def invalid_exc(self, msg: str = 'validation failed') -> Never:
|
||||||
|
raise ValueError(msg)
|
||||||
|
@abstractmethod
|
||||||
|
def get_session(self) -> Session:
|
||||||
|
pass
|
||||||
|
def get_user(self, getter: Callable):
|
||||||
|
return getter(self.get_token())
|
||||||
|
@abstractmethod
|
||||||
|
def get_token(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, value_src: Callable[[Callable[[], _T]], Any], session_src: Callable[[], Session],
|
||||||
|
column: str | Column[_T] = 'id', dest: str = 'user', required: bool = False, validators: Callable | Iterable[Callable] | None = None,
|
||||||
|
invalid_exc: Callable | None = None, required_exc: Callable | None = None):
|
||||||
|
'''
|
||||||
|
Inject the current user into a view, given the Authorization: Bearer header.
|
||||||
|
|
||||||
|
For portability reasons, this is a partial, two-component function.
|
||||||
|
|
||||||
|
The value_src() callable takes a callable as its only and required argument, and the supplied
|
||||||
|
callable, when called, returns the value of the column.
|
||||||
|
|
||||||
|
The session_src() callable takes no argument, and returns a Session object.
|
||||||
|
|
||||||
|
XXX maybe a class is better than a thousand callables?
|
||||||
|
'''
|
||||||
|
col = want_column(cls, column)
|
||||||
|
validators = makelist(validators)
|
||||||
|
|
||||||
|
def get_user(token) -> DeclarativeBase:
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
tok_parts = UserSigner.split_token(token)
|
||||||
|
user: HasSigner = session_src().execute(select(cls).where(col == tok_parts[0])).scalar()
|
||||||
|
try:
|
||||||
|
signer: UserSigner = user.signer()
|
||||||
|
signer.unsign(token)
|
||||||
|
return user
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _default_invalid(msg: str):
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
invalid_exc = invalid_exc or _default_invalid
|
||||||
|
required_exc = required_exc or (lambda: _default_invalid())
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*a, **ka):
|
||||||
|
ka[dest] = value_src(get_user)
|
||||||
|
if not ka[dest] and required:
|
||||||
|
required_exc()
|
||||||
|
for valid in validators:
|
||||||
|
if not valid(ka[dest]):
|
||||||
|
invalid_exc(getattr(valid, 'message', 'validation failed').format(user=ka[dest]))
|
||||||
|
return func(*a, **ka)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint',
|
'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint',
|
||||||
'author_pair', 'age_pair'
|
'author_pair', 'age_pair', 'require_auth_base', 'want_column'
|
||||||
)
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue