User Shared Keys (#225)

Allows users to create shared keys
This commit is contained in:
Divided by Zer0 authored and GitHub committed 2023-05-08 02:34:08 +02:00
1 parent e547e1599f
commit 8ca3849713
12 files changed
+306 -16

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# Changelog
# 4.12.0
**Added Shared Keys**. Now each user can generate a number of shared keys which they can give to others to use.
When using a shared key to generate, the request pretend act as if it was that user.
However shared keys cannot be used for any other purpose than generating, so they cannot be abused. They are thus meant to be a lower-security option for sharing one's priority, without having to transfer kudos all the time.
Shared keys can be created with an optional limited amount of kudos to use, and/or an expiry date.
Regardless of what the shared key kudos limit is, the request will use the full kudos priority of its owner.
New Endpoints:
* /v2/sharedkeys PUT
* /v2/sharedkeys GET/PATCH/DELETE
Check api documentation for payloads required.
* Shortened limiter for kudos transfers to avoid abuse
# 4.11.2
* Increased stipend for moderators
+17 -1
View File
@@ -183,6 +183,21 @@ class Models:
"awarded": fields.Float(default=0,description="The amount of Kudos this user has been awarded from things like rating images."),
})
self.input_model_sharedkey = api.model('SharedKeyInput', {
"kudos": fields.Integer(min=-1, max=50000000, default=5000, required=False, description="The Kudos limit assigned to this key. If -1, then anyone with this key can use an unlimited amount of kudos from this account."),
"expiry": fields.Integer(min=-1, default=-1, example=30, required=False, description="The amount of days after which this key will expire. If -1, this key will not expire"),
"name": fields.String(min_length=3, max_length=255, required=False, example="Mutual Aid", description="A descriptive name for this key"),
})
self.response_model_sharedkey_details = api.model('SharedKeyDetails', {
"id": fields.String(description="The SharedKey ID"),
"username": fields.String(description="The owning user's unique Username. It is a combination of their chosen alias plus their ID."),
"kudos": fields.Integer(description="The Kudos limit assigned to this key"),
"expiry": fields.DateTime(dt_format='rfc822',description="The date at which this API key will expire."),
"utilized": fields.Integer(description="How mych kudos has been utilized via this shared key until now."),
})
#TODO: Obsolete
self.response_model_contrib_details = api.model('ContributionsDetails', {
"megapixelsteps": fields.Float(description="How many megapixelsteps this user has generated"),
@@ -227,7 +242,8 @@ class Models:
"moderator": fields.Boolean(example=False,description="This user is a Horde moderator."),
"kudos_details": fields.Nested(self.response_model_user_kudos_details),
"worker_count": fields.Integer(description="How many workers this user has created (active or inactive)"),
"worker_ids": fields.List(fields.String(description="Privileged or public when the user has explicitly allows it to be public.")),
"worker_ids": fields.List(fields.String(description="Privileged or public when the user has explicitly allows it to be public.", example="00000000-0000-0000-0000-000000000000")),
"sharedkey_ids": fields.List(fields.String(description="(Privileged) The list of shared key IDs created by this user.", example="00000000-0000-0000-0000-000000000000")),
"monthly_kudos": fields.Nested(self.response_model_monthly_kudos, skip_none=True),
"trusted": fields.Boolean(example=False,description="This user is a trusted member of the Horde."),
"flagged": fields.Boolean(example=False,description="This user has been flagged for suspicious activity."),
+2
View File
@@ -16,6 +16,8 @@ api.add_resource(kobold.TextJobSubmit, "/generate/text/submit")
api.add_resource(base.Users, "/users")
api.add_resource(base.UserSingle, "/users/<string:user_id>")
api.add_resource(base.FindUser, "/find_user")
api.add_resource(base.SharedKey, "/sharedkeys")
api.add_resource(base.SharedKeySingle, "/sharedkeys/<string:sharedkey_id>")
api.add_resource(base.Workers, "/workers")
api.add_resource(base.WorkerSingle, "/workers/<string:worker_id>")
api.add_resource(base.TransferKudos, "/kudos/transfer")
+146 -5
View File
@@ -17,7 +17,7 @@ from horde.limiter import limiter
from horde.logger import logger
from horde.argparser import args
from horde import exceptions as e
from horde.classes.base.user import User
from horde.classes.base.user import User, UserSharedKey
from horde.classes.base.waiting_prompt import WaitingPrompt
from horde.classes.base.worker import Worker
import horde.classes.base.stats as stats
@@ -147,7 +147,14 @@ class GenerateTemplate(Resource):
with HORDE.app_context(): # TODO DOUBLE CHECK THIS
#logger.warning(datetime.utcnow())
if self.args.apikey:
self.user = database.find_user_by_api_key(self.args['apikey'])
self.sharedkey = database.find_sharedkey(self.args.apikey)
if self.sharedkey:
is_valid, error_msg = self.sharedkey.is_valid()
if not is_valid:
raise e.Forbidden(error_msg)
self.user = self.sharedkey.user
if not self.user:
self.user = database.find_user_by_api_key(self.args.apikey)
#logger.warning(datetime.utcnow())
if not self.user:
raise e.InvalidAPIKey('generation')
@@ -244,6 +251,7 @@ class GenerateTemplate(Resource):
censor_nsfw = self.args.censor_nsfw,
trusted_workers = self.args.trusted_workers,
ipaddr = self.user_ip,
sharedkey_id = self.args.apikey if self.sharedkey else None,
)
# We split this into its own function, so that it may be overriden and extended
@@ -493,6 +501,7 @@ class TransferKudos(Resource):
parser.add_argument("username", type=str, required=True, help="The user ID which will receive the kudos", location="json")
parser.add_argument("amount", type=int, required=False, default=100, help="The amount of kudos to transfer", location="json")
decorators = [limiter.limit("3/second", key_func = get_request_path)]
@api.expect(parser)
@api.marshal_with(models.response_model_kudos_transfer, code=200, description='Kudos Transferred')
@api.response(400, 'Validation Error', models.response_model_error)
@@ -1004,10 +1013,22 @@ class FindUser(Resource):
if cached_user:
user_details = json.loads(cached_user)
else:
user = database.find_user_by_api_key(self.args.apikey)
user = database.find_user_by_sharedkey(self.args.apikey)
sharedkey = True
privilege = 0
if not user:
user = database.find_user_by_api_key(self.args.apikey)
sharedkey = False
privilege = 1
if not user:
raise e.UserNotFound(self.args.apikey, 'api_key')
user_details = user.get_details(1)
user_details = user.get_details(privilege)
if sharedkey:
sk = database.find_sharedkey(self.args.apikey)
skname = ''
if sk.name is not None:
skname = f": {sk.name}"
user_details["username"] = user_details["username"] + f" (Shared Key{skname})"
if hr.horde_r:
hr.horde_r_setex_json(cache_name, timedelta(seconds=300), user_details)
return(user_details,200)
@@ -1580,4 +1601,124 @@ class Heartbeat(Resource):
'message': 'OK',
'version': HORDE_VERSION,
},200
class SharedKey(Resource):
put_parser = reqparse.RequestParser()
put_parser.add_argument("apikey", type=str, required=True, help="User API key", location='headers')
put_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version", location="headers")
put_parser.add_argument("kudos", type=int, required=False, default=5000, help="The amount of kudos limit available to this key", location="json")
put_parser.add_argument("expiry", type=int, required=False, default=-1, help="The amount of days which this key will stay active.", location="json")
put_parser.add_argument("name", type=str, required=False, help="A descriptive name for this key", location="json")
decorators = [limiter.limit("5/minute", key_func = get_request_path)]
@api.expect(put_parser, models.input_model_sharedkey)
@api.marshal_with(models.response_model_sharedkey_details, code=200, description='SharedKey Details', skip_none=True)
@api.response(400, 'Validation Error', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Shared Key Not Found', models.response_model_error)
def put(self):
'''Create a new SharedKey for this user
'''
self.args = self.put_parser.parse_args()
user: User = database.find_user_by_api_key(self.args.apikey)
if not user:
raise e.InvalidAPIKey("get sharedkey")
if user.is_anon():
raise e.AnonForbidden
if user.count_sharedkeys() > user.max_sharedkeys():
raise e.Forbidden(f"You cannot have more than {user.max_sharedkeys()} shared keys.")
expiry = None
if self.args.expiry and self.args.expiry != -1:
expiry = datetime.utcnow() + timedelta(days=self.args.expiry)
new_key = UserSharedKey(
user_id = user.id,
kudos = self.args.kudos,
expiry = expiry,
name = self.args.name,
)
db.session.add(new_key)
db.session.commit()
return new_key.get_details(),200
class SharedKeySingle(Resource):
get_parser = reqparse.RequestParser()
get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version", location="headers")
@cache.cached(timeout=60)
@api.expect(get_parser)
@api.marshal_with(models.response_model_sharedkey_details, code=200, description='Shared Key Details', skip_none=True)
@api.response(404, 'Shared Key Not Found', models.response_model_error)
def get(self, sharedkey_id=''):
'''Get details about an existing Shared Key for this user
'''
self.args = self.get_parser.parse_args()
sharedkey = database.find_sharedkey(sharedkey_id)
if not sharedkey:
raise e.InvalidAPIKey("get sharedkey", keytype="Shared")
return sharedkey.get_details(),200
patch_parser = reqparse.RequestParser()
patch_parser.add_argument("apikey", type=str, required=True, help="User API key", location='headers')
patch_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version", location="headers")
patch_parser.add_argument("kudos", type=int, required=False, help="The amount of kudos limit available to this key", location="json")
patch_parser.add_argument("expiry", type=int, required=False, help="The amount of days from today which this key will stay active.", location="json")
patch_parser.add_argument("name", type=str, required=False, help="A descriptive name for this key", location="json")
@api.expect(patch_parser, models.input_model_sharedkey)
@api.marshal_with(models.response_model_sharedkey_details, code=200, description='Shared Key Details', skip_none=True)
@api.response(400, 'Validation Error', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Shared Key Not Found', models.response_model_error)
def patch(self, sharedkey_id=''):
'''Modify an existing Shared Key
'''
self.args = self.patch_parser.parse_args()
sharedkey = database.find_sharedkey(sharedkey_id)
if not sharedkey:
raise e.InvalidAPIKey("Shared Key Not Found.", keytype="Shared")
user = database.find_user_by_api_key(self.args.apikey)
if not user:
raise e.InvalidAPIKey("patch sharedkey")
if sharedkey.user_id != user.id:
raise e.Forbidden(f"Shared Key {sharedkey.id} belongs to {sharedkey.user.get_unique_alias()} and not to {user.get_unique_alias()}.")
if not self.args.expiry and not self.args.kudos and not self.arg.name:
raise e.NoValidActions("No shared key modification selected!")
if self.args.expiry is not None:
if self.args.expiry == -1:
sharedkey.expiry = None
else:
sharedkey.expiry = datetime.utcnow() + timedelta(days=self.args.expiry)
if self.args.kudos is not None:
sharedkey.kudos = self.args.kudos
if self.args.name is not None:
sharedkey.name = self.args.name
db.session.commit()
return sharedkey.get_details(),200
delete_parser = reqparse.RequestParser()
delete_parser.add_argument("apikey", type=str, required=True, help="User API key", location='headers')
delete_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version", location="headers")
@api.expect(delete_parser)
@api.marshal_with(models.response_model_simple_response, code=200, description='Shared Key Deleted')
@api.response(404, 'Shared Key Not Found', models.response_model_error)
def delete(self, sharedkey_id=''):
'''Delete an existing SharedKey for this user
'''
self.args = self.delete_parser.parse_args()
sharedkey = database.find_sharedkey(sharedkey_id)
if not sharedkey:
raise e.InvalidAPIKey("Shared Key Not Found.", keytype="Shared")
user = database.find_user_by_api_key(self.args.apikey)
if not user:
raise e.InvalidAPIKey("delete sharedkey")
if sharedkey.user_id != user.id:
raise e.Forbidden(f"Shared Key {sharedkey.id} belongs to {sharedkey.user.get_unique_alias()} and not to {user.get_unique_alias()}.")
db.session.delete(sharedkey)
db.session.commit()
return {"message": "OK"},200
+8 -1
View File
@@ -51,8 +51,16 @@ class TextAsyncGenerate(GenerateTemplate):
ipaddr = self.user_ip,
safe_ip=True,
client_agent=self.args["Client-Agent"],
sharedkey_id = self.args.apikey if self.sharedkey else None,
)
_, total_threads = database.count_active_workers("text")
required_kudos = round(self.wp.max_length * highest_multiplier / 21, 2) * self.wp.n
if self.sharedkey and required_kudos > self.sharedkey.kudos:
raise e.KudosUpfront(
required_kudos,
self.username,
message=f"This shared key does not have enough remaining kudos ({self.sharedkey.kudos}) to fulfill this reques ({required_kudos})."
)
needs_kudos, tokens = self.wp.require_upfront_kudos(database.retrieve_totals(),total_threads)
if needs_kudos:
if len(self.models) == 0:
@@ -65,7 +73,6 @@ class TextAsyncGenerate(GenerateTemplate):
model_multiplier = model_reference.get_text_model_multiplier(model)
if model_multiplier > highest_multiplier:
highest_multiplier = model_multiplier
required_kudos = round(self.wp.max_length * highest_multiplier / 21, 2) * self.wp.n
if required_kudos > self.user.kudos:
raise e.KudosUpfront(
required_kudos,
+8 -1
View File
@@ -145,11 +145,18 @@ class ImageAsyncGenerate(GenerateTemplate):
r2=self.args.r2,
shared=shared,
client_agent=self.args["Client-Agent"],
sharedkey_id = self.args.apikey if self.sharedkey else None,
)
_, total_threads = database.count_active_workers("image")
required_kudos = self.wp.kudos * self.wp.n
if self.sharedkey and required_kudos > self.sharedkey.kudos:
raise e.KudosUpfront(
required_kudos,
self.username,
message=f"This shared key does not have enough remaining kudos ({self.sharedkey.kudos}) to fulfill this reques ({required_kudos})."
)
needs_kudos,resolution = self.wp.require_upfront_kudos(database.retrieve_totals(),total_threads)
if needs_kudos:
required_kudos = self.wp.kudos * self.wp.n
if required_kudos > self.user.kudos:
raise e.KudosUpfront(
required_kudos,
+60 -2
View File
@@ -3,17 +3,21 @@ import os
import dateutil.relativedelta
from datetime import datetime
from sqlalchemy import Enum, UniqueConstraint,exists, and_
from sqlalchemy import Enum, UniqueConstraint
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.dialects.postgresql import UUID
from horde.logger import logger
from horde.flask import db
from horde.flask import db, SQLITE_MODE
from horde.vars import thing_name, text_thing_divisor, text_thing_name
from horde import vars as hv
from horde.suspicions import Suspicions, SUSPICION_LOGS
from horde.utils import is_profane, sanitize_string, generate_client_id
from horde.patreon import patrons
from horde.enums import UserRecordTypes, UserRoleTypes
from horde.utils import get_db_uuid
uuid_column_type = lambda: UUID(as_uuid=True) if not SQLITE_MODE else db.String(36)
class UserStats(db.Model):
@@ -54,6 +58,46 @@ class UserRole(db.Model):
user_role = db.Column(Enum(UserRoleTypes), nullable=False)
value = db.Column(db.Boolean, default=False, nullable=False)
class UserSharedKey(db.Model):
__tablename__ = "user_sharedkeys"
id = db.Column(uuid_column_type(), primary_key=True, default=get_db_uuid)
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
user = db.relationship("User", back_populates="sharedkeys")
kudos = db.Column(db.BigInteger, default=5000, nullable=False)
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
expiry = db.Column(db.DateTime, index=True)
name = db.Column(db.String(255), nullable=True)
utilized = db.Column(db.BigInteger, default=0, nullable=False)
waiting_prompts = db.relationship("WaitingPrompt", back_populates="sharedkey", passive_deletes=True, cascade="all, delete-orphan")
@logger.catch(reraise=True)
def get_details(self):
ret_dict = {
"username": self.user.get_unique_alias(),
"id": self.id,
"kudos": self.kudos,
"expiry": self.expiry,
"utilized": self.utilized,
}
return ret_dict
def consume_kudos(self, kudos):
if self.kudos == 0:
return
if self.kudos != -1:
self.kudos = round(self.kudos - kudos, 2)
self.utilized = round(self.utilized + kudos, 2)
logger.debug(f"Utilized {kudos} from shared key {self.id}. {self.kudos} remaining.")
db.session.commit()
def is_valid(self):
if self.kudos == 0:
return False,"This shared key has run out of kudos."
if self.expiry is not None and self.expiry < datetime.utcnow():
return False,"This shared key has expired"
else:
return True, None
class User(db.Model):
__tablename__ = "users"
SUSPICION_THRESHOLD = 5
@@ -81,6 +125,7 @@ class User(db.Model):
workers = db.relationship(f"Worker", back_populates="user", cascade="all, delete-orphan")
teams = db.relationship(f"Team", back_populates="owner", cascade="all, delete-orphan")
sharedkeys = db.relationship(f"UserSharedKey", back_populates="user", cascade="all, delete-orphan")
suspicions = db.relationship("UserSuspicions", back_populates="user", cascade="all, delete-orphan")
records = db.relationship("UserRecords", back_populates="user", cascade="all, delete-orphan")
roles = db.relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
@@ -511,6 +556,14 @@ class User(db.Model):
def count_workers(self):
return(len(self.workers))
def count_sharedkeys(self):
return(len(self.sharedkeys))
def max_sharedkeys(self):
if self.trusted:
return 10
return 3
def is_suspicious(self):
if self.trusted:
return(False)
@@ -599,6 +652,11 @@ class User(db.Model):
workers_array.append(str(worker.id))
ret_dict["worker_ids"] = workers_array
ret_dict['contact'] = self.contact
if details_privilege >= 1:
sharedkeys_array = []
for sk in self.sharedkeys:
sharedkeys_array.append(str(sk.id))
ret_dict["sharedkey_ids"] = sharedkeys_array
if details_privilege >= 2:
mk_dict = {
"amount": self.calculate_monthly_kudos(),
+5 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy import JSON, func, or_
from horde.logger import logger
from horde.flask import db, SQLITE_MODE
from horde import vars as hv
from horde.utils import is_profane, get_db_uuid, get_expiry_date, get_db_uuid
from horde.utils import is_profane, get_db_uuid, get_expiry_date
from horde.classes.base.processing_generation import ProcessingGeneration
from horde.classes.stable.processing_generation import ImageProcessingGeneration
@@ -82,6 +82,8 @@ class WaitingPrompt(db.Model):
extra_priority = db.Column(db.Integer, default=0, nullable=False, index=True)
job_ttl = db.Column(db.Integer, default=150, nullable=False)
client_agent = db.Column(db.Text, default="unknown:0:unknown", nullable=False)
sharedkey_id = db.Column(uuid_column_type(), db.ForeignKey("user_sharedkeys.id", ondelete="CASCADE"), nullable=True)
sharedkey = db.relationship("UserSharedKey", back_populates="waiting_prompts")
tricked_workers = db.relationship("WPTrickedWorkers", back_populates="wp", passive_deletes=True, cascade="all, delete-orphan")
workers = db.relationship("WPAllowedWorkers", back_populates="wp", passive_deletes=True, cascade="all, delete-orphan")
@@ -296,6 +298,8 @@ class WaitingPrompt(db.Model):
We use 'thing' here as we do not care what type of thing we're recording at this point
This avoids me having to extend this just to change a var name
'''
if self.sharedkey_id is not None:
self.sharedkey.consume_kudos(kudos)
self.user.record_usage(raw_things, kudos, usage_type)
self.consumed_kudos = round(self.consumed_kudos + kudos,2)
self.refresh()
+1 -1
View File
@@ -1,4 +1,4 @@
HORDE_VERSION = "4.11.2"
HORDE_VERSION = "4.12.0"
WHITELISTED_SERVICE_IPS = {
"212.227.227.178", # Turing Bot
+33 -1
View File
@@ -16,7 +16,7 @@ from horde import vars as hv
from horde.classes.base.worker import WorkerPerformance
from horde.classes.stable.worker import ImageWorker
from horde.classes.kobold.worker import TextWorker
from horde.classes.base.user import User, UserRecords
from horde.classes.base.user import User, UserRecords, UserSharedKey
from horde.classes.stable.waiting_prompt import ImageWaitingPrompt
from horde.classes.stable.processing_generation import ImageProcessingGeneration
from horde.classes.kobold.waiting_prompt import TextWaitingPrompt
@@ -175,6 +175,38 @@ def find_user_by_api_key(api_key):
user = db.session.query(User).filter_by(api_key=hash_api_key(api_key)).first()
return user
def find_user_by_sharedkey(shared_key):
try:
sharedkey_uuid = uuid.UUID(shared_key)
except ValueError as e:
logger.debug(f"Non-UUID sharedkey_id sent: '{shared_key}'.")
return None
if SQLITE_MODE:
sharedkey_uuid = str(sharedkey_uuid)
user = db.session.query(
User
).join(
UserSharedKey
).filter(
UserSharedKey.id == shared_key
).first()
return user
def find_sharedkey(shared_key):
try:
sharedkey_uuid = uuid.UUID(shared_key)
except ValueError as e:
logger.debug(f"Non-UUID sharedkey_id sent: '{shared_key}'.")
return None
if SQLITE_MODE:
sharedkey_uuid = str(sharedkey_uuid)
sharedkey = db.session.query(
UserSharedKey
).filter(
UserSharedKey.id == shared_key
).first()
return sharedkey
def find_worker_by_name(worker_name, worker_class=ImageWorker):
worker = db.session.query(worker_class).filter_by(name=worker_name).first()
return worker
+6 -3
View File
@@ -103,9 +103,12 @@ class InvalidAestheticAttempt(wze.BadRequest):
self.log = None
class InvalidAPIKey(wze.Unauthorized):
def __init__(self, subject):
self.specific = "No user matching sent API Key. Have you remembered to register at https://stablehorde.net/register ?"
self.log = f"Invalid API Key sent for {subject}"
def __init__(self, subject, keytype='API'):
if keytype == "Shared":
self.specific = "No user matching sent Shared Key."
else:
self.specific = "No user matching sent API Key. Have you remembered to register at https://stablehorde.net/register ?"
self.log = f"Invalid {keytype} Key sent for {subject}"
class WrongCredentials(wze.Forbidden):
def __init__(self, username, worker):
+1
View File
@@ -0,0 +1 @@
ALTER TABLE waiting_prompts ADD COLUMN sharedkey_id UUID;